From f3bdadad66d3d7d5ff83675f020505fb780c0165 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Tue, 20 Oct 2026 01:32:01 +0200 Subject: [PATCH] feat(terrain): holes cut in the heightmap terrain and shader grass painted over it --- CMakeLists.txt | 1 + application/editormode.cpp | 197 +++++++++++- application/editormode.h | 15 +- application/editormodeselftest.cpp | 5 + application/editormodeselftestterrain.cpp | 75 +++++ application/editormodeviewport.cpp | 4 + editor/editorIcons.hpp | 3 + editor/editorTerrain.cpp | 13 +- editor/editorTerrain.hpp | 12 +- editor/editorTerrainStreamer.cpp | 356 +++++++++++++++++++++- editor/editorTerrainStreamer.hpp | 29 ++ editor/icons/editor_icons.png | Bin 46348 -> 55924 bytes editor/icons/editor_icons.py | 18 +- lang/editor_pl.po | 125 ++++++++ rendering/geometrybank.cpp | 28 ++ rendering/geometrybank.h | 4 + rendering/opengl46/opengl46grass.cpp | 303 ++++++++++++++++++ rendering/opengl46/opengl46grass.h | 85 ++++++ rendering/opengl46/opengl46renderer.cpp | 54 +++- rendering/opengl46/opengl46renderer.h | 11 +- rendering/opengl46/opengl46terrain.cpp | 45 ++- rendering/opengl46/opengl46terrain.h | 8 +- rendering/renderer.cpp | 2 +- rendering/renderer.h | 44 ++- scene/heightmapterrain.cpp | 256 +++++++++++++++- scene/heightmapterrain.h | 66 +++- shaders/grass.frag | 41 +++ shaders/grass.vert | 206 +++++++++++++ shaders/mat_terrain.frag | 10 + shaders/mat_terrain_holes.frag | 9 + utilities/Globals.cpp | 20 ++ utilities/Globals.h | 5 + 32 files changed, 2020 insertions(+), 30 deletions(-) create mode 100644 rendering/opengl46/opengl46grass.cpp create mode 100644 rendering/opengl46/opengl46grass.h create mode 100644 shaders/grass.frag create mode 100644 shaders/grass.vert create mode 100644 shaders/mat_terrain_holes.frag diff --git a/CMakeLists.txt b/CMakeLists.txt index 25104d32..31cb327c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -394,6 +394,7 @@ if (WITH_OPENGL_MODERN) "rendering/opengl46/opengl46precipitation.cpp" "rendering/opengl46/opengl46particles.cpp" "rendering/opengl46/opengl46terrain.cpp" + "rendering/opengl46/opengl46grass.cpp" "rendering/opengl46/opengl46renderer.cpp") set(DEFINITIONS ${DEFINITIONS} "WITH_OPENGL_MODERN") endif() diff --git a/application/editormode.cpp b/application/editormode.cpp index 96759802..d7bd72bd 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -1763,6 +1763,17 @@ void editor_mode::render_terrain_ui() ImGui::TextDisabled("%s", STR_C("A chunk holds up to 8 materials")); } + if (ImGui::CollapsingHeader(STR_C("Grass"), m_terrain_tool == terrain_tool::grass ? ImGuiTreeNodeFlags_DefaultOpen : 0)) + render_grass_ui(); + + if (ImGui::CollapsingHeader(STR_C("Holes"), m_terrain_tool == terrain_tool::holes ? ImGuiTreeNodeFlags_DefaultOpen : 0)) + { + ImGui::TextWrapped("%s", STR_C("Openings in the ground for the portals of tunnels, cuttings under bridges and the like. " + "With the Holes tool LMB cuts the ground under the brush and Shift+LMB closes it; " + "the brush stays on the ground also over an opening. An area outlined with the Fill tool can be cut or closed in the Fill section.")); + ImGui::TextDisabled("%s", STR_C("The holes follow the ground every metre (every grid point of finer chunks), whatever the drawn grid")); + } + if (ImGui::CollapsingHeader(STR_C("Fill"), m_terrain_tool == terrain_tool::fill ? ImGuiTreeNodeFlags_DefaultOpen : 0)) { auto const &layers{m_streamer.manifest().layers}; @@ -1790,6 +1801,49 @@ void editor_mode::render_terrain_ui() ImGui::SameLine(); if (ImGui::Button(STR_C("Clear the outline##fill"))) m_paint_outline.clear(); + // the same outline for the holes and the grass + std::vector outline; + for (auto const &point : m_paint_outline) + outline.emplace_back(point.x, point.z); + ImGui::BeginDisabled(outline.size() < 3); + if (ImGui::Button(STR_C("Cut a hole"))) + { + auto const cut = m_streamer.cut(outline, true); + m_terrain_status = cut ? std::string(STR_C("Hole cut in the outlined area")) : std::string(STR_C("Nothing cut: no chunk under the outline")); + if (cut) + { + ground_tiles.clear(); + forget_ground_shapes(); + } + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Cuts the ground within the outline away, e.g. for the portal of a tunnel")); + ImGui::SameLine(); + if (ImGui::Button(STR_C("Close the hole"))) + { + auto const closed = m_streamer.cut(outline, false); + m_terrain_status = closed ? std::string(STR_C("Hole closed in the outlined area")) : std::string(STR_C("No hole under the outline")); + if (closed) + { + ground_tiles.clear(); + forget_ground_shapes(); + } + } + auto const &grasskinds = m_streamer.grass_kinds(); + ImGui::BeginDisabled(grasskinds.empty()); + if (ImGui::Button(STR_C("Grow grass"))) + { + auto const kind = static_cast(std::clamp(m_grass_kind, 0, std::max(0, static_cast(grasskinds.size()) - 1))); + m_terrain_status = m_streamer.grass(outline, kind, false) ? format(STR_C("Grass grown: %s"), grasskinds[kind].name.c_str()) + : std::string(STR_C("Nothing grown: no chunk under the outline, or no room for another kind of grass")); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Grows the kind of grass chosen in the Grass section within the outline")); + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button(STR_C("Clear the grass"))) + m_terrain_status = m_streamer.grass(outline, 0, true) ? std::string(STR_C("Grass taken away from the outlined area")) : std::string(STR_C("No grass under the outline")); + ImGui::EndDisabled(); } if (ImGui::CollapsingHeader(STR_C("Water"), m_terrain_tool == terrain_tool::water ? ImGuiTreeNodeFlags_DefaultOpen : 0)) @@ -2000,8 +2054,12 @@ void editor_mode::draw_terrain_overlay() } }; - if (terrain_brush() && hovering) + // the holes and the grass are aimed at the ground itself, also over an opening and behind the blades + glm::dvec3 brushcentre = cursor; + bool const onground = (m_terrain_tool == terrain_tool::holes || m_terrain_tool == terrain_tool::grass) && false == ImGui::GetIO().WantCaptureMouse && terrain_cursor(brushcentre); + if (terrain_brush() && (hovering || onground)) { + auto const cursor = onground ? brushcentre : Camera.Pos + offset; std::vector circle; for (int i = 0; i < 64; ++i) { @@ -2011,6 +2069,8 @@ void editor_mode::draw_terrain_overlay() circle.emplace_back(x, ground(x, z, cursor.y) + 0.1, z); } auto const color = m_terrain_tool == terrain_tool::paint ? IM_COL32(120, 230, 120, 220) + : m_terrain_tool == terrain_tool::grass ? IM_COL32(170, 255, 90, 220) + : m_terrain_tool == terrain_tool::holes ? IM_COL32(255, 90, 90, 230) : m_terrain_tool == terrain_tool::smooth ? IM_COL32(120, 200, 255, 220) : m_terrain_tool == terrain_tool::restore ? IM_COL32(230, 120, 230, 220) : IM_COL32(255, 180, 80, 220); @@ -3192,9 +3252,15 @@ void editor_mode::handle_terrain_brush(double Deltatime) { // world point under the cursor (Mouse_Position is camera-relative, like the brush placement uses) glm::dvec3 const offset = GfxRenderer->Mouse_Position(); - if (glm::length(offset) < 1e-3) + glm::dvec3 world = Camera.Pos + offset; + if (m_terrain_tool == terrain_tool::holes || m_terrain_tool == terrain_tool::grass) + { + // the ground itself: the cursor may be over a hole (and what's seen through it), or over the blades of the grass + if (false == terrain_cursor(world)) + return; + } + else if (glm::length(offset) < 1e-3) return; - glm::dvec3 const world = Camera.Pos + offset; // only when the cursor is over the terrain (avoids editing on a stale depth read) if (terrain_at(world.x, world.z) == nullptr) return; @@ -3227,6 +3293,22 @@ void editor_mode::handle_terrain_brush(double Deltatime) case terrain_tool::restore: shaped = m_streamer.restore_stitching(world.x, world.z, m_terrain_brush_radius, std::min(1.0, rate * 0.5)); break; + case terrain_tool::holes: + // the ground there is no longer ground for the tools which ask about it + shaped = m_streamer.cut(world.x, world.z, m_terrain_brush_radius, false == Global.shiftState); + break; + case terrain_tool::grass: + { + if (m_streamer.grass_kinds().empty() && false == Global.shiftState) + { + // the first kind of the presets, so the brush grows something + m_streamer.add_grass_kind(heightmap::grass_presets().front()); + m_grass_kind = 0; + } + m_grass_kind = std::clamp(m_grass_kind, 0, std::max(0, static_cast(m_streamer.grass_kinds().size()) - 1)); + m_streamer.grass(world.x, world.z, m_terrain_brush_radius, std::min(1.0, rate * 0.25), static_cast(m_grass_kind), Global.shiftState); + break; + } default: break; } @@ -3238,6 +3320,115 @@ void editor_mode::handle_terrain_brush(double Deltatime) } } +bool editor_mode::terrain_cursor(glm::dvec3 &World) const +{ + // along the ray to what the depth under the cursor shows, or, with nothing there, the ray through the cursor + glm::dvec3 const offset = GfxRenderer->Mouse_Position(); + if (glm::length(offset) > 1e-3) + return m_streamer.raycast(Camera.Pos, glm::normalize(offset), 5000.0, World); + ImGuiIO const &io = ImGui::GetIO(); + if (io.DisplaySize.x <= 0.0f || io.DisplaySize.y <= 0.0f) + return false; + auto const inverse = glm::inverse(editor_mode::projection_matrix(io.DisplaySize.x / io.DisplaySize.y) * GfxRenderer->Camera_View_Matrix()); + glm::vec2 const ndc{io.MousePos.x / io.DisplaySize.x * 2.0f - 1.0f, 1.0f - io.MousePos.y / io.DisplaySize.y * 2.0f}; + // two points of the ray, whatever the depth range of the projection + auto const point = [&](float const Depth) { + auto const clip = inverse * glm::vec4(ndc, Depth, 1.0f); + return glm::dvec3{glm::vec3{clip} / clip.w}; + }; + auto const direction = point(0.5f) - point(0.0f); + if (glm::length(direction) < 1e-9) + return false; + return m_streamer.raycast(GfxRenderer->Camera_Position() + point(0.0f), glm::normalize(direction), 5000.0, World); +} + +void editor_mode::render_grass_ui() +{ + auto const &kinds = m_streamer.grass_kinds(); + if (kinds.empty()) + ImGui::TextWrapped("%s", STR_C("No kinds of grass yet. Add one of the presets below; the first stroke of the brush adds a meadow.")); + m_grass_kind = std::clamp(m_grass_kind, 0, std::max(0, static_cast(kinds.size()) - 1)); + for (std::size_t i = 0; i < kinds.size(); ++i) + { + ImGui::PushID(static_cast(i)); + // a swatch of the colours of the kind, from the root to the tip + auto *drawlist = ImGui::GetWindowDrawList(); + auto const corner = ImGui::GetCursorScreenPos(); + auto const height = ImGui::GetTextLineHeight(); + auto const colour = [](glm::vec3 const &Colour) { return ImGui::ColorConvertFloat4ToU32(ImVec4(Colour.r, Colour.g, Colour.b, 1.0f)); }; + drawlist->AddRectFilledMultiColor(corner, ImVec2(corner.x + height, corner.y + height), colour(kinds[i].tip), colour(kinds[i].tip), colour(kinds[i].base), colour(kinds[i].base)); + ImGui::Dummy(ImVec2(height, height)); + ImGui::SameLine(); + if (ImGui::Selectable(format(STR_C("%zu: %s, %.2f m, %.0f blades/m2"), i, kinds[i].name.c_str(), kinds[i].height, kinds[i].density).c_str(), static_cast(i) == m_grass_kind)) + m_grass_kind = static_cast(i); + ImGui::PopID(); + } + if (false == kinds.empty()) + { + // the chosen kind, given to the palette once a field is left + if (m_grass_edit_of != m_grass_kind || false == m_grass_edit_active) + { + m_grass_edit = kinds[m_grass_kind]; + m_grass_edit_of = m_grass_kind; + } + auto &kind = m_grass_edit; + bool changed = false, active = false; + auto const field = [&](bool const Edited) { + changed |= ImGui::IsItemDeactivatedAfterEdit() || (Edited && false == ImGui::IsItemActive()); + active |= ImGui::IsItemActive(); + }; + char name[64]; + std::snprintf(name, sizeof(name), "%s", kind.name.c_str()); + ImGui::SetNextItemWidth(160.0f); + if (ImGui::InputText(STR_C("Name##grass"), name, sizeof(name))) + kind.name = name; + field(false); + ImGui::SetNextItemWidth(200.0f); + field(ImGui::ColorEdit3(STR_C("Root colour"), &kind.base.r, ImGuiColorEditFlags_NoInputs)); + ImGui::SameLine(); + field(ImGui::ColorEdit3(STR_C("Tip colour"), &kind.tip.r, ImGuiColorEditFlags_NoInputs)); + ImGui::SetNextItemWidth(120.0f); + field(ImGui::DragFloat(STR_C("Height (m)##grass"), &kind.height, 0.005f, 0.02f, 2.0f, "%.2f")); + ImGui::SetNextItemWidth(120.0f); + field(ImGui::SliderFloat(STR_C("Height varies by"), &kind.variation, 0.0f, 1.0f, "%.2f")); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Share of the height the blades differ by, either way")); + ImGui::SetNextItemWidth(120.0f); + field(ImGui::DragFloat(STR_C("Blade width (m)"), &kind.width, 0.0002f, 0.002f, 0.05f, "%.3f m")); + ImGui::SetNextItemWidth(120.0f); + field(ImGui::DragFloat(STR_C("Density (blades/m2)"), &kind.density, 0.5f, 1.0f, 300.0f, "%.0f")); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("Blades on a square metre painted fully. The renderer draws fewer further away, and wider;\n" + "gfx.grass.distance and gfx.grass.density in eu07.ini set how far and how many")); + ImGui::SetNextItemWidth(120.0f); + field(ImGui::SliderFloat(STR_C("Lean"), &kind.bend, 0.0f, 1.0f, "%.2f")); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", STR_C("How far the tips of the blades lean over, share of their height; the wind adds to it")); + m_grass_edit_active = active; + if (changed) + { + kind.height = std::max(0.02f, kind.height); + kind.width = std::max(0.002f, kind.width); + kind.density = std::max(1.0f, kind.density); + m_streamer.grass_kind(static_cast(m_grass_kind), kind); + } + } + // the presets, added to the palette + ImGui::TextUnformatted(STR_C("Add:")); + for (auto const &preset : heightmap::grass_presets()) + { + ImGui::SameLine(); + if (ImGui::SmallButton(STR(preset.name.c_str()).c_str())) + { + m_grass_kind = m_streamer.add_grass_kind(preset); + m_grass_edit_of = -1; + } + } + ImGui::TextDisabled("%s", STR_C("A chunk holds up to 4 kinds of grass. LMB grows the chosen kind, Shift+LMB takes all grass away")); + if (false == Global.gfx_grass) + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", STR_C("The grass isn't drawn: gfx.grass is off")); +} + void editor_mode::fill_terrain_area() { auto const &layers = m_streamer.manifest().layers; diff --git a/application/editormode.h b/application/editormode.h index 4ef8dac9..653d70ae 100644 --- a/application/editormode.h +++ b/application/editormode.h @@ -436,14 +436,21 @@ class editor_mode : public application_mode, private editor_track::observer spacing, // selects chunks (Ctrl: adds or takes away one), whose point spacing is then converted water, // outlines a body of water restore, // takes the touch-ups over the modifiers back - fill // outlines an area filled with a material + fill, // outlines an area filled with a material + holes, // cuts holes in the ground (portals of tunnels), Shift: closes them + grass // grows the chosen kind of grass, Shift: takes the grass away }; terrain_tool m_terrain_tool{terrain_tool::none}; bool terrain_brush() const { return m_terrain_tool == terrain_tool::sculpt || m_terrain_tool == terrain_tool::smooth || m_terrain_tool == terrain_tool::level || m_terrain_tool == terrain_tool::paint || - m_terrain_tool == terrain_tool::restore; + m_terrain_tool == terrain_tool::restore || m_terrain_tool == terrain_tool::holes || m_terrain_tool == terrain_tool::grass; } + // the point of the ground of the terrain under the cursor, found along the ray through the cursor, so it's found also over + // a hole and behind the grass. returns: false if the ray meets no terrain + bool terrain_cursor(glm::dvec3 &World) const; + // the grass of the terrain: the palette, the kind grown, presets + void render_grass_ui(); // converts the point spacing of the selected chunks to m_terrain_convert_spacing void convert_selected_chunks(); // takes the height of whatever is under the cursor (terrain, track, model) for the target of the level brush. returns: false if there's nothing @@ -552,6 +559,10 @@ class editor_mode : public application_mode, private editor_track::observer bool m_terrain_stroke{false}; // a stroke of the brush is under way bool m_terrain_picking{false}; // the next click picks the target height int m_terrain_layer{0}; // entry of the palette painted + int m_grass_kind{0}; // entry of the grass palette grown + heightmap::grass_def m_grass_edit; // the chosen kind of grass while it's edited + int m_grass_edit_of{-1}; // the kind in m_grass_edit, -1: none + bool m_grass_edit_active{false}; // a field of the chosen kind is being dragged char m_terrain_material[128]{"grass"}; // material added to the palette float m_terrain_material_size{0.0f}; // metres its textures repeat at, 0: given by the material float m_terrain_layer_size{0.0f}; // repeat of the chosen palette entry, while it's dragged diff --git a/application/editormodeselftest.cpp b/application/editormodeselftest.cpp index 2a7909bd..e163b632 100644 --- a/application/editormodeselftest.cpp +++ b/application/editormodeselftest.cpp @@ -115,6 +115,11 @@ editor_mode::selftest_result editor_mode::selftest_dispatch(std::string const &c // terrainfill ... the area outlined filled, as the fill tool does it // terrainturn the textures of the palette entry turned // terrainwater [size] material and repeat of a body of water; terrainwaterdefault +// terraincut <1 cut|0 close> a stroke of the holes brush; terraincutarea <1|0> ... the area outlined +// terraingrasskind a preset added to the grass palette; terraingrass [erase 0|1] a stroke +// of the grass brush; terraingrassarea ... the area outlined +// terraincursor logs the point of the ground under the cursor, found along the ray (over holes too) +// terraintool the tool the left button works on the terrain, with a brush of 3 m // orthophoto [in scene 0|1] the imagery layer on, placed and fitted as given // join [radius] [variant] [transitions 0|1] the free ends at the points joined, as a drag from one to the other // layto from the free end at the first point towards the second, as a drag in the layout diff --git a/application/editormodeselftestterrain.cpp b/application/editormodeselftestterrain.cpp index 8f2c66ca..3380a8a1 100644 --- a/application/editormodeselftestterrain.cpp +++ b/application/editormodeselftestterrain.cpp @@ -408,6 +408,69 @@ editor_mode::selftest_result editor_mode::selftest_terrain(std::string const &co WriteLog("SELFTEST terrainadd: " + m_terrain_status); return selftest_result::handled; } + if (command == "terraincut") + { + double x{0.0}; + double z{0.0}; + double radius{2.0}; + int cut{1}; + words >> x >> z >> radius >> cut; + auto const changed{m_streamer.cut(x, z, radius, cut != 0)}; + forget_ground(); + WriteLog(format("SELFTEST terraincut %.1f %.1f: %s", x, z, changed ? "changed" : "nothing changed")); + return selftest_result::handled; + } + if (command == "terraincutarea" || command == "terraingrassarea") + { + int value{1}; + words >> value; + std::vector outline; + double x, z; + while (words >> x >> z) + outline.emplace_back(x, z); + auto const changed{command == "terraincutarea" ? m_streamer.cut(outline, value != 0) : m_streamer.grass(outline, static_cast(std::max(0, value)), value < 0)}; + forget_ground(); + WriteLog(format("SELFTEST %s: %s", command.c_str(), changed ? "changed" : "nothing changed")); + return selftest_result::handled; + } + if (command == "terraingrasskind") + { + std::string name; + std::getline(words >> std::ws, name); + for (auto const &preset : heightmap::grass_presets()) + if (preset.name == name) + m_grass_kind = m_streamer.add_grass_kind(preset); + WriteLog(format("SELFTEST terraingrasskind %s: %zu kinds", name.c_str(), m_streamer.grass_kinds().size())); + return selftest_result::handled; + } + if (command == "terraingrass") + { + double x{0.0}; + double z{0.0}; + double radius{5.0}; + int kind{0}; + int erase{0}; + words >> x >> z >> radius >> kind >> erase; + auto const changed{m_streamer.grass(x, z, radius, 1.0, static_cast(kind), erase != 0)}; + WriteLog(format("SELFTEST terraingrass %.1f %.1f: %s", x, z, changed ? "changed" : "nothing changed")); + return selftest_result::handled; + } + if (command == "terraintool") + { + std::string name; + words >> name; + m_terrain_tool = name == "holes" ? terrain_tool::holes : name == "grass" ? terrain_tool::grass : name == "paint" ? terrain_tool::paint : name == "sculpt" ? terrain_tool::sculpt : terrain_tool::none; + m_terrain_brush_radius = 3.0f; + WriteLog("SELFTEST terraintool " + name); + return selftest_result::handled; + } + if (command == "terraincursor") + { + glm::dvec3 world; + auto const found{terrain_cursor(world)}; + WriteLog(format("SELFTEST terraincursor: %s %.2f %.2f %.2f", found ? "ground" : "none", world.x, world.y, world.z)); + return selftest_result::handled; + } if (command == "terrainprobe") { double x{0.0}; @@ -435,6 +498,18 @@ editor_mode::selftest_result editor_mode::selftest_terrain(std::string const &co if (false == data.adjust.empty()) adjust = data.height_in(data.adjust, x, z); grid = format(", spacing %.1f, paint %d, base %s %.3f, touch-up %.3f", data.spacing(), data.paint, data.base.empty() ? "none" : "", base, adjust); + // the holes and the grass at the point + grid += format(", holes %d%s, grass kinds %zu", data.holes, data.hole_at(x, z) ? " (cut here)" : "", data.grass_count()); + if (data.grass > 0) + { + auto const corner{data.corner()}; + auto const ix{std::clamp(static_cast(std::lround((x - corner.x) / heightmap::chunk_size * data.grass)), 0, data.grass)}; + auto const iz{std::clamp(static_cast(std::lround((z - corner.y) / heightmap::chunk_size * data.grass)), 0, data.grass)}; + auto const plane{static_cast(data.grass + 1) * (data.grass + 1)}; + grid += ", grass"; + for (std::size_t slot = 0; slot < data.grass_count(); ++slot) + grid += format(" %u:%d", data.grass_kinds[slot], data.grass_density[slot * plane + static_cast(iz) * (data.grass + 1) + ix]); + } if (data.paint > 0) { // weights at the paint sample nearest the point diff --git a/application/editormodeviewport.cpp b/application/editormodeviewport.cpp index efa81240..2e3dff19 100644 --- a/application/editormodeviewport.cpp +++ b/application/editormodeviewport.cpp @@ -229,10 +229,14 @@ void editor_mode::work_area_tools(std::vector &Buttons) Buttons.push_back({icon::profile, STR_C("Level"), nullptr, STR_C("LMB leads the terrain under the brush to the target height, Ctrl+LMB takes the target height from what's under the cursor"), chosen(terrain_tool::level), choose(terrain_tool::level)}); Buttons.push_back({icon::brush, STR_C("Paint"), nullptr, STR_C("LMB paints the material chosen in the palette, Shift+LMB the first material of the palette"), chosen(terrain_tool::paint), choose(terrain_tool::paint)}); + Buttons.push_back({icon::grass, STR_C("Grass"), nullptr, STR_C("LMB grows the kind of grass chosen in the grass palette, Shift+LMB takes the grass away"), chosen(terrain_tool::grass), + choose(terrain_tool::grass)}); Buttons.push_back({icon::chunks, STR_C("Chunks"), nullptr, STR_C("LMB adds a chunk next to the clicked one, Shift+LMB removes it"), chosen(terrain_tool::chunks), choose(terrain_tool::chunks)}); Buttons.push_back({icon::area_fill, STR_C("Water"), nullptr, STR_C("LMB adds a point of the outline of a body of water, Shift+LMB takes the last one back"), chosen(terrain_tool::water), choose(terrain_tool::water)}); Buttons.push_back({icon::count, STR_C("Fill"), nullptr, STR_C("LMB adds a point of the outline of an area to fill with a material, Shift+LMB takes the last one back"), chosen(terrain_tool::fill), choose(terrain_tool::fill)}); + Buttons.push_back({icon::hole, STR_C("Holes"), nullptr, STR_C("LMB cuts a hole in the terrain under the brush (portals of tunnels), Shift+LMB closes it"), chosen(terrain_tool::holes), + choose(terrain_tool::holes)}); auto const orthophoto{m_orthophoto.enabled()}; Buttons.push_back({icon::orthophoto, STR_C("Orthophoto"), nullptr, STR_C("Aerial imagery of geoportal.gov.pl under the scenery, laid out by the origin of the scenery"), orthophoto, [this, orthophoto]() { m_orthophoto.enabled(false == orthophoto); }, true}); diff --git a/editor/editorIcons.hpp b/editor/editorIcons.hpp index eec9f4a8..3a5f1ba0 100644 --- a/editor/editorIcons.hpp +++ b/editor/editorIcons.hpp @@ -57,6 +57,9 @@ enum class icon work_tracks, work_roads, work_terrain, + // the tools of the terrain added later, at the end so the other cells stay where they were + hole, + grass, count }; diff --git a/editor/editorTerrain.cpp b/editor/editorTerrain.cpp index 16daaf1e..8b39ba9e 100644 --- a/editor/editorTerrain.cpp +++ b/editor/editorTerrain.cpp @@ -10,6 +10,15 @@ http://mozilla.org/MPL/2.0/. #include "stdafx.h" #include "editor/editorTerrain.hpp" +#include + +namespace +{ +std::atomic serials{0}; +} + +editor_terrain::editor_terrain(heightmap::chunk_ptr Data) : m_data(std::move(Data)), m_serial(++serials) {} + heightmap::chunk_data &editor_terrain::edit(bool const Shape, bool const Paint) { // a worker may still be making a mesh of the current data, so a copy is changed instead @@ -24,7 +33,7 @@ heightmap::chunk_data &editor_terrain::edit(bool const Shape, bool const Paint) return const_cast(*m_data); } -void editor_terrain::replace(heightmap::chunk_ptr Data, bool const Shape, bool const Paint) +void editor_terrain::replace(heightmap::chunk_ptr Data, bool const Shape, bool const Paint, bool const Grass) { m_data = std::move(Data); m_modified = true; @@ -32,6 +41,8 @@ void editor_terrain::replace(heightmap::chunk_ptr Data, bool const Shape, bool c ++m_shapeversion; if (Paint) ++m_paintversion; + if (Grass) + ++m_grassversion; } glm::dvec3 editor_terrain::centre() const diff --git a/editor/editorTerrain.hpp b/editor/editorTerrain.hpp index d26c4c52..310e0fc5 100644 --- a/editor/editorTerrain.hpp +++ b/editor/editorTerrain.hpp @@ -28,14 +28,18 @@ namespace scene { class basic_section; } class editor_terrain { public: - explicit editor_terrain(heightmap::chunk_ptr Data) : m_data(std::move(Data)) {} + explicit editor_terrain(heightmap::chunk_ptr Data); heightmap::chunk_data const &data() const { return *m_data; } heightmap::chunk_ptr const &shared() const { return m_data; } // the data, to be changed. marks the chunk as edited; Shape: the heights changed, Paint: the materials changed heightmap::chunk_data &edit(bool const Shape, bool const Paint); - // puts provided data in place of the current one - void replace(heightmap::chunk_ptr Data, bool const Shape, bool const Paint); + // puts provided data in place of the current one; Grass: the grass changed + void replace(heightmap::chunk_ptr Data, bool const Shape, bool const Paint, bool const Grass = false); + // tells this chunk from any other, also from the same chunk loaded again + std::uint64_t serial() const { return m_serial; } + // changes with every change of what the grass of the chunk is made of: the ground, the holes and the grass itself + std::uint64_t grass_version() const { return m_shapeversion + m_paintversion + m_grassversion; } heightmap::chunk_key key() const { return m_data->key; } // true if (X,Z) lies within the chunk @@ -64,6 +68,8 @@ class editor_terrain bool m_modified{false}; std::uint64_t m_shapeversion{1}; // grows with every change of the heights std::uint64_t m_paintversion{1}; // grows with every change of the paint + std::uint64_t m_grassversion{1}; // grows with every change of the grass + std::uint64_t m_serial{0}; // drawn state, managed by the streamer int m_slot{-1}; // geometry slot holding the mesh diff --git a/editor/editorTerrainStreamer.cpp b/editor/editorTerrainStreamer.cpp index 8f799c46..c2254e89 100644 --- a/editor/editorTerrainStreamer.cpp +++ b/editor/editorTerrainStreamer.cpp @@ -145,6 +145,8 @@ void terrain_streamer::close() m_active = false; m_name.clear(); m_directory.clear(); + if (GfxRenderer) + GfxRenderer->Terrain_Grass({}, {}); } void terrain_streamer::reset() @@ -172,6 +174,8 @@ void terrain_streamer::reset() m_name.clear(); m_directory.clear(); m_hascamera = false; + if (GfxRenderer) + GfxRenderer->Terrain_Grass({}, {}); } // workers @@ -370,7 +374,9 @@ void terrain_streamer::update_material(editor_terrain &Chunk) auto const packcorner{heightmap::pack_corner(heightmap::pack_of(data.key))}; glm::vec3 const placement{static_cast(corner.x - packcorner.x), static_cast(corner.y - packcorner.y), static_cast(heightmap::chunk_size)}; auto const painted{data.paint > 0 && count > 1}; - auto const material{GfxRenderer->Terrain_Material(Chunk.m_material, layers, painted ? data.paint : 0, painted ? data.weights.data() : nullptr, placement)}; + auto const holes{data.has_holes()}; + auto const material{GfxRenderer->Terrain_Material(Chunk.m_material, layers, painted ? data.paint : 0, painted ? data.weights.data() : nullptr, placement, holes ? data.holes : 0, + holes ? data.hole_mask.data() : nullptr)}; if (Chunk.m_material != null_handle && Chunk.m_material != material) GfxRenderer->Terrain_Release(Chunk.m_material); Chunk.m_material = material; @@ -385,8 +391,29 @@ void terrain_streamer::upload(editor_terrain &Chunk, heightmap::mesh &Mesh, int auto const triangles{Mesh.indices.size() / 3}; gfx::userdata_array nouserdata; - // the mesh goes to a geometry slot made for its grid; a slot of the same kind is refilled in place - auto fits = [&](geometry_slot const &Slot) { return Slot.cells == cells && Slot.stride == Stride; }; + // the mesh goes to a geometry slot made for its grid; a slot of the same kind is refilled in place. the indices of a chunk + // with holes are its own, its slot gets them anew with the vertices + auto fits = [&](geometry_slot const &Slot) { return Slot.cells == cells && Slot.stride == Stride && Slot.cut == Mesh.cut; }; + if (m_nocut && Mesh.cut) + { + // the backend can't change the indices of a chunk: the mesh goes whole, the material cuts the holes if it can + heightmap::build_indices(cells, Stride, Mesh.indices); + Mesh.cut = false; + } + auto const refill = [&](geometry_slot const &Slot) { + if (Slot.cut) + return GfxRenderer->Replace_Indexed(Mesh.indices, Mesh.vertices, nouserdata, Slot.geometry); + return GfxRenderer->Replace(Mesh.vertices, nouserdata, Slot.geometry, GL_TRIANGLES); + }; + auto const refilled = [&](int const Slot) { + if (refill(m_slots[Slot]) || false == m_slots[Slot].cut) + return true; + // the slot keeps the indices of the chunk it was made for, it's never used again + m_nocut = true; + m_slots[Slot].cells = -1; + Chunk.m_slot = -1; + return false; + }; if (Chunk.m_slot < 0 || false == fits(m_slots[Chunk.m_slot])) { if (Chunk.m_slot >= 0) @@ -404,6 +431,7 @@ void terrain_streamer::upload(editor_terrain &Chunk, heightmap::mesh &Mesh, int slot.bank = GfxRenderer->Create_Bank(); slot.cells = cells; slot.stride = Stride; + slot.cut = Mesh.cut; slot.geometry = GfxRenderer->Insert(Mesh.indices, Mesh.vertices, nouserdata, slot.bank, GL_TRIANGLES); if (slot.geometry.chunk == 0) { @@ -419,12 +447,17 @@ void terrain_streamer::upload(editor_terrain &Chunk, heightmap::mesh &Mesh, int else { m_slots[Chunk.m_slot].used = true; - GfxRenderer->Replace(Mesh.vertices, nouserdata, m_slots[Chunk.m_slot].geometry, GL_TRIANGLES); + if (false == refilled(Chunk.m_slot)) + { + upload(Chunk, Mesh, Stride); + return; + } } } - else + else if (false == refilled(Chunk.m_slot)) { - GfxRenderer->Replace(Mesh.vertices, nouserdata, m_slots[Chunk.m_slot].geometry, GL_TRIANGLES); + upload(Chunk, Mesh, Stride); + return; } auto const &slot{m_slots[Chunk.m_slot]}; @@ -744,6 +777,66 @@ void terrain_streamer::update(glm::dvec3 const &Camera) request_mesh(*entry.second, wanted_stride(*entry.second, Camera), entry.first / heightmap::chunk_size); ++meshing; } + + publish_grass(Camera); +} + +void terrain_streamer::publish_grass(glm::dvec3 const &Camera) +{ + if (GfxRenderer == nullptr) + return; + std::vector kinds; + std::vector chunks; + if (Global.gfx_grass && false == m_manifest.grass.empty()) + { + for (auto const &kind : m_manifest.grass) + { + gfx::grass_kind entry; + // the palette holds the colours as they're seen, the renderer lights them in linear rgb + entry.base = glm::pow(glm::clamp(kind.base, glm::vec3{0.f}, glm::vec3{1.f}), glm::vec3{2.2f}); + entry.tip = glm::pow(glm::clamp(kind.tip, glm::vec3{0.f}, glm::vec3{1.f}), glm::vec3{2.2f}); + entry.height = kind.height; + entry.variation = kind.variation; + entry.width = kind.width; + entry.density = kind.density; + entry.bend = kind.bend; + kinds.push_back(entry); + } + auto const reach{static_cast(Global.gfx_grass_distance) + 2.0}; + for (auto const &entry : m_chunks) + { + auto const &chunk{*entry.second}; + auto const &data{chunk.data()}; + auto const count{data.grass_count()}; + if (count == 0 || data.grass_density.size() != static_cast(data.grass + 1) * (data.grass + 1) * count || false == data.valid()) + continue; + // distance from the camera to the area of the chunk + auto const low{data.corner()}; + glm::dvec2 const nearest{std::clamp(Camera.x, low.x, low.x + heightmap::chunk_size), std::clamp(Camera.z, low.y, low.y + heightmap::chunk_size)}; + if (glm::length(nearest - glm::dvec2{Camera.x, Camera.z}) > reach) + continue; + gfx::grass_chunk grass; + grass.id = chunk.serial(); + grass.version = chunk.grass_version(); + grass.corner = low; + grass.size = static_cast(heightmap::chunk_size); + grass.low = static_cast(chunk.m_low.y); + grass.high = static_cast(chunk.m_high.y); + grass.cells = data.cells; + grass.heights = data.heights.data(); + grass.samples = data.grass; + grass.density = data.grass_density.data(); + for (std::size_t slot = 0; slot < count; ++slot) + grass.kinds[slot] = data.grass_kinds[slot]; + if (data.has_holes()) + { + grass.holes = data.holes; + grass.hole_mask = data.hole_mask.data(); + } + chunks.push_back(grass); + } + } + GfxRenderer->Terrain_Grass(kinds, chunks); } void terrain_streamer::upload_shape_material(editor_terrain &Chunk) @@ -813,6 +906,9 @@ void terrain_streamer::gather_triangles(glm::dvec2 const &Min, glm::dvec2 const for (int iz = zs.first; iz <= zs.second && iz < data.cells; ++iz) for (int ix = xs.first; ix <= xs.second && ix < data.cells; ++ix) { + // the cells cut out entirely aren't ground + if (data.cut_cells(ix, iz, ix + 1, iz + 1)) + continue; auto const v00{point(ix, iz)}, v10{point(ix + 1, iz)}, v01{point(ix, iz + 1)}, v11{point(ix + 1, iz + 1)}; Out.push_back({v00, v01, v10}); Out.push_back({v11, v10, v01}); @@ -820,6 +916,45 @@ void terrain_streamer::gather_triangles(glm::dvec2 const &Min, glm::dvec2 const } } +bool terrain_streamer::raycast(glm::dvec3 const &Origin, glm::dvec3 const &Direction, double const Reach, glm::dvec3 &Hit) const +{ + if (glm::length(Direction) < 1e-9) + return false; + auto const direction{glm::normalize(Direction)}; + // above the ground (or where there's none) at the start of a step, below it at its end: the crossing is in between + auto const above = [&](double const T) { + auto const point{Origin + direction * T}; + double height; + return false == height_at(point.x, point.z, height) || point.y > height; + }; + if (false == above(0.0)) + return false; // starts under the ground + double previous{0.0}; + double t{0.0}; + while (t < Reach) + { + // finer near the camera, where the brush is aimed most + t = std::min(Reach, t + std::max(0.25, t * 0.004)); + if (above(t)) + { + previous = t; + continue; + } + auto low{previous}, high{t}; + for (int i = 0; i < 24; ++i) + { + auto const middle{(low + high) * 0.5}; + if (above(middle)) + low = middle; + else + high = middle; + } + Hit = Origin + direction * high; + return true; + } + return false; +} + bool terrain_streamer::owns(gfx::geometry_handle const &Geometry) const { if (Geometry.bank == 0 && Geometry.chunk == 0) @@ -1320,6 +1455,210 @@ bool terrain_streamer::paint_area(glm::dvec2 const &Min, glm::dvec2 const &Max, return changed; } +namespace +{ +// true if the point lies within the outline (x, z) +bool inside_outline(std::vector const &Outline, double const X, double const Z) +{ + bool inside{false}; + for (std::size_t i = 0, j = Outline.size() - 1; i < Outline.size(); j = i++) + { + auto const &a{Outline[i]}; + auto const &b{Outline[j]}; + if ((a.y > Z) != (b.y > Z) && X < (b.x - a.x) * (Z - a.y) / (b.y - a.y) + a.x) + inside = !inside; + } + return inside; +} + +std::pair outline_bounds(std::vector const &Outline) +{ + glm::dvec2 low{std::numeric_limits::max()}, high{-std::numeric_limits::max()}; + for (auto const &point : Outline) + { + low = glm::min(low, point); + high = glm::max(high, point); + } + return {low, high}; +} +} // namespace + +bool terrain_streamer::cut(double X, double Z, double Radius, bool Cut) +{ + if (Radius <= 0.0) + return false; + return cut_area(glm::dvec2{X - Radius, Z - Radius}, glm::dvec2{X + Radius, Z + Radius}, Cut, false, + [&](double const Px, double const Pz) { return (Px - X) * (Px - X) + (Pz - Z) * (Pz - Z) <= Radius * Radius; }); +} + +bool terrain_streamer::cut(std::vector const &Outline, bool Cut) +{ + if (Outline.size() < 3) + return false; + auto const [low, high]{outline_bounds(Outline)}; + return cut_area(low, high, Cut, true, [&](double const Px, double const Pz) { return inside_outline(Outline, Px, Pz); }); +} + +bool terrain_streamer::cut_area(glm::dvec2 const &Min, glm::dvec2 const &Max, bool const Cut, bool const Load, std::function const &Inside) +{ + bool changed{false}; + for (auto *chunk : chunks_in(Min, Max, Load)) + { + auto const ¤t{chunk->data()}; + if (false == Cut && false == current.has_holes()) + continue; // nothing to close + auto data{std::make_shared(current)}; + data->ensure_holes(); + auto const step{heightmap::chunk_size / data->holes}; + auto const low{data->corner()}; + auto const xs{index_range(low.x, step, data->holes + 1, Min.x, Max.x)}; + auto const zs{index_range(low.y, step, data->holes + 1, Min.y, Max.y)}; + std::uint8_t const value{Cut ? std::uint8_t{255} : std::uint8_t{0}}; + bool touched{false}; + for (int iz = zs.first; iz <= zs.second; ++iz) + for (int ix = xs.first; ix <= xs.second; ++ix) + { + auto &sample{data->hole_mask[static_cast(iz) * (data->holes + 1) + ix]}; + if (sample == value || false == Inside(low.x + ix * step, low.y + iz * step)) + continue; + sample = value; + touched = true; + } + if (false == touched) + continue; + data->compact_holes(); + remember(*chunk); + // the mesh leaves the cells cut out entirely, the material cuts the rest + chunk->replace(std::move(data), true, true); + changed = true; + } + return changed; +} + +bool terrain_streamer::grass(double X, double Z, double Radius, double Strength, std::uint16_t Kind, bool Erase) +{ + if (Radius <= 0.0 || Strength <= 0.0) + return false; + return grass_area(glm::dvec2{X - Radius, Z - Radius}, glm::dvec2{X + Radius, Z + Radius}, Kind, Erase, false, [&](double const Px, double const Pz) { + auto const distance{std::sqrt((Px - X) * (Px - X) + (Pz - Z) * (Pz - Z))}; + return distance > Radius ? 0.0 : Strength * falloff(distance, Radius); + }); +} + +bool terrain_streamer::grass(std::vector const &Outline, std::uint16_t Kind, bool Erase) +{ + if (Outline.size() < 3) + return false; + auto const [low, high]{outline_bounds(Outline)}; + return grass_area(low, high, Kind, Erase, true, [&](double const Px, double const Pz) { return inside_outline(Outline, Px, Pz) ? 1.0 : 0.0; }); +} + +bool terrain_streamer::grass_area(glm::dvec2 const &Min, glm::dvec2 const &Max, std::uint16_t const Kind, bool const Erase, bool const Load, std::function const &Amount) +{ + if (false == Erase && Kind >= m_manifest.grass.size()) + return false; + bool changed{false}; + for (auto *chunk : chunks_in(Min, Max, Load)) + { + auto const ¤t{chunk->data()}; + if (Erase && current.grass_count() == 0) + continue; // nothing to take away + auto data{std::make_shared(current)}; + data->ensure_grass(); + auto const samples{data->grass}; + auto const plane{static_cast(samples + 1) * (samples + 1)}; + int slot{-1}; + if (false == Erase) + { + slot = data->grass_slot_of(Kind); + if (slot < 0) + { + data->compact_grass(); + data->ensure_grass(); + auto const count{data->grass_count()}; + if (count >= heightmap::max_chunk_grass) + { + WriteLog("Editor: chunk " + std::to_string(current.key.first) + "," + std::to_string(current.key.second) + " has " + std::to_string(count) + + " kinds of grass already, no room for another", + logtype::generic); + continue; + } + slot = static_cast(count); + data->grass_kinds[slot] = Kind; + data->grass_density.resize(data->grass_density.size() + plane, 0); + } + } + auto const count{data->grass_count()}; + auto const step{heightmap::chunk_size / samples}; + auto const low{data->corner()}; + auto const xs{index_range(low.x, step, samples + 1, Min.x, Max.x)}; + auto const zs{index_range(low.y, step, samples + 1, Min.y, Max.y)}; + bool grown{false}; + for (int iz = zs.first; iz <= zs.second; ++iz) + for (int ix = xs.first; ix <= xs.second; ++ix) + { + auto const amount{std::clamp(Amount(low.x + ix * step, low.y + iz * step), 0.0, 1.0)}; + if (amount <= 0.0) + continue; + auto const sample{static_cast(iz) * (samples + 1) + ix}; + if (Erase) + { + for (std::size_t other = 0; other < count; ++other) + { + auto &density{data->grass_density[other * plane + sample]}; + auto const target{static_cast(std::lround(density * (1.0 - amount)))}; + grown |= (target != density); + density = target; + } + continue; + } + auto &own{data->grass_density[slot * plane + sample]}; + auto const target{static_cast(std::lround(own + (255 - own) * amount))}; + if (target == own) + continue; + own = static_cast(target); + // the other kinds give way, so the densities add up to the full at most + int others{0}; + for (std::size_t other = 0; other < count; ++other) + if (static_cast(other) != slot) + others += data->grass_density[other * plane + sample]; + auto const room{255 - target}; + if (others > room) + for (std::size_t other = 0; other < count; ++other) + if (static_cast(other) != slot) + { + auto &density{data->grass_density[other * plane + sample]}; + density = static_cast(density * room / others); + } + grown = true; + } + if (false == grown) + continue; + data->compact_grass(); + remember(*chunk); + chunk->replace(std::move(data), false, false, true); + changed = true; + } + return changed; +} + +std::uint16_t terrain_streamer::add_grass_kind(heightmap::grass_def Kind) +{ + remember_manifest(); + m_manifest.grass.push_back(std::move(Kind)); + m_manifestchanged = true; + return static_cast(m_manifest.grass.size() - 1); +} + +void terrain_streamer::grass_kind(std::uint16_t const Index, heightmap::grass_def Kind) +{ + if (Index >= m_manifest.grass.size()) + return; + remember_manifest(); + m_manifest.grass[Index] = std::move(Kind); + m_manifestchanged = true; +} + std::uint16_t terrain_streamer::layer(std::string const &Material, float const Size, float const Rotation) { for (std::size_t i = 0; i < m_manifest.layers.size(); ++i) @@ -1671,6 +2010,7 @@ void terrain_streamer::remember_manifest() m_change.water = m_manifest.water; m_change.water_material = m_manifest.water_material; m_change.modifiers = m_manifest.modifiers; + m_change.grass = m_manifest.grass; } terrain_streamer::change terrain_streamer::restore(change const &Change) @@ -1695,7 +2035,7 @@ terrain_streamer::change terrain_streamer::restore(change const &Change) } if (current != nullptr) { - current->replace(data, true, true); + current->replace(data, true, true, true); } else { @@ -1718,11 +2058,13 @@ terrain_streamer::change terrain_streamer::restore(change const &Change) opposite.water = m_manifest.water; opposite.water_material = m_manifest.water_material; opposite.modifiers = m_manifest.modifiers; + opposite.grass = m_manifest.grass; auto const samelayers{std::equal(Change.layers.begin(), Change.layers.end(), m_manifest.layers.begin(), m_manifest.layers.end(), [](heightmap::layer_def const &A, heightmap::layer_def const &B) { return A.material == B.material && A.size == B.size && A.rotation == B.rotation; })}; m_manifest.layers = Change.layers; m_manifest.modifiers = Change.modifiers; m_manifest.water = Change.water; + m_manifest.grass = Change.grass; if (false == Change.water_material.empty()) m_manifest.water_material = Change.water_material; m_manifestchanged = true; diff --git a/editor/editorTerrainStreamer.hpp b/editor/editorTerrainStreamer.hpp index beff9746..a93632dc 100644 --- a/editor/editorTerrainStreamer.hpp +++ b/editor/editorTerrainStreamer.hpp @@ -71,6 +71,9 @@ class terrain_streamer bool height_at(double X, double Z, double &Height) const; // triangles of the full grid of the chunks in memory overlapping the rectangle (x, z) void gather_triangles(glm::dvec2 const &Min, glm::dvec2 const &Max, std::vector> &Out) const; + // the first point where a ray from Origin along Direction (normalized) meets the ground of the chunks in memory, within + // Reach metres; the holes are taken for ground. returns: false if it meets none + bool raycast(glm::dvec3 const &Origin, glm::dvec3 const &Direction, double Reach, glm::dvec3 &Hit) const; // true if the geometry belongs to the terrain (or its water) bool owns(gfx::geometry_handle const &Geometry) const; std::size_t resident() const { return m_chunks.size(); } @@ -116,6 +119,21 @@ class terrain_streamer // Feather: metres across the outline the material blends in over, half of them inside; 0: a sharp edge, as sharp as // the paint samples of the chunks go. the chunks are loaded where needed bool fill(std::vector const &Outline, double Feather, std::uint16_t Layer, double Strength = 1.0); + // holes, for the portals of tunnels and the like: cut in the ground (Cut true) within Radius of (X,Z), or closed again + bool cut(double X, double Z, double Radius, bool Cut); + // the same within Outline (x, z); the chunks are loaded where needed + bool cut(std::vector const &Outline, bool Cut); + // grass: grows the kind of the grass palette Kind within Radius of (X,Z), Strength (0..1) of the way at the centre; + // Erase: takes every kind of grass away there instead + bool grass(double X, double Z, double Radius, double Strength, std::uint16_t Kind, bool Erase); + // the same within Outline (x, z), fully; the chunks are loaded where needed + bool grass(std::vector const &Outline, std::uint16_t Kind, bool Erase); + // the grass palette + std::vector const &grass_kinds() const { return m_manifest.grass; } + // adds a kind to the grass palette; returns: its index + std::uint16_t add_grass_kind(heightmap::grass_def Kind); + // changes a kind of the grass palette; the grass of that kind changes with it + void grass_kind(std::uint16_t Index, heightmap::grass_def Kind); // palette entry of the material (repeated at Size, 0: any, turned by Rotation degrees), added to the palette if needed std::uint16_t layer(std::string const &Material, float Size = 0.f, float Rotation = 0.f); void layer_size(std::uint16_t Layer, float Size); @@ -153,6 +171,7 @@ class terrain_streamer std::vector water; std::string water_material; std::vector modifiers; + std::vector grass; bool empty() const { return chunks.empty() && false == manifest; } }; @@ -218,6 +237,7 @@ class terrain_streamer gfx::geometry_handle geometry{0, 0}; int cells{0}; int stride{0}; + bool cut{false}; // the indices are of a chunk with holes, filled anew with every mesh bool used{false}; }; struct water_shape @@ -247,6 +267,14 @@ class terrain_streamer // paints the material of the palette entry Layer over the paint samples within the rectangle, Amount (0..1) of the way // at each, as Amount gives it for the place of the sample. Load: chunks not in memory are loaded bool paint_area(glm::dvec2 const &Min, glm::dvec2 const &Max, std::uint16_t Layer, bool Load, std::function const &Amount); + // cuts (Cut true) or closes the holes at the hole samples within the rectangle where Inside says so. Load: chunks not in + // memory are loaded + bool cut_area(glm::dvec2 const &Min, glm::dvec2 const &Max, bool Cut, bool Load, std::function const &Inside); + // grows the kind Kind of the grass palette (or takes all grass away, Erase) over the grass samples within the rectangle, + // Amount (0..1) of the way at each + bool grass_area(glm::dvec2 const &Min, glm::dvec2 const &Max, std::uint16_t Kind, bool Erase, bool Load, std::function const &Amount); + // hands the renderer the grass of the chunks near the camera + void publish_grass(glm::dvec3 const &Camera); // chunk loaded right away from its pack file, for editing far from the camera editor_terrain *load_now(chunk_key const &Key); // chunks in memory or in the files overlapping the rectangle, loaded if needed @@ -279,6 +307,7 @@ class terrain_streamer std::set m_loading; // asked from a worker std::set m_removed; // removed in the editor, still in the files until saved std::vector m_slots; + bool m_nocut{false}; // the renderer can't change the indices of the chunks: the holes are cut by the materials alone std::map m_slotofgeometry; // geometry key -> slot std::map m_palette; std::vector m_watershapes; diff --git a/editor/icons/editor_icons.png b/editor/icons/editor_icons.png index affe75b5021dd99818f350ec3adb81cf0c483fa3..f54cb31dfbadb34eb3d7342ffd786195ffe0300f 100644 GIT binary patch literal 55924 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 sDz- zhRB@dGtFs3+}<v%IFcj}Vsg;vetiKQ7LVR8K@nhm}JoFhehzTy|w{&x5SCd_1-5 z!wQ2@FEQQAm7dXFX%=Zl^8{mDfU^dZy$RmMw`N&J+YBgem�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 literal 46348 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 z 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 z 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 diff --git a/editor/icons/editor_icons.py b/editor/icons/editor_icons.py index a1756b34..2c99e4b0 100644 --- a/editor/icons/editor_icons.py +++ b/editor/icons/editor_icons.py @@ -378,11 +378,24 @@ def work_terrain(d): line(d, [at(u, v) for v in steps], W - 6) +def hole(d): + # a hill with the portal of a tunnel cut in it (the opening is in cutouts()) + poly(d, [(2, 58), (2, 40), (14, 22), (28, 12), (42, 16), (54, 30), (62, 42), (62, 58)]) + + +def grass(d): + # blades growing from the ground, leaning either way + line(d, [(4, 58), (60, 58)], W - 8) + for x, lean, height in ((10, -6, 30), (20, 4, 44), (31, -3, 52), (42, 7, 40), (53, -5, 30)): + pts = [(x + lean * t * t, 56 - height * t) for t in (i / 10 for i in range(11))] + line(d, pts, W - 8) + + ICONS = [select, insert, brush, area_fill, copy_to_bank, translate, rotate, scale, local_space, ortho, perspective, sculpt, smooth, chunks, orthophoto, road, place, lanes, lay_track, switch, straight, curve, signal, objects, vehicle, profile, speed, joints, infra, gauge, turntable, parallel, more, work_surroundings, - work_tracks, work_roads, work_terrain] + work_tracks, work_roads, work_terrain, hole, grass] def cutouts(name, d): @@ -403,6 +416,9 @@ def cutouts(name, d): # the door and the gap between the crowns of the tree rect(d, 46, 46, 52, 60) line(d, [(9, 31), (27, 31)], 8) + elif name == "hole": + # the opening of the portal, an arch on the ground + poly(d, [(20, 60), (20, 42)] + [(32 + 12 * math.cos(math.radians(180 - a)), 42 - 12 * math.sin(math.radians(180 - a))) for a in range(0, 181, 10)] + [(44, 60)]) elif name == "work_roads": # the windows, and the gaps around the wheels poly(d, [(25, 22), (31, 22), (31, 30), (19, 30)]) diff --git a/lang/editor_pl.po b/lang/editor_pl.po index ec07525b..89f3ec3b 100644 --- a/lang/editor_pl.po +++ b/lang/editor_pl.po @@ -9949,3 +9949,128 @@ msgstr "Dobudowuj teren przy ukÅ‚adaniu" msgid "Where a track is laid past the edge of the terrain, the terrain model is made around the cursor as it goes" msgstr "Gdy tor wychodzi poza teren, NMT dobudowuje siÄ™ wokół kursora na bieżąco" + +# scenery editor: holes and grass of the heightmap terrain + +msgid "%zu: %s, %.2f m, %.0f blades/m2" +msgstr "%zu: %s, %.2f m, %.0f źdźbeÅ‚/m2" + +msgid "A chunk holds up to 4 kinds of grass. LMB grows the chosen kind, Shift+LMB takes all grass away" +msgstr "KawaÅ‚ek mieÅ›ci do 4 rodzajów trawy. LPM sieje wybrany rodzaj, Shift+LPM usuwa całą trawÄ™" + +msgid "Add:" +msgstr "Dodaj:" + +msgid "Blade width (m)" +msgstr "Szerokość źdźbÅ‚a (m)" + +msgid "Blades on a square metre painted fully. The renderer draws fewer further away, and wider;\ngfx.grass.distance and gfx.grass.density in eu07.ini set how far and how many" +msgstr "ŹdźbÅ‚a na metrze kwadratowym w peÅ‚ni pomalowanym. Dalej renderer rysuje ich mniej, ale szerszych;\ngfx.grass.distance i gfx.grass.density w eu07.ini ustalajÄ…, jak daleko i ile" + +msgid "Clear the grass" +msgstr "UsuÅ„ trawÄ™" + +msgid "Close the hole" +msgstr "Zamknij dziurÄ™" + +msgid "Cut a hole" +msgstr "Wytnij dziurÄ™" + +msgid "Cuts the ground within the outline away, e.g. for the portal of a tunnel" +msgstr "Wycina teren wewnÄ…trz obrysu, np. pod portal tunelu" + +msgid "Density (blades/m2)" +msgstr "GÄ™stość (źdźbÅ‚a/m2)" + +msgid "Grass grown: %s" +msgstr "Wysiano trawÄ™: %s" + +msgid "Grass taken away from the outlined area" +msgstr "UsuniÄ™to trawÄ™ z obrysowanego obszaru" + +msgid "Grass" +msgstr "Trawa" + +msgid "Grow grass" +msgstr "Posiej trawÄ™" + +msgid "Grows the kind of grass chosen in the Grass section within the outline" +msgstr "Sieje wewnÄ…trz obrysu rodzaj trawy wybrany w sekcji Trawa" + +msgid "Height (m)##grass" +msgstr "Wysokość (m)##grass" + +msgid "Height varies by" +msgstr "Rozrzut wysokoÅ›ci" + +msgid "Hole closed in the outlined area" +msgstr "ZamkniÄ™to dziurÄ™ w obrysowanym obszarze" + +msgid "Hole cut in the outlined area" +msgstr "WyciÄ™to dziurÄ™ w obrysowanym obszarze" + +msgid "Holes" +msgstr "Dziury" + +msgid "How far the tips of the blades lean over, share of their height; the wind adds to it" +msgstr "Jak bardzo czubki źdźbeÅ‚ siÄ™ pochylajÄ…, jako część ich wysokoÅ›ci; wiatr dokÅ‚ada swoje" + +msgid "LMB cuts a hole in the terrain under the brush (portals of tunnels), Shift+LMB closes it" +msgstr "LPM wycina dziurÄ™ w terenie pod pÄ™dzlem (portale tuneli), Shift+LPM jÄ… zamyka" + +msgid "LMB grows the kind of grass chosen in the grass palette, Shift+LMB takes the grass away" +msgstr "LPM sieje rodzaj trawy wybrany w palecie traw, Shift+LPM usuwa trawÄ™" + +msgid "Lean" +msgstr "Pochylenie" + +msgid "Name##grass" +msgstr "Nazwa##grass" + +msgid "No grass under the outline" +msgstr "Pod obrysem nie ma trawy" + +msgid "No hole under the outline" +msgstr "Pod obrysem nie ma dziury" + +msgid "No kinds of grass yet. Add one of the presets below; the first stroke of the brush adds a meadow." +msgstr "Nie ma jeszcze rodzajów trawy. Dodaj któryÅ› z gotowych poniżej; pierwsze pociÄ…gniÄ™cie pÄ™dzla doda łąkÄ™." + +msgid "Nothing cut: no chunk under the outline" +msgstr "Nic nie wyciÄ™to: pod obrysem nie ma kawaÅ‚ka terenu" + +msgid "Nothing grown: no chunk under the outline, or no room for another kind of grass" +msgstr "Nic nie wysiano: pod obrysem nie ma kawaÅ‚ka terenu albo nie ma miejsca na kolejny rodzaj trawy" + +msgid "Openings in the ground for the portals of tunnels, cuttings under bridges and the like. With the Holes tool LMB cuts the ground under the brush and Shift+LMB closes it; the brush stays on the ground also over an opening. An area outlined with the Fill tool can be cut or closed in the Fill section." +msgstr "Otwory w terenie pod portale tuneli, przejÅ›cia pod mostami i podobne. NarzÄ™dziem Dziury LPM wycina teren pod pÄ™dzlem, a Shift+LPM go zamyka; pÄ™dzel trzyma siÄ™ terenu także nad otworem. Obszar obrysowany narzÄ™dziem WypeÅ‚nienie można wyciąć albo zamknąć w sekcji WypeÅ‚nienie." + +msgid "Root colour" +msgstr "Kolor u nasady" + +msgid "Share of the height the blades differ by, either way" +msgstr "O jakÄ… część wysokoÅ›ci źdźbÅ‚a różniÄ… siÄ™ w górÄ™ i w dół" + +msgid "The grass isn't drawn: gfx.grass is off" +msgstr "Trawa nie jest rysowana: gfx.grass jest wyłączone" + +msgid "The holes follow the ground every metre (every grid point of finer chunks), whatever the drawn grid" +msgstr "Dziury idÄ… za terenem co metr (co punkt siatki w gÄ™stszych kawaÅ‚kach), niezależnie od rysowanej siatki" + +msgid "Tip colour" +msgstr "Kolor czubka" + +msgid "meadow" +msgstr "łąka" + +msgid "lawn" +msgstr "trawnik" + +msgid "dry grass" +msgstr "sucha trawa" + +msgid "tall grass" +msgstr "wysoka trawa" + +msgid "weeds" +msgstr "chwasty" diff --git a/rendering/geometrybank.cpp b/rendering/geometrybank.cpp index 551c2b5a..5b4abf12 100644 --- a/rendering/geometrybank.cpp +++ b/rendering/geometrybank.cpp @@ -309,6 +309,27 @@ geometry_bank::replace( gfx::vertex_array &Vertices, userdata_array &Userdata, g return true; } +// replaces the index and the vertex data of specified indexed chunk with the supplied data of the same counts +bool +geometry_bank::replace( gfx::index_array &Indices, gfx::vertex_array &Vertices, userdata_array &Userdata, gfx::geometry_handle const &Geometry ) { + + if( ( Geometry.chunk == 0 ) || ( Geometry.chunk > m_chunks.size() ) ) { return false; } + + auto &chunk = gfx::geometry_bank::chunk( Geometry ); + // the place of the chunk in the buffers of the bank is kept, so the sizes have to stay + if( ( Indices.size() != chunk.indices.size() ) + || ( Vertices.size() != chunk.vertices.size() ) + || ( Userdata.size() != chunk.userdata.size() ) ) { + return false; + } + chunk.indices.swap( Indices ); + chunk.vertices.swap( Vertices ); + chunk.userdata.swap( Userdata ); + // template method implementation; the chunk is uploaded whole the next time it's drawn + replace_( Geometry ); + return true; +} + // adds supplied vertex data at the end of specified chunk bool geometry_bank::append( gfx::vertex_array &Vertices, userdata_array &Userdata, gfx::geometry_handle const &Geometry ) { @@ -441,6 +462,13 @@ geometrybank_manager::replace( gfx::vertex_array &Vertices, userdata_array &User return bank( Geometry ).first->replace( Vertices, Userdata, Geometry, Offset ); } +// replaces the index and the vertex data of specified indexed chunk with the supplied data of the same counts +bool +geometrybank_manager::replace( gfx::index_array &Indices, gfx::vertex_array &Vertices, userdata_array &Userdata, gfx::geometry_handle const &Geometry ) { + + return bank( Geometry ).first->replace( Indices, Vertices, Userdata, Geometry ); +} + // adds supplied vertex data at the end of specified chunk bool geometrybank_manager::append( gfx::vertex_array &Vertices, userdata_array &Userdata, gfx::geometry_handle const &Geometry ) { diff --git a/rendering/geometrybank.h b/rendering/geometrybank.h index e245486f..eae11241 100644 --- a/rendering/geometrybank.h +++ b/rendering/geometrybank.h @@ -125,6 +125,8 @@ public: auto create( gfx::index_array &Indices, gfx::vertex_array &Vertices, gfx::userdata_array& Userdata, unsigned int const Type ) -> gfx::geometry_handle; // replaces vertex data of specified chunk with the supplied data, starting from specified offset auto replace( gfx::vertex_array &Vertices, gfx::userdata_array& Userdata, gfx::geometry_handle const &Geometry, std::size_t const Offset = 0 ) -> bool; + // replaces the index and the vertex data of specified indexed chunk with the supplied data of the same counts. returns: false if the counts differ + auto replace( gfx::index_array &Indices, gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry ) -> bool; // adds supplied vertex data at the end of specified chunk auto append( gfx::vertex_array &Vertices, gfx::userdata_array& Userdata, gfx::geometry_handle const &Geometry ) -> bool; // draws geometry stored in specified chunk @@ -243,6 +245,8 @@ public: auto create_chunk( gfx::index_array &Indices, gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometrybank_handle const &Geometry, unsigned int const Type ) -> gfx::geometry_handle; // replaces data of specified chunk with the supplied vertex data, starting from specified offset auto replace( gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry, std::size_t const Offset = 0 ) -> bool; + // replaces the index and the vertex data of specified indexed chunk with the supplied data of the same counts. returns: false if the counts differ + auto replace( gfx::index_array &Indices, gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry ) -> bool; // adds supplied vertex data at the end of specified chunk auto append( gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry ) -> bool; // draws geometry stored in specified chunk diff --git a/rendering/opengl46/opengl46grass.cpp b/rendering/opengl46/opengl46grass.cpp new file mode 100644 index 00000000..d9c58965 --- /dev/null +++ b/rendering/opengl46/opengl46grass.cpp @@ -0,0 +1,303 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#include "stdafx.h" +#include "rendering/opengl46/opengl46grass.h" + +#include "rendering/openglcamera.h" +#include "model/Texture.h" +#include "simulation/simulationenvironment.h" +#include "utilities/Globals.h" +#include "utilities/Logs.h" +#include "utilities/Timer.h" + +namespace +{ + +int constexpr max_blades{65536}; // drawn in a tile at most +float constexpr blade_reach{1.5f}; // metres the bounds of a tile reach above its ground, for the blades + +// the share of the blades drawn at specified distance; the same as lod_share() of grass.vert +float lod_share(float const Distance, float const Near) +{ + return Distance <= Near ? 1.f : std::pow(Near / Distance, 1.5f); +} + +GLuint make_texture(GLenum const Format, int const Width, int const Height, GLenum const Filter) +{ + GLuint texture{0}; + ::glCreateTextures(GL_TEXTURE_2D, 1, &texture); + ::glTextureStorage2D(texture, 1, Format, Width, Height); + ::glTextureParameteri(texture, GL_TEXTURE_MIN_FILTER, Filter); + ::glTextureParameteri(texture, GL_TEXTURE_MAG_FILTER, Filter); + ::glTextureParameteri(texture, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + ::glTextureParameteri(texture, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + return texture; +} + +} // namespace + +opengl46_grass::~opengl46_grass() +{ + // the textures go with the context; deleted here only if it's still there + if (::glfwGetCurrentContext() != nullptr) + clear(); +} + +bool opengl46_grass::init() +{ + if (m_state != 0) + return m_state > 0; + m_state = -1; + try + { + gl::shader const vertex("grass.vert"); + gl::shader const fragment("grass.frag"); + m_program = std::make_unique(std::vector>{vertex, fragment}); + } + catch (gl::shader_exception const &e) + { + ErrorLog("heightmap terrain: can't use the grass shader: " + std::string(e.what())); + return false; + } + m_vao = std::make_unique(); + auto const location = [&](char const *Name) { return ::glGetUniformLocation(*m_program, Name); }; + m_uniforms.tile = location("grass_tile"); + m_uniforms.part = location("grass_part"); + m_uniforms.chunk = location("grass_chunk"); + m_uniforms.camera = location("grass_camera"); + m_uniforms.lod = location("grass_lod"); + m_uniforms.wind = location("grass_wind"); + m_uniforms.base = location("grass_base"); + m_uniforms.tip = location("grass_tip"); + m_uniforms.shape = location("grass_shape"); + m_uniforms.share = location("grass_share"); + m_state = 1; + return true; +} + +void opengl46_grass::release(chunk &Chunk) +{ + for (auto *texture : {&Chunk.heights, &Chunk.density, &Chunk.holes}) + if (*texture != 0) + { + ::glDeleteTextures(1, texture); + *texture = 0; + } +} + +void opengl46_grass::clear() +{ + for (auto &entry : m_chunks) + release(entry.second); + m_chunks.clear(); +} + +void opengl46_grass::upload(chunk &Chunk, gfx::grass_chunk const &Source) +{ + release(Chunk); + Chunk.version = Source.version; + Chunk.corner = Source.corner; + Chunk.size = Source.size; + Chunk.cells = Source.cells; + Chunk.samples = Source.samples; + Chunk.kinds = Source.kinds; + + auto const side{Source.cells + 1}; + Chunk.heights = make_texture(GL_R32F, side, side, GL_NEAREST); + ::glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + ::glTextureSubImage2D(Chunk.heights, 0, 0, 0, side, side, GL_RED, GL_FLOAT, Source.heights); + + // the densities of the kinds side by side, as the channels of a texel + auto const samples{Source.samples + 1}; + auto const plane{static_cast(samples) * samples}; + std::size_t kinds{0}; + while (kinds < Source.kinds.size() && Source.kinds[kinds] >= 0) + ++kinds; + std::vector texels(plane * 4, 0); + for (std::size_t kind = 0; kind < kinds; ++kind) + for (std::size_t i = 0; i < plane; ++i) + texels[i * 4 + kind] = Source.density[kind * plane + i]; + Chunk.density = make_texture(GL_RGBA8, samples, samples, GL_LINEAR); + ::glTextureSubImage2D(Chunk.density, 0, 0, 0, samples, samples, GL_RGBA, GL_UNSIGNED_BYTE, texels.data()); + + std::uint8_t const none{0}; + auto const holeside{(Source.holes > 0 && Source.hole_mask != nullptr) ? Source.holes + 1 : 1}; + Chunk.holes = make_texture(GL_R8, holeside, holeside, GL_LINEAR); + ::glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + ::glTextureSubImage2D(Chunk.holes, 0, 0, 0, holeside, holeside, GL_RED, GL_UNSIGNED_BYTE, holeside > 1 ? Source.hole_mask : &none); + ::glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + + // the ground and the densest place of every kind of each tile, to skip the empty tiles and to draw as many blades as needed + auto const spacing{static_cast(Source.size) / Source.cells}; + auto const step{static_cast(Source.size) / Source.samples}; + auto const tilesize{static_cast(Source.size) / tiles}; + for (int tz = 0; tz < tiles; ++tz) + for (int tx = 0; tx < tiles; ++tx) + { + auto &part{Chunk.parts[static_cast(tz) * tiles + tx]}; + auto const range = [](double const From, double const To, double const Step, int const Last) { + return std::make_pair(std::clamp(static_cast(std::floor(From / Step)), 0, Last), std::clamp(static_cast(std::ceil(To / Step)), 0, Last)); + }; + auto const [gx0, gx1]{range(tx * tilesize, (tx + 1) * tilesize, spacing, Source.cells)}; + auto const [gz0, gz1]{range(tz * tilesize, (tz + 1) * tilesize, spacing, Source.cells)}; + part.low = std::numeric_limits::max(); + part.high = -std::numeric_limits::max(); + for (int z = gz0; z <= gz1; ++z) + for (int x = gx0; x <= gx1; ++x) + { + auto const height{Source.heights[static_cast(z) * side + x]}; + part.low = std::min(part.low, height); + part.high = std::max(part.high, height); + } + part.most.fill(0.f); + auto const [sx0, sx1]{range(tx * tilesize, (tx + 1) * tilesize, step, Source.samples)}; + auto const [sz0, sz1]{range(tz * tilesize, (tz + 1) * tilesize, step, Source.samples)}; + for (std::size_t kind = 0; kind < kinds; ++kind) + { + std::uint8_t most{0}; + for (int z = sz0; z <= sz1; ++z) + for (int x = sx0; x <= sx1; ++x) + most = std::max(most, Source.density[kind * plane + static_cast(z) * samples + x]); + part.most[kind] = most / 255.f; + } + } +} + +void opengl46_grass::update(std::vector const &Kinds, std::vector const &Chunks) +{ + m_kinds = Kinds; + for (auto &entry : m_chunks) + entry.second.listed = false; + if (false == Global.gfx_grass) + { + clear(); + return; + } + for (auto const &source : Chunks) + { + if (source.heights == nullptr || source.density == nullptr || source.cells <= 0 || source.samples <= 0) + continue; + auto &entry{m_chunks[source.id]}; + entry.listed = true; + if (entry.heights == 0 || entry.version != source.version) + upload(entry, source); + } + // what's no longer near goes + for (auto it = m_chunks.begin(); it != m_chunks.end();) + { + if (it->second.listed) + { + ++it; + continue; + } + release(it->second); + it = m_chunks.erase(it); + } +} + +std::size_t opengl46_grass::render(opengl_camera const &Camera, glm::mat4 const &View, std::function const &Modelview) +{ + if (false == Global.gfx_grass || m_chunks.empty() || m_kinds.empty() || false == init()) + return 0; + + auto const reach{std::max(5.f, Global.gfx_grass_distance)}; + auto const near{std::max(3.f, reach * 0.2f)}; + auto const multiplier{std::max(0.f, Global.gfx_grass_density)}; + auto const camera{Camera.position()}; + + m_program->bind(); + m_vao->bind(); + ::glDisable(GL_CULL_FACE); + ::glUniform4f(m_uniforms.lod, near, reach, 0.f, 0.f); + // the wind sways the blades along it, harder in the gusts + auto const wind{glm::vec3{simulation::Environment.wind()}}; + auto const windspeed{glm::length(glm::vec2{wind.x, wind.z})}; + auto const winddirection{windspeed > 0.01f ? glm::vec2{wind.x, wind.z} / windspeed : glm::vec2{1.f, 0.f}}; + ::glUniform4f(m_uniforms.wind, winddirection.x, winddirection.y, std::clamp(windspeed / 15.f, 0.f, 1.f), static_cast(std::fmod(Timer::GetTime(), 3600.0))); + + std::size_t triangles{0}; + for (auto &entry : m_chunks) + { + auto &chunk{entry.second}; + // the kinds of the chunk, and the share of the blades of each against its densest + std::array base{}, tip{}, shape{}; + glm::vec4 share{0.f}; + float densest{0.f}; + for (std::size_t i = 0; i < chunk.kinds.size(); ++i) + if (chunk.kinds[i] >= 0 && chunk.kinds[i] < static_cast(m_kinds.size())) + densest = std::max(densest, m_kinds[chunk.kinds[i]].density); + if (densest <= 0.f) + continue; + for (std::size_t i = 0; i < chunk.kinds.size(); ++i) + { + if (chunk.kinds[i] < 0 || chunk.kinds[i] >= static_cast(m_kinds.size())) + continue; + auto const &kind{m_kinds[chunk.kinds[i]]}; + base[i] = glm::vec4{kind.base, 0.f}; + tip[i] = glm::vec4{kind.tip, 0.f}; + shape[i] = glm::vec4{kind.height, kind.variation, kind.width, kind.bend}; + share[i] = kind.density / densest; + } + auto const tallest{std::max({shape[0].x, shape[1].x, shape[2].x, shape[3].x}) * 1.5f + blade_reach}; + + auto const tilesize{chunk.size / tiles}; + bool set{false}; + for (int tz = 0; tz < tiles; ++tz) + for (int tx = 0; tx < tiles; ++tx) + { + auto const &part{chunk.parts[static_cast(tz) * tiles + tx]}; + auto const fill{std::min(1.f, glm::dot(glm::vec4{part.most[0], part.most[1], part.most[2], part.most[3]}, share))}; + if (fill <= 0.f) + continue; + // distance to the box of the tile + glm::dvec3 const low{chunk.corner.x + tx * tilesize, part.low, chunk.corner.y + tz * tilesize}; + glm::dvec3 const high{low.x + tilesize, part.high + tallest, low.z + tilesize}; + auto const nearest{glm::clamp(camera, low, high)}; + auto const distance{static_cast(glm::length(nearest - camera))}; + if (distance > reach) + continue; + if (false == Camera.visible(scene::bounding_area{(low + high) * 0.5, static_cast(glm::length(high - low) * 0.5)})) + continue; + auto const full{tilesize * tilesize * densest * multiplier * fill}; + auto const count{std::min(max_blades, static_cast(std::ceil(full * lod_share(distance, near))))}; + if (count <= 0) + continue; + if (false == set) + { + // the chunk is drawn relative to its corner, the heights as they are + set = true; + Modelview(glm::translate(View, glm::vec3{glm::dvec3{chunk.corner.x - camera.x, -camera.y, chunk.corner.y - camera.z}})); + ::glUniform4f(m_uniforms.chunk, chunk.size, static_cast(chunk.cells), static_cast(chunk.samples), 0.f); + ::glUniform4f(m_uniforms.camera, static_cast(camera.x - chunk.corner.x), static_cast(camera.y), static_cast(camera.z - chunk.corner.y), 0.f); + ::glUniform4fv(m_uniforms.base, 4, glm::value_ptr(base[0])); + ::glUniform4fv(m_uniforms.tip, 4, glm::value_ptr(tip[0])); + ::glUniform4fv(m_uniforms.shape, 4, glm::value_ptr(shape[0])); + ::glUniform4fv(m_uniforms.share, 1, glm::value_ptr(share)); + GLuint const textures[]{chunk.heights, chunk.density, chunk.holes}; + ::glBindTextures(0, 3, textures); + } + // fewer segments further away + auto const segments{distance < near * 1.5f ? 3 : distance < reach * 0.5f ? 2 : 1}; + // the number of the tile in the world, the seed of its blades + auto const seedx{static_cast(std::floor(chunk.corner.x / tilesize + 0.5) + tx)}; + auto const seedz{static_cast(std::floor(chunk.corner.y / tilesize + 0.5) + tz)}; + ::glUniform4f(m_uniforms.tile, tx * tilesize, tz * tilesize, tilesize, std::max(1.f, full)); + ::glUniform4f(m_uniforms.part, fill, static_cast(segments), seedx, seedz); + ::glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, segments * 2 + 1, count); + triangles += static_cast(count) * (segments * 2 - 1); + } + } + + ::glEnable(GL_CULL_FACE); + gl::vao::unbind(); + // the units of the textures were taken behind the back of the texture cache + opengl_texture::reset_unit_cache(); + return triangles; +} diff --git a/rendering/opengl46/opengl46grass.h b/rendering/opengl46/opengl46grass.h new file mode 100644 index 00000000..34c4c056 --- /dev/null +++ b/rendering/opengl46/opengl46grass.h @@ -0,0 +1,85 @@ +/* +This Source Code Form is subject to the +terms of the Mozilla Public License, v. +2.0. If a copy of the MPL was not +distributed with this file, You can +obtain one at +http://mozilla.org/MPL/2.0/. +*/ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "rendering/renderer.h" +#include "gl/shader.h" +#include "gl/vao.h" + +class opengl_camera; + +// grass of the heightmap terrain for the opengl 4.6 renderer: blades made up by the vertex shader (grass.vert) where the +// grass is painted, in the chunks near the camera. +// +// a chunk is cut into tiles; each tile visible within the reach of the grass is drawn as instances of a blade, as many as +// its densest kind and its distance ask for. a blade finds its place, its kind and its shape from its number, so the same +// blades stand at the same places in every frame. further away the blades thin out and grow wider, and fade out at the end +// of the reach. the heights, the densities and the holes of a chunk are kept in textures, made when the chunk comes near +class opengl46_grass +{ + public: + opengl46_grass() = default; + ~opengl46_grass(); + + // see gfx_renderer::Terrain_Grass() + void update(std::vector const &Kinds, std::vector const &Chunks); + // draws the grass seen by the camera. View: the modelview of the pass with the camera at its origin; Modelview: hands a + // modelview to the model block of the draws which follow. returns: triangles drawn + std::size_t render(opengl_camera const &Camera, glm::mat4 const &View, std::function const &Modelview); + // drops everything made on the gpu, e.g. when the context goes + void clear(); + bool empty() const { return m_chunks.empty(); } + + static int constexpr tiles{16}; // along a side of a chunk + + private: + struct tile + { + float low{0.f}, high{0.f}; // of the ground + std::array most{}; // the highest density of each kind of the chunk within the tile, 0..1 + }; + struct chunk + { + std::uint64_t version{0}; + glm::dvec2 corner{0.0}; + float size{0.f}; + int cells{0}; + int samples{0}; + std::array kinds{-1, -1, -1, -1}; + GLuint heights{0}; // R32F, the grid points + GLuint density{0}; // RGBA8, the densities of the kinds + GLuint holes{0}; // R8, a single texel of 0 without holes + std::array parts; + bool listed{false}; // in the last list given + }; + + bool init(); + void upload(chunk &Chunk, gfx::grass_chunk const &Source); + void release(chunk &Chunk); + + int m_state{0}; // 0: not set up yet, 1: ready, -1: can't be used + std::unique_ptr m_program; + std::unique_ptr m_vao; // without attributes, the blades are made of the numbers of their vertices + std::vector m_kinds; + std::unordered_map m_chunks; + // locations of the uniforms of the program + struct locations + { + GLint tile{-1}, part{-1}, chunk{-1}, camera{-1}, lod{-1}, wind{-1}; + GLint base{-1}, tip{-1}, shape{-1}, share{-1}; + } m_uniforms; +}; diff --git a/rendering/opengl46/opengl46renderer.cpp b/rendering/opengl46/opengl46renderer.cpp index 6f0f53ef..a04d3be7 100644 --- a/rendering/opengl46/opengl46renderer.cpp +++ b/rendering/opengl46/opengl46renderer.cpp @@ -1108,6 +1108,12 @@ void opengl46_renderer::draw_debug_ui() if (Global.gfx_contactshadows > 0) ImGui::TextUnformatted("(drawn anyway for the contact shadows, gfx.contactshadows)"); ImGui::Checkbox("walk the scene once for its depth and colour, drawing both from a record (gfx.depthprepass.replay)", &Global.gfx_depthprepass_replay); + ImGui::Checkbox("grass of the heightmap terrain (gfx.grass)", &Global.gfx_grass); + if (Global.gfx_grass) + { + ImGui::SliderFloat("reach of the grass, m (gfx.grass.distance)", &Global.gfx_grass_distance, 10.f, 250.f, "%.0f"); + ImGui::SliderFloat("density of the grass (gfx.grass.density)", &Global.gfx_grass_density, 0.1f, 2.f, "%.2f"); + } auto info{m_instancecull.info()}; if (Global.gfx_gpucull_shadows) { @@ -1428,6 +1434,13 @@ void opengl46_renderer::Render_pass(viewport_config &vp, rendermode const Mode) m_scenedepthdrawn = false; m_indirectcolourpending = false; + // the grass of the terrain, opaque, over the ground drawn + if (vp.main && (false == Global.bWireFrame)) + { + gpupart("grass"); + Render_grass(future); + } + Render_vr_models(); // ...translucent parts @@ -2742,6 +2755,14 @@ bool opengl46_renderer::Replace(gfx::vertex_array &Vertices, gfx::userdata_array return m_geometry.replace(Vertices, Userdata, Geometry, Offset); } +// replaces the index and the vertex data of specified indexed chunk with the supplied data of the same counts +bool opengl46_renderer::Replace_Indexed(gfx::index_array &Indices, gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry) +{ + // NOTE: we expect indexed geometry to come with calculated tangents + m_chunkspheres.erase(static_cast(Geometry)); + return m_geometry.replace(Indices, Vertices, Userdata, Geometry); +} + // adds supplied vertex data at the end of specified chunk bool opengl46_renderer::Append(gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry, int const Type) { @@ -3041,13 +3062,14 @@ IMaterial const *opengl46_renderer::Material(material_handle const Material) con return &m_materials.material(Material); } -material_handle opengl46_renderer::Terrain_Material(material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement) +material_handle opengl46_renderer::Terrain_Material(material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement, + int const HoleSamples, std::uint8_t const *Holes) { - auto const material{m_terrainmaterials.make(Reuse, Layers, Samples, Weights, Placement)}; + auto const material{m_terrainmaterials.make(Reuse, Layers, Samples, Weights, Placement, HoleSamples, Holes)}; if (material != null_handle) return material; // the terrain shader can't be used, the chunk makes do with a plain material - return gfx_renderer::Terrain_Material(Reuse, Layers, Samples, Weights, Placement); + return gfx_renderer::Terrain_Material(Reuse, Layers, Samples, Weights, Placement, HoleSamples, Holes); } void opengl46_renderer::Terrain_Release(material_handle const Material) @@ -3055,6 +3077,11 @@ void opengl46_renderer::Terrain_Release(material_handle const Material) m_terrainmaterials.release(Material); } +void opengl46_renderer::Terrain_Grass(std::vector const &Kinds, std::vector const &Chunks) +{ + m_grass.update(Kinds, Chunks); +} + opengl_material &opengl46_renderer::Material(material_handle const Material) { return m_materials.material(Material); @@ -6801,6 +6828,27 @@ void opengl46_renderer::Render(TMemCell *Memcell) ::glPopMatrix(); } +void opengl46_renderer::Render_grass(glm::mat4 const &Future) +{ + if (false == Global.gfx_grass || m_grass.empty()) + return; + glDebug("render grass"); + // the blades bring their colour, the lights do the rest + model_ubs.future = Future; + model_ubs.param[0] = glm::vec4(1.f); + model_ubs.param[1] = glm::vec4(1.f, 1.f, 0.f, 1.f); // diffuse, specular, reflection, glossiness + model_ubs.param[2] = glm::vec4(0.f); + model_ubs.emission = 0.f; + model_ubs.alpha_mult = 1.f; + model_ubs.opacity = 0.5f; + glm::mat4 const view{OpenGLMatrices.data(GL_MODELVIEW)}; + auto const triangles{m_grass.render(m_renderpass.pass_camera, view, [this](glm::mat4 const &Modelview) { + model_ubs.set_modelview(Modelview); + model_ubo->update(model_ubs); + })}; + m_renderpass.draw_stats.triangles += static_cast(triangles); +} + void opengl46_renderer::Render_particles() { model_ubs.set_modelview(OpenGLMatrices.data(GL_MODELVIEW)); diff --git a/rendering/opengl46/opengl46renderer.h b/rendering/opengl46/opengl46renderer.h index 71213811..188eead0 100644 --- a/rendering/opengl46/opengl46renderer.h +++ b/rendering/opengl46/opengl46renderer.h @@ -34,6 +34,7 @@ http://mozilla.org/MPL/2.0/. #include "gl/pbo.h" #include "gl/query.h" #include "rendering/opengl46/opengl46terrain.h" +#include "rendering/opengl46/opengl46grass.h" #include "rendering/opengl46/opengl46sky.h" #include "rendering/opengl46/opengl46weather.h" #include "rendering/opengl46/opengl46exposure.h" @@ -77,6 +78,8 @@ class opengl46_renderer : public gfx_renderer { gfx::geometry_handle Insert(gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometrybank_handle const &Geometry, int const Type) override; // replaces data of specified chunk with the supplied vertex data, starting from specified offset bool Replace(gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry, int const Type, const std::size_t Offset = 0) override; + // replaces the index and the vertex data of specified indexed chunk with the supplied data of the same counts + bool Replace_Indexed(gfx::index_array &Indices, gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry) override; // adds supplied vertex data at the end of specified chunk bool Append(gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry, int const Type) override; // provides direct access to index data of specfied chunk @@ -102,9 +105,12 @@ class opengl46_renderer : public gfx_renderer { IMaterial const * Material( material_handle const Material ) const override; material_handle - Terrain_Material( material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement ) override; + Terrain_Material( material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement, + int const HoleSamples = 0, std::uint8_t const *Holes = nullptr ) override; void Terrain_Release( material_handle const Material ) override; + void + Terrain_Grass( std::vector const &Kinds, std::vector const &Chunks ) override; // shader methods auto Fetch_Shader( std::string const &name ) -> std::shared_ptr override; // texture methods @@ -489,6 +495,8 @@ class opengl46_renderer : public gfx_renderer { void Render(TMemCell *Memcell); void Render_particles(); void Render_precipitation(); + // the grass of the heightmap terrain around the camera of the main view; Future: the matrix of the motion vectors + void Render_grass(glm::mat4 const &Future); void Render_vr_models(); void Render_Alpha(scene::basic_region *Region); void Render_Alpha(cell_sequence::reverse_iterator First, cell_sequence::reverse_iterator Last); @@ -544,6 +552,7 @@ class opengl46_renderer : public gfx_renderer { material_manager m_materials; texture_manager m_textures; opengl46_terrain_materials m_terrainmaterials{m_materials, m_textures}; + opengl46_grass m_grass; // of the heightmap terrain opengl46_light m_sunlight; // fog colour of the frame: the one of the simulation, or of the atmosphere (see opengl46_sky::update()) glm::vec3 m_fogcolor{0.f}; diff --git a/rendering/opengl46/opengl46terrain.cpp b/rendering/opengl46/opengl46terrain.cpp index 3e73952c..294a7338 100644 --- a/rendering/opengl46/opengl46terrain.cpp +++ b/rendering/opengl46/opengl46terrain.cpp @@ -79,7 +79,7 @@ bool opengl46_terrain_materials::init() // the shader is checked now, so a broken one makes the terrain fall back on plain materials instead of the failure indicator try { - GfxRenderer->Fetch_Shader("terrain"); + m_program = GfxRenderer->Fetch_Shader("terrain"); } catch (gl::shader_exception const &e) { @@ -192,6 +192,7 @@ std::size_t opengl46_terrain_materials::acquire_slot(material_handle const Reuse entry.weights[0] = make_texture("internal_src:terrain/weights0_" + suffix, GL_TEXTURE_2D); entry.weights[1] = make_texture("internal_src:terrain/weights1_" + suffix, GL_TEXTURE_2D); entry.table = make_texture("internal_src:terrain/table_" + suffix, GL_TEXTURE_2D); + entry.holes = make_texture("internal_src:terrain/holes_" + suffix, GL_TEXTURE_2D); // the textures get their content before the material is made of them, which checks they're there std::uint8_t const blank[4]{0, 0, 0, 0}; for (auto const handle : entry.weights) @@ -211,6 +212,18 @@ std::size_t opengl46_terrain_materials::acquire_slot(material_handle const Reuse std::array const zeros{}; ::glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, static_cast(max_layers), 2, 0, GL_RGBA, GL_FLOAT, zeros.data()); } + { + // led between the samples, so the edges of the holes run smoothly across the cells + bind_for_upload(m_textures.texture(entry.holes)); + ::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + ::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + ::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + ::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + std::uint8_t const none{0}; + ::glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + ::glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, 1, 1, 0, GL_RED, GL_UNSIGNED_BYTE, &none); + ::glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + } opengl_texture::reset_unit_cache(); auto const name = [&](texture_handle const Handle) { return "internal_src:" + m_textures.texture(Handle).name; }; std::string const definition{ @@ -221,7 +234,8 @@ std::size_t opengl46_terrain_materials::acquire_slot(material_handle const Reuse "texture4: " + name(m_arrays[0].handle) + "\n" "texture5: " + name(m_arrays[1].handle) + "\n" "texture6: " + name(m_arrays[2].handle) + "\n" - "texture7: " + name(entry.table) + "\n"}; + "texture7: " + name(entry.table) + "\n" + "texture8: " + name(entry.holes) + "\n"}; entry.material = m_materials.create_from_text("internal_src:terrain/chunk_" + suffix, definition); if (entry.material == null_handle || false == m_materials.material(entry.material).is_good) { @@ -235,7 +249,8 @@ std::size_t opengl46_terrain_materials::acquire_slot(material_handle const Reuse return index; } -material_handle opengl46_terrain_materials::make(material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement) +material_handle opengl46_terrain_materials::make(material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement, + int const HoleSamples, std::uint8_t const *Holes) { if (Layers.empty() || false == init()) return null_handle; @@ -286,8 +301,32 @@ material_handle opengl46_terrain_materials::make(material_handle const Reuse, st entry.rotations[i] = Layers[i].rotation; write_table(entry); + // the holes: the cells cut out entirely are left out of the mesh, the program of the holes cuts the rest of the openings + auto const cut{Holes != nullptr && HoleSamples > 0}; + { + auto const holeside{cut ? HoleSamples + 1 : 1}; + std::uint8_t const none{0}; + bind_for_upload(m_textures.texture(entry.holes)); + ::glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + ::glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, holeside, holeside, 0, GL_RED, GL_UNSIGNED_BYTE, cut ? Holes : &none); + ::glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + opengl_texture::reset_unit_cache(); + } auto &material{m_materials.material(entry.material)}; material.params[2] = glm::vec4(Placement.x, Placement.y, 1.f / std::max(1.f, Placement.z), static_cast(side - 1)); + if (m_holesprogram == nullptr && cut) + { + try + { + m_holesprogram = GfxRenderer->Fetch_Shader("terrain_holes"); + } + catch (gl::shader_exception const &e) + { + ErrorLog("heightmap terrain: can't use the shader of the holes: " + std::string(e.what())); + } + } + // the program of the holes discards fragments, which the depth prepass of the plain one never does + material.shader = (cut && m_holesprogram != nullptr) ? m_holesprogram : m_program; return entry.material; } diff --git a/rendering/opengl46/opengl46terrain.h b/rendering/opengl46/opengl46terrain.h index 76997f42..abcf74ab 100644 --- a/rendering/opengl46/opengl46terrain.h +++ b/rendering/opengl46/opengl46terrain.h @@ -20,7 +20,7 @@ http://mozilla.org/MPL/2.0/. #include "gl/framebuffer.h" #include "gl/postfx.h" -// materials of the heightmap terrain for the opengl 3.3 renderer (shader mat_terrain). +// materials of the heightmap terrain for the opengl 4.6 renderer (shader mat_terrain, mat_terrain_holes for chunks with holes). // the textures of the layer materials are copied into texture arrays shared by all chunks; each chunk gets a material // of its own, with textures holding the weights of its layers and a table of what its layers are class opengl46_terrain_materials @@ -29,7 +29,8 @@ class opengl46_terrain_materials opengl46_terrain_materials(material_manager &Materials, texture_manager &Textures) : m_materials(Materials), m_textures(Textures) {} // see gfx_renderer::Terrain_Material. returns null_handle if the terrain shader can't be used - material_handle make(material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement); + material_handle make(material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement, + int const HoleSamples = 0, std::uint8_t const *Holes = nullptr); void release(material_handle const Material); // copies the textures of new layers into the arrays; to be called before the frame is drawn void update(); @@ -63,6 +64,7 @@ class opengl46_terrain_materials material_handle material{null_handle}; std::array weights{null_handle, null_handle}; texture_handle table{null_handle}; + texture_handle holes{null_handle}; // where the ground is cut, R8; a single texel of 0 without holes bool used{false}; // what the table was made of, kept to make it again when a layer changes std::size_t count{0}; @@ -90,6 +92,8 @@ class opengl46_terrain_materials std::vector m_slots; std::vector m_free; std::unordered_map m_slotof; + std::shared_ptr m_program; // of the chunks, mat_terrain + std::shared_ptr m_holesprogram; // of the chunks with holes, mat_terrain_holes std::unique_ptr m_copy; std::unique_ptr m_framebuffer; }; diff --git a/rendering/renderer.cpp b/rendering/renderer.cpp index d9d4b5df..d3116c80 100644 --- a/rendering/renderer.cpp +++ b/rendering/renderer.cpp @@ -39,7 +39,7 @@ gfx_renderer_factory *gfx_renderer_factory::get_instance() gfx_renderer_factory *gfx_renderer_factory::instance; -material_handle gfx_renderer::Terrain_Material( material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement ) +material_handle gfx_renderer::Terrain_Material( material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement, int const HoleSamples, std::uint8_t const *Holes ) { // without blending the chunk gets the material which covers the most of it if( Layers.empty() ) { diff --git a/rendering/renderer.h b/rendering/renderer.h index 649dd795..be4bc9f8 100644 --- a/rendering/renderer.h +++ b/rendering/renderer.h @@ -9,6 +9,9 @@ http://mozilla.org/MPL/2.0/. #pragma once +#include +#include + #include "rendering/geometrybank.h" #include "interfaces/IMaterial.h" #include "interfaces/ITexture.h" @@ -30,6 +33,35 @@ struct terrain_layer float size{0.f}; float rotation{0.f}; // degrees the textures are turned by }; + +// kind of grass grown over the heightmap terrain (heightmap::grass_def), colours in linear rgb +struct grass_kind +{ + glm::vec3 base{0.f}; // at the root of the blades + glm::vec3 tip{0.f}; + float height{0.3f}; // metres + float variation{0.4f}; // share of the height the blades differ by, either way + float width{0.012f}; // metres, at the root + float density{60.f}; // blades per square metre where painted fully + float bend{0.3f}; // share of the height the tips lean over by +}; + +// a chunk of the heightmap terrain the grass grows on, see gfx_renderer::Terrain_Grass() +struct grass_chunk +{ + std::uint64_t id{0}; // the same for the same chunk, as long as it's in memory + std::uint64_t version{0}; // changes with every change of what the grass is made of + glm::dvec2 corner{0.0}; // x, z + float size{0.f}; // metres along a side + float low{0.f}, high{0.f}; // the lowest and the highest point + int cells{0}; // quads of the grid along a side + float const *heights{nullptr}; // (cells + 1)^2, rows of growing z + int samples{0}; // grass samples along a side less one + std::uint8_t const *density{nullptr}; // (samples + 1)^2 per kind, planar + std::array kinds{-1, -1, -1, -1}; // indices of the kinds of the samples, -1 unused + int holes{0}; // hole samples along a side less one, 0: none + std::uint8_t const *hole_mask{nullptr}; // (holes + 1)^2, 255: cut +}; } // namespace gfx class gfx_renderer { @@ -57,6 +89,9 @@ public: virtual auto Insert(gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometrybank_handle const &Geometry, int const Type) -> gfx::geometry_handle = 0; // replaces data of specified chunk with the supplied vertex data, starting from specified offset virtual auto Replace(gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry, int const Type, const std::size_t Offset = 0) -> bool = 0; + // replaces the index and the vertex data of specified indexed chunk with the supplied data of the same counts. + // returns: false if it wasn't replaced (the counts differ, or the backend can't) + virtual auto Replace_Indexed( gfx::index_array &Indices, gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry ) -> bool { return false; } // adds supplied vertex data at the end of specified chunk virtual auto Append(gfx::vertex_array &Vertices, gfx::userdata_array &Userdata, gfx::geometry_handle const &Geometry, int const Type) -> bool = 0; // provides direct access to index data of specfied chunk @@ -78,10 +113,17 @@ public: // Weights: (Samples + 1)^2 values per layer, planar, rows of growing z, the weights of a sample adding up to 255; null with a single layer. // Placement: corner of the chunk (x, z) in the texture coordinates of its mesh, and the size of the chunk. // Reuse: a material made by this call before, to be filled anew, or null_handle. + // Holes: (HoleSamples + 1)^2 values (255: the ground is cut there), rows like the weights; null without holes. the cells + // cut out entirely are left out of the geometry, the material cuts the rest of the openings. // returns: the material. backends which can't blend give back the material of the layer covering the most - virtual auto Terrain_Material( material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement ) -> material_handle; + virtual auto Terrain_Material( material_handle const Reuse, std::vector const &Layers, int const Samples, std::uint8_t const *Weights, glm::vec3 const &Placement, + int const HoleSamples = 0, std::uint8_t const *Holes = nullptr ) -> material_handle; // gives back a material made by Terrain_Material, once its chunk is gone virtual void Terrain_Release( material_handle const Material ) {} + // heightmap terrain: the grass to draw, the kinds of the grass palette and the chunks near the camera which have grass. + // the data of the chunks is read during the call only; a chunk the renderer has with the same id and version isn't read + // again. chunks left out are dropped. backends which can't draw grass ignore it + virtual void Terrain_Grass( std::vector const &Kinds, std::vector const &Chunks ) {} // shader methods virtual auto Fetch_Shader( std::string const &name ) -> std::shared_ptr = 0; // texture methods diff --git a/scene/heightmapterrain.cpp b/scene/heightmapterrain.cpp index e7b8f491..b346f764 100644 --- a/scene/heightmapterrain.cpp +++ b/scene/heightmapterrain.cpp @@ -39,6 +39,8 @@ std::uint32_t constexpr tag_heights{fourcc('H', 'G', 'H', 'T')}; std::uint32_t constexpr tag_base{fourcc('H', 'B', 'A', 'S')}; std::uint32_t constexpr tag_paint{fourcc('S', 'P', 'L', 'T')}; std::uint32_t constexpr tag_adjust{fourcc('H', 'A', 'D', 'J')}; +std::uint32_t constexpr tag_holes{fourcc('H', 'O', 'L', 'E')}; +std::uint32_t constexpr tag_grass{fourcc('G', 'R', 'A', 'S')}; std::uint32_t constexpr pack_magic{fourcc('T', 'C', 'H', '1')}; std::uint32_t constexpr pack_version{1}; @@ -188,6 +190,11 @@ float valid_spacing(float const Spacing) return best; } +int detail_samples(int const Cells) +{ + return std::max(default_paint_samples, Cells); +} + chunk_key chunk_at(double const X, double const Z) { return {static_cast(std::floor(X / chunk_size)), static_cast(std::floor(Z / chunk_size))}; @@ -429,6 +436,111 @@ void chunk_data::compact_layers() weights = std::move(kept); } +float chunk_data::hole_value(double const X, double const Z) const +{ + if (false == has_holes()) + return 0.f; + auto const low{corner()}; + auto const step{chunk_size / holes}; + auto const fx{std::clamp((X - low.x) / step, 0.0, static_cast(holes))}; + auto const fz{std::clamp((Z - low.y) / step, 0.0, static_cast(holes))}; + auto const ix{std::min(static_cast(fx), holes - 1)}; + auto const iz{std::min(static_cast(fz), holes - 1)}; + auto const tx{fx - ix}, tz{fz - iz}; + auto const side{static_cast(holes + 1)}; + auto const at = [&](int const X_, int const Z_) { return hole_mask[static_cast(Z_) * side + X_] / 255.0; }; + return static_cast((at(ix, iz) * (1.0 - tx) + at(ix + 1, iz) * tx) * (1.0 - tz) + (at(ix, iz + 1) * (1.0 - tx) + at(ix + 1, iz + 1) * tx) * tz); +} + +bool chunk_data::cut_cells(int const Ix0, int const Iz0, int const Ix1, int const Iz1) const +{ + if (false == has_holes() || cells <= 0) + return false; + // every sample the surface of the cells is led from has to be cut, then all of it is + auto const ratio{static_cast(holes) / cells}; + auto const range = [&](int const From, int const To) { + return std::make_pair(std::clamp(static_cast(std::floor(From * ratio + 1e-9)), 0, holes), std::clamp(static_cast(std::ceil(To * ratio - 1e-9)), 0, holes)); + }; + auto const [x0, x1]{range(Ix0, Ix1)}; + auto const [z0, z1]{range(Iz0, Iz1)}; + auto const side{static_cast(holes + 1)}; + for (int z = z0; z <= z1; ++z) + for (int x = x0; x <= x1; ++x) + if (hole_mask[static_cast(z) * side + x] < 128) + return false; + return true; +} + +void chunk_data::ensure_holes() +{ + if (has_holes()) + return; + holes = detail_samples(cells); + hole_mask.assign(static_cast(holes + 1) * (holes + 1), 0); +} + +void chunk_data::compact_holes() +{ + if (has_holes() && std::any_of(hole_mask.begin(), hole_mask.end(), [](std::uint8_t const Value) { return Value != 0; })) + return; + holes = 0; + hole_mask.clear(); +} + +std::size_t chunk_data::grass_count() const +{ + if (grass <= 0) + return 0; + std::size_t count{0}; + while (count < grass_kinds.size() && grass_kinds[count] != no_layer) + ++count; + return count; +} + +int chunk_data::grass_slot_of(std::uint16_t const Kind) const +{ + for (std::size_t slot = 0; slot < grass_count(); ++slot) + if (grass_kinds[slot] == Kind) + return static_cast(slot); + return -1; +} + +void chunk_data::ensure_grass() +{ + if (grass > 0) + return; + grass = default_paint_samples; + grass_kinds.fill(no_layer); + grass_density.clear(); +} + +void chunk_data::compact_grass() +{ + if (grass <= 0) + return; + auto const samples{static_cast(grass + 1) * (grass + 1)}; + auto const count{grass_count()}; + std::vector kept; + std::array keptkinds; + keptkinds.fill(no_layer); + std::size_t used{0}; + for (std::size_t slot = 0; slot < count; ++slot) + { + auto const begin{grass_density.begin() + slot * samples}; + if (slot * samples + samples > grass_density.size() || std::all_of(begin, begin + samples, [](std::uint8_t const Value) { return Value == 0; })) + continue; + kept.insert(kept.end(), begin, begin + samples); + keptkinds[used++] = grass_kinds[slot]; + } + grass_kinds = keptkinds; + grass_density = std::move(kept); + if (used == 0) + { + grass = 0; + grass_density.clear(); + } +} + // modifiers std::pair modifier::bounds() const @@ -541,6 +653,19 @@ std::optional modifier_shape::evaluate(double const X, d // manifest +std::vector const &grass_presets() +{ + static std::vector const presets{ + // name, root, tip, height, variation, width, density, bend + {"meadow", {0.22f, 0.33f, 0.09f}, {0.58f, 0.68f, 0.27f}, 0.25f, 0.45f, 0.020f, 90.f, 0.30f}, + {"lawn", {0.18f, 0.30f, 0.08f}, {0.42f, 0.58f, 0.20f}, 0.07f, 0.30f, 0.012f, 200.f, 0.15f}, + {"dry grass", {0.36f, 0.32f, 0.16f}, {0.80f, 0.72f, 0.45f}, 0.30f, 0.45f, 0.016f, 70.f, 0.40f}, + {"tall grass", {0.18f, 0.27f, 0.07f}, {0.60f, 0.66f, 0.30f}, 0.70f, 0.35f, 0.022f, 40.f, 0.45f}, + {"weeds", {0.16f, 0.25f, 0.08f}, {0.45f, 0.55f, 0.22f}, 0.16f, 0.60f, 0.030f, 30.f, 0.25f}, + }; + return presets; +} + bool load_manifest(std::string const &Path, manifest &Manifest, std::string *Error) { try @@ -615,6 +740,30 @@ bool load_manifest(std::string const &Path, manifest &Manifest, std::string *Err if (item.points.size() >= 2) result.modifiers.push_back(std::move(item)); } + if (auto const node{document["grass"]}; node && node.IsSequence()) + for (auto const &entry : node) + { + grass_def kind; + auto const colour = [](YAML::Node const &Node, glm::vec3 &Colour) { + if (Node && Node.IsSequence() && Node.size() >= 3) + Colour = glm::clamp(glm::vec3{Node[0].as(), Node[1].as(), Node[2].as()}, glm::vec3{0.f}, glm::vec3{1.f}); + }; + if (auto const name{entry["name"]}) + kind.name = name.as(); + colour(entry["base"], kind.base); + colour(entry["tip"], kind.tip); + if (auto const height{entry["height"]}) + kind.height = std::max(0.01f, height.as()); + if (auto const variation{entry["variation"]}) + kind.variation = std::clamp(variation.as(), 0.f, 1.f); + if (auto const width{entry["width"]}) + kind.width = std::max(0.001f, width.as()); + if (auto const density{entry["density"]}) + kind.density = std::max(0.f, density.as()); + if (auto const bend{entry["bend"]}) + kind.bend = std::clamp(bend.as(), 0.f, 1.f); + result.grass.push_back(std::move(kind)); + } Manifest = std::move(result); return true; } @@ -689,6 +838,24 @@ bool save_manifest(std::string const &Path, manifest const &Manifest, std::strin } out << YAML::EndSeq; } + if (false == Manifest.grass.empty()) + { + out << YAML::Key << "grass" << YAML::Value << YAML::BeginSeq; + for (auto const &kind : Manifest.grass) + { + out << YAML::BeginMap; + out << YAML::Key << "name" << YAML::Value << kind.name; + out << YAML::Key << "base" << YAML::Value << YAML::Flow << YAML::BeginSeq << kind.base.r << kind.base.g << kind.base.b << YAML::EndSeq; + out << YAML::Key << "tip" << YAML::Value << YAML::Flow << YAML::BeginSeq << kind.tip.r << kind.tip.g << kind.tip.b << YAML::EndSeq; + out << YAML::Key << "height" << YAML::Value << kind.height; + out << YAML::Key << "variation" << YAML::Value << kind.variation; + out << YAML::Key << "width" << YAML::Value << kind.width; + out << YAML::Key << "density" << YAML::Value << kind.density; + out << YAML::Key << "bend" << YAML::Value << kind.bend; + out << YAML::EndMap; + } + out << YAML::EndSeq; + } out << YAML::EndMap; if (false == out.good()) { @@ -844,7 +1011,7 @@ std::shared_ptr pack_file::read(chunk_key const &Key, std::string *E auto chunk{std::make_shared()}; chunk->key = Key; - slice const *heights{nullptr}, *base{nullptr}, *adjust{nullptr}, *paint{nullptr}; + slice const *heights{nullptr}, *base{nullptr}, *adjust{nullptr}, *paint{nullptr}, *holes{nullptr}, *grass{nullptr}; bool header{false}; for (auto const &slice : slices) { @@ -871,6 +1038,10 @@ std::shared_ptr pack_file::read(chunk_key const &Key, std::string *E adjust = &slice; else if (slice.tag == tag_paint) paint = &slice; + else if (slice.tag == tag_holes) + holes = &slice; + else if (slice.tag == tag_grass) + grass = &slice; // slices of unknown kinds are left for newer versions of the simulator } if (false == header || heights == nullptr || chunk->cells <= 0 || chunk->cells > 4096) @@ -892,6 +1063,34 @@ std::shared_ptr pack_file::read(chunk_key const &Key, std::string *E if (paint == nullptr || false == load(*paint, chunk->weights) || chunk->weights.size() != expected) return fail("damaged paint"); } + if (holes != nullptr) + { + if (false == load(*holes, data)) + return fail("damaged holes"); + reader input{data.data(), data.size()}; + std::uint16_t samples{0}; + input.get(samples); + auto const count{static_cast(samples + 1) * (samples + 1)}; + chunk->holes = samples; + chunk->hole_mask.resize(count); + if (samples == 0 || input.left() != count || false == input.get(chunk->hole_mask.data(), count)) + return fail("damaged holes"); + } + if (grass != nullptr) + { + if (false == load(*grass, data)) + return fail("damaged grass"); + reader input{data.data(), data.size()}; + std::uint16_t samples{0}; + input.get(samples); + for (auto &kind : chunk->grass_kinds) + input.get(kind); + chunk->grass = samples; + auto const count{static_cast(samples + 1) * (samples + 1) * chunk->grass_count()}; + chunk->grass_density.resize(count); + if (samples == 0 || input.left() != count || false == input.get(chunk->grass_density.data(), count)) + return fail("damaged grass"); + } return chunk; } @@ -958,6 +1157,22 @@ bool write_pack(std::string const &Path, pack_key const &Key, std::vectorpaint > 0 && chunk->layer_count() > 1) add(table, tag_paint, chunk->weights); + if (chunk->has_holes()) + { + data.clear(); + put(data, static_cast(chunk->holes)); + data.insert(data.end(), chunk->hole_mask.begin(), chunk->hole_mask.end()); + add(table, tag_holes, data); + } + if (auto const kinds{chunk->grass_count()}; kinds > 0 && chunk->grass_density.size() == static_cast(chunk->grass + 1) * (chunk->grass + 1) * kinds) + { + data.clear(); + put(data, static_cast(chunk->grass)); + for (auto const kind : chunk->grass_kinds) + put(data, kind); + data.insert(data.end(), chunk->grass_density.begin(), chunk->grass_density.end()); + add(table, tag_grass, data); + } } std::vector file; @@ -1146,6 +1361,42 @@ void build_indices(int const Cells, int const Stride, gfx::index_array &Out) skirt(grid + 3 * row, [&](gfx::basic_index const K) { return at(n, K); }, false); } +void cut_indices(chunk_data const &Chunk, int const Stride, gfx::index_array &Indices) +{ + if (false == Chunk.has_holes()) + return; + auto const cells{Chunk.cells}; + auto const stride{std::clamp(Stride, 1, max_stride(cells))}; + auto const n{segments(cells, stride)}; + auto const grid{static_cast(n) * n * 6}; + if (Indices.size() != grid + static_cast(n) * 24) + return; + auto const coordinate = [&](int const I) { return std::min(I * stride, cells); }; + // the triangles of a cell cut out entirely become empty ones, so the count of the indices stays + std::vector cut(static_cast(n) * n, false); + for (int j = 0; j < n; ++j) + for (int i = 0; i < n; ++i) + { + if (false == Chunk.cut_cells(coordinate(i), coordinate(j), coordinate(i + 1), coordinate(j + 1))) + continue; + cut[static_cast(j) * n + i] = true; + std::fill_n(Indices.begin() + (static_cast(j) * n + i) * 6, 6, Indices[(static_cast(j) * n + i) * 6]); + } + // the skirts along the cells cut out would hang in the opening + auto const skirt = [&](std::size_t const Skirt, auto const &Cell) { + for (int k = 0; k < n; ++k) + if (Cell(k)) + { + auto const first{grid + (Skirt * n + k) * 6}; + std::fill_n(Indices.begin() + first, 6, Indices[first]); + } + }; + skirt(0, [&](int const K) { return cut[K]; }); + skirt(1, [&](int const K) { return cut[static_cast(n - 1) * n + K]; }); + skirt(2, [&](int const K) { return cut[static_cast(K) * n]; }); + skirt(3, [&](int const K) { return cut[static_cast(K) * n + n - 1]; }); +} + void build_mesh(chunk_data const &Chunk, int const Stride, glm::dvec3 const &Origin, height_source const &Outside, mesh &Out) { auto const cells{Chunk.cells}; @@ -1223,6 +1474,9 @@ void build_mesh(chunk_data const &Chunk, int const Stride, glm::dvec3 const &Ori vertex(cells, coordinate(k), skirtdepth); build_indices(cells, stride, Out.indices); + Out.cut = Chunk.has_holes(); + if (Out.cut) + cut_indices(Chunk, stride, Out.indices); Out.centre = (lowest + highest) * 0.5; Out.radius = static_cast(glm::length(highest - lowest) * 0.5 + skirtdepth); Out.low = lowest - glm::dvec3{0.0, skirtdepth, 0.0}; diff --git a/scene/heightmapterrain.h b/scene/heightmapterrain.h index 707af808..0a0a077e 100644 --- a/scene/heightmapterrain.h +++ b/scene/heightmapterrain.h @@ -35,6 +35,7 @@ double constexpr height_step{0.005}; // metres, quantum of the stored heights std::size_t constexpr max_chunk_layers{8}; // materials painted over a single chunk std::uint16_t constexpr no_layer{0xffff}; int constexpr default_paint_samples{250}; // paint samples along the side of a chunk, less one: a sample per metre +std::size_t constexpr max_chunk_grass{4}; // kinds of grass growing on a single chunk // point spacings a chunk can have, finest first std::array constexpr spacings{0.5f, 1.f, 2.f, 5.f}; @@ -43,6 +44,9 @@ float constexpr default_spacing{2.f}; int cells_for(float const Spacing); // the allowed spacing closest to specified one float valid_spacing(float const Spacing); +// samples along the side of a chunk less one, of the holes and the grass of a chunk with specified cells: a sample a metre, +// or one at every grid point of a finer grid +int detail_samples(int const Cells); using chunk_key = std::pair; using pack_key = std::pair; @@ -70,8 +74,22 @@ struct chunk_data int paint{0}; // paint samples along a side, less one. 0: the chunk is covered with its first layer alone std::array layers; // indices of the materials in the terrain palette, no_layer for unused std::vector weights; // planar, (paint + 1)^2 per used layer, rows like the heights; weights of a sample add up to 255 + // holes cut in the ground, for the portals of tunnels and the like: (holes + 1)^2 samples, rows like the heights, 255 where + // the ground is cut and 0 where it stays. the surface is cut where the value led between the samples is over the half. + // 0: no holes + int holes{0}; + std::vector hole_mask; + // grass grown on the chunk by the renderer: the density (0..255) of up to 4 kinds of the grass palette at (grass + 1)^2 + // samples, planar per used kind, rows like the heights. the densities of a sample add up to 255 at most. 0: no grass + int grass{0}; + std::array grass_kinds; // indices of the kinds in the grass palette, no_layer for unused + std::vector grass_density; - chunk_data() { layers.fill(no_layer); } + chunk_data() + { + layers.fill(no_layer); + grass_kinds.fill(no_layer); + } int side() const { return cells + 1; } double spacing() const { return chunk_size / cells; } @@ -101,6 +119,28 @@ struct chunk_data void ensure_paint(int const Samples = default_paint_samples); // drops the layers left without weight anywhere, and the paint samples altogether when a single layer is left void compact_layers(); + + // holes + bool has_holes() const { return holes > 0 && hole_mask.size() == static_cast(holes + 1) * (holes + 1); } + // 0..1, how much the point is cut, led between the samples; the surface is cut where it's over the half + float hole_value(double const X, double const Z) const; + bool hole_at(double const X, double const Z) const { return has_holes() && hole_value(X, Z) > 0.5f; } + // true if the whole of the grid cells from (Ix0, Iz0) to (Ix1, Iz1) (exclusive) is cut out + bool cut_cells(int const Ix0, int const Iz0, int const Ix1, int const Iz1) const; + // gives the chunk a hole mask with nothing cut, if it has none + void ensure_holes(); + // drops the hole mask if nothing is cut + void compact_holes(); + + // grass + // number of kinds of grass in use, the used ones come first + std::size_t grass_count() const; + // grass slot of specified kind of the palette, or -1 + int grass_slot_of(std::uint16_t const Kind) const; + // gives the chunk grass samples (with no kind) if it has none yet + void ensure_grass(); + // drops the kinds left without density anywhere, and the samples altogether when no grass is left + void compact_grass(); }; using chunk_ptr = std::shared_ptr; @@ -171,6 +211,22 @@ struct layer_def float rotation{0.f}; // degrees the textures are turned by, counterclockwise seen from above }; +// kind of grass of the grass palette: blades the renderer makes up where the grass is painted +struct grass_def +{ + std::string name; + glm::vec3 base{0.22f, 0.33f, 0.09f}; // colour at the root of the blades, sRGB 0..1 + glm::vec3 tip{0.58f, 0.68f, 0.27f}; // colour at the tips + float height{0.25f}; // metres, of an average blade + float variation{0.45f}; // share of the height the blades differ by, either way + float width{0.02f}; // metres, of a blade at its root + float density{90.f}; // blades per square metre where the kind is painted fully + float bend{0.3f}; // how far the tips lean over, share of the height +}; + +// a few kinds to start the palette with +std::vector const &grass_presets(); + // description of the terrain, terrain.yaml struct manifest { @@ -181,6 +237,7 @@ struct manifest float spacing{default_spacing}; // point spacing of new chunks std::vector water; std::vector modifiers; + std::vector grass; // the grass palette }; bool load_manifest(std::string const &Path, manifest &Manifest, std::string *Error = nullptr); @@ -202,6 +259,8 @@ bool save_manifest(std::string const &Path, manifest const &Manifest, std::strin // 'HBAS' heights before the modifiers, as 'HGHT' // 'HADJ' touch-ups over the modifiers, as 'HGHT' (only with 'HBAS'; older versions skip it and keep the heights) // 'SPLT' paint weights: uint8 per sample per used layer, planar +// 'HOLE' holes: uint16 samples less one, then uint8 per sample (255: cut) +// 'GRAS' grass: uint16 samples less one, uint16 kinds[4], then uint8 density per sample per used kind, planar class pack_file { public: @@ -247,12 +306,17 @@ struct mesh float radius{0.f}; // from the centre of the vertices glm::dvec3 centre{0.0}; glm::dvec3 low{0.0}, high{0.0}; // world bounds of the grid, the skirts included + // the chunk has holes: the cells cut out are left out of the indices (their triangles made empty, so the count of the + // indices is the same as without the holes), the indices are its own + bool cut{false}; }; // strides the grid of a chunk with specified cells can be drawn with int max_stride(int const Cells); void build_mesh(chunk_data const &Chunk, int const Stride, glm::dvec3 const &Origin, height_source const &Outside, mesh &Out); // indices of the mesh of a chunk with specified cells and stride; the same for every chunk of these void build_indices(int const Cells, int const Stride, gfx::index_array &Out); +// leaves the cells of the chunk cut out entirely, and the skirts along them, out of the indices made by build_indices() +void cut_indices(chunk_data const &Chunk, int const Stride, gfx::index_array &Indices); // vertices the mesh of a chunk with specified cells and stride has std::size_t mesh_vertex_count(int const Cells, int const Stride); diff --git a/shaders/grass.frag b/shaders/grass.frag new file mode 100644 index 00000000..49128796 --- /dev/null +++ b/shaders/grass.frag @@ -0,0 +1,41 @@ +// grass of the heightmap terrain (opengl46_grass, grass.vert): the blades lit like the rest of the scene + +in vec3 f_normal; +in vec4 f_pos; +in vec3 f_albedo; +in vec4 f_clip_pos; +in vec4 f_clip_future_pos; + +#include + +layout(location = 0) out vec4 out_color; +#if MOTIONBLUR_ENABLED +layout(location = 1) out vec4 out_motion; +#endif + +#include +#include +#include + +void main() +{ + vec3 fragnormal = normalize(f_normal); + glossiness = 6.0; + metalic = 0.0; + vec3 fragcolor = apply_lights(ambient, fragnormal, f_albedo, 0.0, 0.2, shadow_tone); + + vec4 color = vec4(apply_fog(fragcolor), 1.0); +#if POSTFX_ENABLED + out_color = color; +#else + out_color = tonemap(color); +#endif +#if MOTIONBLUR_ENABLED + { + vec2 a = (f_clip_future_pos.xy / f_clip_future_pos.w) * 0.5 + 0.5; + vec2 b = (f_clip_pos.xy / f_clip_pos.w) * 0.5 + 0.5; + + out_motion = vec4(a - b, 0.0f, 0.0f); + } +#endif +} diff --git a/shaders/grass.vert b/shaders/grass.vert new file mode 100644 index 00000000..1d62b607 --- /dev/null +++ b/shaders/grass.vert @@ -0,0 +1,206 @@ +// grass of the heightmap terrain (opengl46_grass): a blade made of the numbers of its vertex and its instance. +// +// the instances of a draw stand in a tile of a chunk. a blade takes its place in the tile from a sequence which spreads +// any number of its first members evenly (R2, shifted for each tile), so drawing fewer of them thins the grass out +// evenly; a blade picks its kind by the densities painted there, and gets its height, its turn and its lean from its own +// numbers, so it's the same in every frame. blades of no kind, in the holes, or past the share drawn at their distance are +// collapsed into a point outside the view. the vertices go up the blade in pairs, the last one is its tip. +// the chunk is drawn relative to its corner: x, z in metres from the corner, y the height as it is + +#include + +// x, z: corner of the tile in the chunk, metres; y: side of the tile; w: blades of the tile where its densest kind is painted fully +uniform vec4 grass_tile; +// x: the share of the densest kind at the densest place of the tile (the blades of the tile are counted for it); y: segments +// of a blade; z, w: number of the tile in the world +uniform vec4 grass_part; +// x: side of the chunk, metres; y: cells of its grid along a side; z: grass samples along a side less one +uniform vec4 grass_chunk; +// the camera relative to the corner of the chunk (y as it is) +uniform vec4 grass_camera; +// x: distance the grass starts to thin out at; y: reach of the grass +uniform vec4 grass_lod; +// x, y: direction of the wind (x, z of the world); z: its strength, 0..1; w: time, seconds +uniform vec4 grass_wind; +// the kinds of the chunk: colour at the root and at the tip (linear rgb), and height, its variation, width, lean +uniform vec4 grass_base[4]; +uniform vec4 grass_tip[4]; +uniform vec4 grass_shape[4]; +// share of the blades of each kind against the densest one of the chunk +uniform vec4 grass_share; + +layout(binding = 0) uniform sampler2D grass_heights; // heights of the grid points, R32F +layout(binding = 1) uniform sampler2D grass_density; // densities of the kinds, RGBA8 +layout(binding = 2) uniform sampler2D grass_holes; // holes cut in the ground, R8 + +out vec3 f_normal; +out vec4 f_pos; +out vec3 f_albedo; +out vec4 f_clip_pos; +out vec4 f_clip_future_pos; +out vec4 UserData; + +uint hash(uint x) +{ + x ^= x >> 16; + x *= 0x7feb352dU; + x ^= x >> 15; + x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} + +float random(inout uint state) +{ + state = hash(state); + return float(state >> 8) * (1.0 / 16777216.0); +} + +// the share of the blades drawn at specified distance; the same as lod_share() of opengl46grass.cpp +float lod_share(float distance) +{ + return distance <= grass_lod.x ? 1.0 : pow(grass_lod.x / distance, 1.5); +} + +// height of the ground at a point of the chunk and its normal, the way the full grid is triangulated +float ground(vec2 point, out vec3 normal) +{ + float cells = grass_chunk.y; + float spacing = grass_chunk.x / cells; + vec2 place = clamp(point / spacing, vec2(0.0), vec2(cells)); + ivec2 cell = min(ivec2(place), ivec2(int(cells) - 1)); + vec2 t = place - vec2(cell); + float h00 = texelFetch(grass_heights, cell, 0).r; + float h10 = texelFetch(grass_heights, cell + ivec2(1, 0), 0).r; + float h01 = texelFetch(grass_heights, cell + ivec2(0, 1), 0).r; + float h11 = texelFetch(grass_heights, cell + ivec2(1, 1), 0).r; + // the quads are cut along the diagonal from (x0, z1) to (x1, z0), as the mesh is made + if (t.x + t.y <= 1.0) + { + normal = normalize(vec3(-(h10 - h00) / spacing, 1.0, -(h01 - h00) / spacing)); + return h00 + t.x * (h10 - h00) + t.y * (h01 - h00); + } + normal = normalize(vec3(-(h11 - h01) / spacing, 1.0, -(h11 - h10) / spacing)); + return h11 + (1.0 - t.x) * (h01 - h11) + (1.0 - t.y) * (h10 - h11); +} + +void collapse() +{ + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + f_normal = vec3(0.0, 1.0, 0.0); + f_pos = vec4(0.0); + f_albedo = vec3(0.0); + f_clip_pos = gl_Position; + f_clip_future_pos = gl_Position; + UserData = vec4(0.0); +} + +void main() +{ + int blade = gl_InstanceID; + // the numbers of the blade, drawn in the same order whatever becomes of them + uint state = hash(uint(blade) * 0x9E3779B9U ^ hash(uint(int(grass_part.z)) * 0x85EBCA6BU ^ hash(uint(int(grass_part.w)) + 0x27D4EB2FU))); + uint tilestate = hash(uint(int(grass_part.z)) * 0x85EBCA6BU ^ hash(uint(int(grass_part.w)) + 0x165667B1U)); + vec2 shift = vec2(random(tilestate), random(tilestate)); + vec2 jitter = vec2(random(state), random(state)) - 0.5; + float pick = random(state); + float heightrandom = random(state) * 2.0 - 1.0; + float widthrandom = random(state); + float turnrandom = random(state) * 6.2831853; + float leanrandom = random(state) * 6.2831853; + float leanamount = random(state); + float shade = random(state); + float phase = random(state); + + // place: R2 spreads the first blades evenly at any count, a little jitter breaks its lattice + vec2 spot = fract(shift + float(blade) * vec2(0.7548776662466927, 0.5698402909980532)); + vec2 point = grass_tile.xy + fract(spot + jitter * (0.7 / sqrt(grass_tile.w))) * grass_tile.z; + if (any(lessThan(point, vec2(0.0))) || any(greaterThanEqual(point, vec2(grass_chunk.x)))) + { + collapse(); + return; + } + vec2 local = point / grass_chunk.x; + float samples = grass_chunk.z; + vec4 density = texture(grass_density, (local * samples + 0.5) / (samples + 1.0)); + float holeside = float(textureSize(grass_holes, 0).x); + float hole = texture(grass_holes, (local * (holeside - 1.0) + 0.5) / holeside).r; + + // the kind: the blades of the tile are shared among the kinds by their densities here + vec4 cover = density * grass_share / max(grass_part.x, 1e-3); + float covered = cover.x + cover.y + cover.z + cover.w; + if (covered > 1.0) + cover /= covered; + int kind = -1; + float sum = 0.0; + for (int i = 0; i < 4; ++i) + { + sum += cover[i]; + if (pick < sum) + { + kind = i; + break; + } + } + + vec3 groundnormal; + vec3 root = vec3(point.x, ground(point, groundnormal), point.y); + float distance = length(root - grass_camera.xyz); + float share = lod_share(distance); + float rank = (float(blade) + 0.5) / grass_tile.w; + if (kind < 0 || hole > 0.5 || rank >= share || distance > grass_lod.y) + { + collapse(); + return; + } + + vec4 shape = grass_shape[kind]; + float height = shape.x * (1.0 + shape.y * heightrandom); + // the last of the blades drawn at the distance grow in, and the grass fades out at the end of its reach + height *= 1.0 - smoothstep(share * 0.8, share, rank); + height *= 1.0 - smoothstep(grass_lod.y * 0.75, grass_lod.y, distance); + // where the paint thins out the blades get shorter + height *= mix(0.5, 1.0, clamp(density[kind] * 1.5, 0.0, 1.0)); + // the blades drawn further away stand for more of them, so they're wider + float width = shape.z * (0.8 + 0.4 * widthrandom) / sqrt(share); + + // the face of the blade turns towards the camera further away, so the blades don't vanish edge on + vec2 facing = vec2(cos(turnrandom), sin(turnrandom)); + vec2 toview = grass_camera.xz - root.xz; + toview = toview / max(length(toview), 1e-3); + facing = mix(facing, toview, smoothstep(grass_lod.x * 0.5, grass_lod.x * 2.0, distance)); + facing = facing / max(length(facing), 1e-3); + vec2 across = vec2(-facing.y, facing.x); + + // the lean of the blade and the wind: a steady push along it, with gusts running across the field, and a flutter + vec2 lean = vec2(cos(leanrandom), sin(leanrandom)) * (shape.w * (0.4 + 0.6 * leanamount)); + float gust = 0.5 + 0.5 * sin(grass_wind.w * 0.9 - dot(root.xz, grass_wind.xy) * 0.12); + float flutter = sin(grass_wind.w * (2.0 + phase) + phase * 6.2831853); + vec2 sway = grass_wind.xy * grass_wind.z * (0.15 + 0.45 * gust + 0.08 * flutter); + vec2 bend = lean + sway; + // a long lean takes some of the height + float bent = min(length(bend), 0.9); + + int segments = int(grass_part.y); + int level = min(gl_VertexID / 2, segments); + float side = (gl_VertexID >= segments * 2) ? 0.0 : ((gl_VertexID & 1) == 0 ? -0.5 : 0.5); + float t = float(level) / float(segments); + float halfwidth = width * (1.0 - pow(t, 1.5)); + vec2 xz = root.xz + across * (side * halfwidth) + bend * (height * t * t); + float y = root.y - 0.02 + height * t * (1.0 - 0.35 * bent * t); + vec3 position = vec3(xz.x, y, xz.y); + + // lit mostly as the ground under it, which keeps a field of blades from shimmering + vec3 normal = normalize(groundnormal * 0.8 + vec3(facing.x, 0.0, facing.y) * (side == 0.0 ? 0.0 : 0.25) + vec3(0.0, 0.3, 0.0)); + vec3 albedo = mix(grass_base[kind].rgb, grass_tip[kind].rgb, t) * (0.85 + 0.3 * shade); + // the lower part of the blades is shaded by the others + albedo *= mix(0.7, 1.0, t); + + f_pos = modelview * vec4(position, 1.0); + f_normal = normalize(modelviewnormal * normal); + f_albedo = albedo; + f_clip_pos = projection * f_pos; + f_clip_future_pos = (projection * future) * f_pos; + gl_Position = f_clip_pos; + UserData = vec4(0.0); +} diff --git a/shaders/mat_terrain.frag b/shaders/mat_terrain.frag index 77519e1c..cd219690 100644 --- a/shaders/mat_terrain.frag +++ b/shaders/mat_terrain.frag @@ -51,6 +51,12 @@ void main() { // place within the chunk, 0..1 along x and z, and the paint samples around it vec2 local = clamp(vec2(f_coord.x - param[2].x, -f_coord.y - param[2].y) * param[2].z, 0.0, 1.0); +#if TERRAIN_HOLES + // the openings cut in the ground (mat_terrain_holes.frag): the mask led between its samples, cut over the half. + // discarded at the end, the neighbours of the fragment still need it for their derivatives + float holeside = float(textureSize(holes, 0).x); + bool cut = (texture(holes, (local * (holeside - 1.0) + 0.5) / holeside).r > 0.5); +#endif float samples = param[2].w; vec2 paintcoord = (local * samples + 0.5) / (samples + 1.0); vec4 w0 = texture(weights0, paintcoord); @@ -122,6 +128,10 @@ void main() vec3 fragcolor = ambient; fragcolor = apply_lights(fragcolor, fragnormal, albedo, reflectivity, specularity, shadow_tone); +#if TERRAIN_HOLES + if (cut) + discard; +#endif vec4 color = vec4(apply_fog(fragcolor), alpha_mult); #if POSTFX_ENABLED out_color = color; diff --git a/shaders/mat_terrain_holes.frag b/shaders/mat_terrain_holes.frag new file mode 100644 index 00000000..4848b1df --- /dev/null +++ b/shaders/mat_terrain_holes.frag @@ -0,0 +1,9 @@ +// heightmap terrain with holes cut in it, for the portals of tunnels and the like (opengl46_terrain_materials): the cells cut +// out entirely are left out of the mesh, the rest of the openings is cut here. the mask of the holes covers the chunk, 255 +// where the ground is cut; the fragments where the value led between its samples is over the half are discarded +#define TERRAIN_HOLES 1 + +#texture (holes, 7, R) +uniform sampler2D holes; + +#include diff --git a/utilities/Globals.cpp b/utilities/Globals.cpp index 49fee1ae..55f87c29 100644 --- a/utilities/Globals.cpp +++ b/utilities/Globals.cpp @@ -1528,6 +1528,23 @@ global_settings::ConfigParse_gfx( cParser &Parser, std::string_view const Token Parser.getTokens(1); Parser >> gfx_depthprepass_replay; } + else if (Token == "gfx.grass") + { + Parser.getTokens(1); + Parser >> gfx_grass; + } + else if (Token == "gfx.grass.distance") + { + Parser.getTokens(1); + Parser >> gfx_grass_distance; + gfx_grass_distance = std::clamp(gfx_grass_distance, 5.f, 500.f); + } + else if (Token == "gfx.grass.density") + { + Parser.getTokens(1); + Parser >> gfx_grass_density; + gfx_grass_density = std::clamp(gfx_grass_density, 0.f, 4.f); + } else if (Token == "gfx.bufferstreaming") { Parser.getTokens(1); @@ -2041,6 +2058,9 @@ global_settings::export_as_text( std::ostream &Output ) const { export_as_text( Output, "gfx.gpucull.models", gfx_gpucull_models ); export_as_text( Output, "gfx.depthprepass.scene", gfx_depthprepass_scene ); export_as_text( Output, "gfx.depthprepass.replay", gfx_depthprepass_replay ); + export_as_text( Output, "gfx.grass", gfx_grass ); + export_as_text( Output, "gfx.grass.distance", gfx_grass_distance ); + export_as_text( Output, "gfx.grass.density", gfx_grass_density ); export_as_text( Output, "gfx.bufferstreaming", gfx_bufferstreaming ); export_as_text( Output, "gfx.postfx.motionblur.enabled", gfx_postfx_motionblur_enabled ); export_as_text( Output, "gfx.postfx.motionblur.shutter", gfx_postfx_motionblur_shutter ); diff --git a/utilities/Globals.h b/utilities/Globals.h index c90cdf9f..cf08a8c0 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -321,6 +321,11 @@ struct global_settings { // the colour pass as well: its opaque draws are recorded once, their depth drawn from the record (with the contact shadows // alone only within their reach) and their colour too. false: the scene is walked twice, as before bool gfx_depthprepass_replay = true; + // (opengl 4.6) grass of the heightmap terrain, grown where it's painted: drawn, how far from the camera (metres), and how + // many blades of the density painted (1: all) + bool gfx_grass = true; + float gfx_grass_distance = 60.f; + float gfx_grass_density = 1.f; // (opengl 4.6) the model block of every draw and the matrices of instances are written to persistently mapped buffers, // one for each of the last frames, instead of with glBufferSubData. read at the start bool gfx_bufferstreaming = true;