diff --git a/CMakeLists.txt b/CMakeLists.txt index a0a4ef44..6c7f0ec5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -167,6 +167,7 @@ set(SOURCES "application/driveruipanels.cpp" "input/editorkeyboardinput.cpp" "editor/editorSettings.cpp" +"editor/editorTerrain.cpp" "application/editormode.cpp" "input/editormouseinput.cpp" "application/editoruilayer.cpp" diff --git a/application/editormode.cpp b/application/editormode.cpp index 6457fe04..ede1448d 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -21,6 +21,8 @@ http://mozilla.org/MPL/2.0/. #include "Console.h" #include "rendering/renderer.h" #include "model/AnimModel.h" +#include "model/Model3d.h" +#include "utilities/Float3d.h" #include "scene/scene.h" @@ -29,8 +31,10 @@ http://mozilla.org/MPL/2.0/. #include "utilities/Logs.h" #include #include +#include #include #include +#include #include // Static member initialization @@ -54,6 +58,65 @@ namespace return state == GLFW_PRESS; } + // tests whether the vertical line through (Px,Pz) passes over triangle abc; if so returns the + // surface height at that point through OutY. used by the "snap to ground" (END) feature. + inline bool triangle_height_at(glm::dvec3 const &a, glm::dvec3 const &b, glm::dvec3 const &c, + double const Px, double const Pz, double &OutY) + { + double const ux = b.x - a.x, uz = b.z - a.z; + double const vx = c.x - a.x, vz = c.z - a.z; + double const wx = Px - a.x, wz = Pz - a.z; + double const den = ux * vz - vx * uz; + if (std::abs(den) < 1e-9) + return false; // degenerate or vertical triangle, no defined height + double const s = (wx * vz - vx * wz) / den; + double const t = (ux * wz - wx * uz) / den; + if (s < 0.0 || t < 0.0 || (s + t) > 1.0) + return false; + OutY = a.y + s * (b.y - a.y) + t * (c.y - a.y); + return true; + } + + using world_triangle = std::array; + + // walks a model's submodel tree (mirroring the renderer's transform chain) and appends every + // mesh triangle, in world space, to Out. siblings are iterated to avoid deep recursion. + void gather_submodel_triangles(TSubModel *Submodel, glm::dmat4 const &M, std::vector &Out) + { + for (TSubModel *sub = Submodel; sub != nullptr; sub = sub->Next) + { + glm::dmat4 mlocal = M; + if ((sub->iFlags & 0xC000) && (sub->GetMatrix() != nullptr)) + mlocal = M * glm::dmat4(glm::make_mat4(sub->GetMatrix()->readArray())); + + if (sub->eType < TP_ROTATOR) // a drawable mesh, not a rotator/light/etc. + { + auto const handle = sub->m_geometry.handle; + if (handle.bank != 0 || handle.chunk != 0) + { + auto const &verts = GfxRenderer->Vertices(handle); + auto const &indices = GfxRenderer->Indices(handle); + auto const to_world = [&](gfx::basic_vertex const &v) { + return glm::dvec3(mlocal * glm::dvec4(glm::dvec3(v.position), 1.0)); + }; + if (false == indices.empty()) + { + for (std::size_t i = 0; i + 2 < indices.size(); i += 3) + Out.push_back({to_world(verts[indices[i]]), to_world(verts[indices[i + 1]]), to_world(verts[indices[i + 2]])}); + } + else + { + for (std::size_t i = 0; i + 2 < verts.size(); i += 3) + Out.push_back({to_world(verts[i]), to_world(verts[i + 1]), to_world(verts[i + 2])}); + } + } + } + + if (sub->Child != nullptr) + gather_submodel_triangles(sub->Child, mlocal, Out); // children inherit this matrix + } + } + } bool editor_mode::editormode_input::init() @@ -104,18 +167,132 @@ void editor_mode::start_focus(scene::basic_node *node, double duration) if (!node) return; + glm::dvec3 const center = node->location(); + + // distance that frames the object's bounding sphere within the vertical FOV, with some margin + double const radius = std::max(1.0, static_cast(node->radius())); + double const fovy = glm::radians(static_cast(Global.FieldOfView) / std::max(0.01, static_cast(Global.ZoomFactor))); + double distance = (radius / std::tan(fovy * 0.5)) * 1.6; + distance = std::clamp(distance, radius * 1.5, static_cast(kMaxPlacementDistance)); + + // keep the camera on the side it currently views from, so the move turns toward the object + // rather than flying around it; fall back to a pleasant 3/4 direction when sitting on top of it + glm::dvec3 dir = Camera.Pos - center; + double const len = glm::length(dir); + dir = (len > 1e-3) ? dir / len : glm::normalize(glm::dvec3(1.0, 0.5, 1.0)); + + m_focus_start_pos = Camera.Pos; + m_focus_start_angle = Camera.Angle; + m_focus_target_pos = center + dir * distance; + + // target orientation looks from the target position straight at the object + glm::dvec3 look = center - m_focus_target_pos; + double const looklen = glm::length(look); + if (looklen > 1e-6) + look /= looklen; + m_focus_target_angle = glm::vec3( + static_cast(std::asin(glm::clamp(look.y, -1.0, 1.0))), // pitch + static_cast(std::atan2(-look.x, -look.z)), // yaw + 0.0f); // roll + m_focus_active = true; m_focus_time = 0.0; m_focus_duration = duration; +} - m_focus_start_pos = Camera.Pos; - m_focus_start_angle = Camera.LookAt; +void editor_mode::snap_to_ground(scene::basic_node *node) +{ + if (!node || !simulation::Region) + return; - m_focus_target_pos = node->location(); + glm::dvec3 const origin = node->location(); + if (!simulation::Region->point_inside(origin)) + return; - glm::dvec3 dir = m_focus_target_pos - m_focus_start_pos; - double dist = glm::length(dir); - m_focus_target_angle = m_focus_target_pos + glm::dvec3(10.0, 3.0, 10.0); + // small tolerance so a node already resting on a surface still snaps cleanly to it + double const epsilon = 0.05; + double bestY = -std::numeric_limits::max(); + bool found = false; + + // record the highest surface that is at or below the node's current position at its (x,z) + auto consider_triangle = [&](glm::dvec3 const &a, glm::dvec3 const &b, glm::dvec3 const &c) { + double y; + if (triangle_height_at(a, b, c, origin.x, origin.z, y) && y <= origin.y + epsilon && y > bestY) + { + bestY = y; + found = true; + } + }; + + auto consider_shapes = [&](std::vector const &shapes) { + for (auto const &shape : shapes) + { + // quick reject: skip shapes whose bounding circle doesn't cover our (x,z) column + auto const &sdata = shape.data(); + double const sdx = origin.x - sdata.area.center.x; + double const sdz = origin.z - sdata.area.center.z; + if (sdx * sdx + sdz * sdz > static_cast(sdata.area.radius) * sdata.area.radius) + continue; + + auto const &verts = sdata.vertices; + for (std::size_t i = 0; i + 2 < verts.size(); i += 3) + consider_triangle(verts[i].position, verts[i + 1].position, verts[i + 2].position); + } + }; + + scene::basic_section &sec = simulation::Region->section(origin); + // section level holds the large opaque geometry, including legacy terrain + consider_shapes(sec.m_shapes); + + // scan a 3x3 neighbourhood of cells for smaller geometry and other model instances below us + for (int dz = -1; dz <= 1; ++dz) + for (int dx = -1; dx <= 1; ++dx) + { + scene::basic_cell &cell = sec.cell(origin, glm::ivec2(dx, dz)); + consider_shapes(cell.m_shapesopaque); + consider_shapes(cell.m_shapestranslucent); + + // other instances are approximated by their bounding sphere, so a node can rest on top of them + for (auto *inst : cell.m_instancesopaque) + { + if (!inst || inst == node) + continue; + glm::dvec3 const ic = inst->location(); + double const r = static_cast(inst->radius()); + double const idx = origin.x - ic.x, idz = origin.z - ic.z; + double const horiz2 = idx * idx + idz * idz; + if (horiz2 < r * r) + { + double const ytop = ic.y + std::sqrt(r * r - horiz2); + if (ytop <= origin.y + epsilon && ytop > bestY) + { + bestY = ytop; + found = true; + } + } + } + } + + // editable terrain patches keep their heightmap on the CPU, so query them directly + for (auto const &terrain : m_terrains) + { + if (!terrain || !terrain->contains(origin.x, origin.z)) + continue; + double const y = terrain->height_at(origin.x, origin.z); + if (y <= origin.y + epsilon && y > bestY) + { + bestY = y; + found = true; + } + } + + if (!found) + return; + + push_snapshot(node, EditorSnapshot::Action::Move); + glm::dvec3 target = origin; + target.y = bestY; + m_editor.translate(node, target, true); // true == apply the computed Y (free vertical move) } void editor_mode::handle_brush_mouse_hold(int Action, int Button) @@ -439,6 +616,16 @@ bool editor_mode::update() auto const deltarealtime = Timer::GetDeltaRenderTime(); + // reconcile camera fly-mode with the real right-button state. ImGui is always fed the button + // events (even when it captures the mouse), so io.MouseDown[1] is authoritative; if a release + // was swallowed by an ImGui window while flying, force the editor out of fly-mode here so the + // camera doesn't get stuck spinning with a hidden cursor. + if (!ImGui::GetIO().MouseDown[1] && m_input.mouse.button(GLFW_MOUSE_BUTTON_RIGHT) == GLFW_PRESS) + { + m_input.mouse.button(GLFW_MOUSE_BUTTON_RIGHT, GLFW_RELEASE); + Application.set_cursor(GLFW_CURSOR_NORMAL); + } + // fixed step render time routines (50 Hz) fTime50Hz += deltarealtime; // accumulate even when paused to keep frame reads stable while (fTime50Hz >= 1.0 / 50.0) @@ -481,6 +668,10 @@ bool editor_mode::update() simulation::is_ready = true; + // continuous terrain sculpting while the left mouse button is held in sculpt mode + if (m_terrain_sculpt && mouseHold) + handle_terrain_sculpt(deltarealtime); + // --- ImGuizmo: in-viewport transform gizmo for the selected node --- render_gizmo(); @@ -514,12 +705,257 @@ void editor_mode::render_settings() ImGui::Separator(); ImGui::Checkbox("Transform gizmo (ImGuizmo)", &m_gizmo_enabled); + render_terrain_ui(); + ImGui::End(); } +void editor_mode::render_terrain_ui() +{ + ImGui::Separator(); + ImGui::TextUnformatted("Terrain"); + + ImGui::SetNextItemWidth(120.0f); + ImGui::InputInt("Grid cells", &m_terrain_cells); + m_terrain_cells = std::clamp(m_terrain_cells, 1, 512); + ImGui::SetNextItemWidth(120.0f); + ImGui::InputFloat("Cell size (m)", &m_terrain_cellsize); + if (m_terrain_cellsize < 0.1f) + m_terrain_cellsize = 0.1f; + ImGui::SetNextItemWidth(120.0f); + ImGui::InputFloat("Base height (m)", &m_terrain_baseheight); + ImGui::SetNextItemWidth(200.0f); + ImGui::InputText("Texture (optional)", m_terrain_texture, IM_ARRAYSIZE(m_terrain_texture)); + + if (ImGui::Button("Create flat terrain")) + { + // centre the new patch horizontally on the camera, flat at the requested base height + glm::dvec3 const center(Camera.Pos.x, static_cast(m_terrain_baseheight), Camera.Pos.z); + auto terrain = std::make_unique(); + if (terrain->create(center, m_terrain_cells, m_terrain_cellsize, std::string(m_terrain_texture))) + m_terrains.push_back(std::move(terrain)); + else + WriteLog("Editor: failed to create terrain", logtype::generic); + } + + ImGui::SetNextItemWidth(120.0f); + ImGui::InputInt("Chunks / side", &m_terrain_chunks); + m_terrain_chunks = std::clamp(m_terrain_chunks, 1, 32); + ImGui::SameLine(); + if (ImGui::Button("Create chunked terrain")) + create_chunked_terrain(); + ImGui::TextDisabled("total %d x %d m, %d chunks", + static_cast(m_terrain_chunks * m_terrain_cells * m_terrain_cellsize), + static_cast(m_terrain_chunks * m_terrain_cells * m_terrain_cellsize), + m_terrain_chunks * m_terrain_chunks); + + ImGui::Text("Patches: %zu", m_terrains.size()); + + // capture: sample the selected model's geometry into an editable patch and remove the original + if (dynamic_cast(m_node) != nullptr) + { + if (ImGui::Button("Capture selected model as terrain")) + capture_terrain(); + } + else + { + ImGui::TextDisabled("Capture: select a model instance first"); + } + + if (!m_terrains.empty()) + { + ImGui::Separator(); + ImGui::Checkbox("Sculpt mode (LMB raise / Shift = lower)", &m_terrain_sculpt); + ImGui::SetNextItemWidth(120.0f); + ImGui::InputFloat("Brush radius", &m_terrain_brush_radius); + if (m_terrain_brush_radius < 0.5f) + m_terrain_brush_radius = 0.5f; + ImGui::SetNextItemWidth(120.0f); + ImGui::InputFloat("Brush strength", &m_terrain_brush_strength); + + // one-shot nudge of the most recent patch at its centre, handy for a quick test + auto &terrain = m_terrains.back(); + glm::dvec3 const c = terrain->centre(); + if (ImGui::Button("Raise centre")) + terrain->sculpt(c.x, c.z, m_terrain_brush_radius, m_terrain_brush_strength); + ImGui::SameLine(); + if (ImGui::Button("Lower centre")) + terrain->sculpt(c.x, c.z, m_terrain_brush_radius, -m_terrain_brush_strength); + + ImGui::Separator(); + ImGui::TextUnformatted("Optimize (mesh simplification, all patches)"); + ImGui::SetNextItemWidth(120.0f); + ImGui::InputFloat("Flatness tol (m)", &m_terrain_simplify_error); + if (m_terrain_simplify_error < 0.01f) + m_terrain_simplify_error = 0.01f; + if (ImGui::Button("Optimize all")) + for (auto &t : m_terrains) + if (t) + t->optimize(m_terrain_simplify_error); + ImGui::SameLine(); + if (ImGui::Button("Full-res all")) + for (auto &t : m_terrains) + if (t) + t->unoptimize(); + + std::size_t tris = 0, full = 0; + for (auto &t : m_terrains) + if (t) + { + tris += t->triangles(); + full += t->full_triangles(); + } + ImGui::Text("Triangles: %zu / %zu", tris, full); + } +} + +editor_terrain *editor_mode::terrain_at(double X, double Z) +{ + for (auto &terrain : m_terrains) + if (terrain && terrain->contains(X, Z)) + return terrain.get(); + return nullptr; +} + +void editor_mode::create_chunked_terrain() +{ + int const chunks = std::clamp(m_terrain_chunks, 1, 32); + int const cells = std::clamp(m_terrain_cells, 1, 256); + float const cellsize = std::max(0.1f, m_terrain_cellsize); + + double const chunkextent = static_cast(cells) * cellsize; + double const half = 0.5 * chunks * chunkextent; + double const x0 = Camera.Pos.x - half; // corner of the whole field + double const z0 = Camera.Pos.z - half; + + int created = 0; + for (int cz = 0; cz < chunks; ++cz) + for (int cx = 0; cx < chunks; ++cx) + { + // adjacent chunks share edges exactly (aligned grids, identical world coords), + // so a world-space brush keeps the seams consistent + glm::dvec3 const center( + x0 + (cx + 0.5) * chunkextent, + static_cast(m_terrain_baseheight), + z0 + (cz + 0.5) * chunkextent); + auto terrain = std::make_unique(); + if (terrain->create(center, cells, cellsize, std::string(m_terrain_texture))) + { + m_terrains.push_back(std::move(terrain)); + ++created; + } + } + + WriteLog("Editor: created chunked terrain with " + std::to_string(created) + " chunks", logtype::generic); +} + +void editor_mode::handle_terrain_sculpt(double Deltatime) +{ + // world point under the cursor (Mouse_Position is camera-relative, like the brush placement uses) + glm::dvec3 const world = Camera.Pos + GfxRenderer->Mouse_Position(); + // only sculpt when the cursor is actually over terrain (avoids editing on a stale depth read) + if (terrain_at(world.x, world.z) == nullptr) + return; + + double const rate = m_terrain_brush_strength * Deltatime; // metres applied this frame + double const signedrate = (Global.shiftState ? -rate : rate); + // apply to every chunk the brush touches; each patch clips to its own bounds, so a stroke + // crossing a chunk boundary edits both and shared-edge vertices stay in sync + for (auto &terrain : m_terrains) + if (terrain) + terrain->sculpt(world.x, world.z, m_terrain_brush_radius, signedrate); +} + +void editor_mode::capture_terrain() +{ + TAnimModel *model = dynamic_cast(m_node); + if (model == nullptr || model->pModel == nullptr) + { + WriteLog("Editor: select a model instance to capture as terrain", logtype::generic); + return; + } + + // instance world transform, matching the renderer: translate * rotateY * rotateX * rotateZ * scale + glm::dmat4 rootm(1.0); + rootm = glm::translate(rootm, model->location()); + glm::vec3 const angles = model->Angles(); + if (angles.y != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.y)), glm::dvec3(0.0, 1.0, 0.0)); + if (angles.x != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.x)), glm::dvec3(1.0, 0.0, 0.0)); + if (angles.z != 0.0f) rootm = glm::rotate(rootm, glm::radians(static_cast(angles.z)), glm::dvec3(0.0, 0.0, 1.0)); + glm::vec3 const scale = model->Scale(); + rootm = glm::scale(rootm, glm::dvec3(scale)); + + std::vector tris; + gather_submodel_triangles(model->pModel->Root, rootm, tris); + if (tris.empty()) + { + WriteLog("Editor: selected model has no readable geometry to capture", logtype::generic); + return; + } + + // horizontal bounds of the captured geometry + glm::dvec3 lo(std::numeric_limits::max()); + glm::dvec3 hi(-std::numeric_limits::max()); + for (auto const &t : tris) + for (auto const &p : t) + { + lo.x = std::min(lo.x, p.x); lo.y = std::min(lo.y, p.y); lo.z = std::min(lo.z, p.z); + hi.x = std::max(hi.x, p.x); hi.y = std::max(hi.y, p.y); hi.z = std::max(hi.z, p.z); + } + + glm::dvec3 const center((lo.x + hi.x) * 0.5, lo.y, (lo.z + hi.z) * 0.5); + double const extent = std::max(hi.x - lo.x, hi.z - lo.z); + int const cells = std::max(1, m_terrain_cells); + float const cellsize = static_cast(std::max(0.1, extent / cells)); + + // sampler: highest captured triangle at (x,z) + auto const sampler = [&tris](double X, double Z, double &OutY) -> bool { + double best = -std::numeric_limits::max(); + bool found = false; + for (auto const &t : tris) + { + double const minx = std::min({t[0].x, t[1].x, t[2].x}); + double const maxx = std::max({t[0].x, t[1].x, t[2].x}); + double const minz = std::min({t[0].z, t[1].z, t[2].z}); + double const maxz = std::max({t[0].z, t[1].z, t[2].z}); + if (X < minx || X > maxx || Z < minz || Z > maxz) + continue; + double y; + if (triangle_height_at(t[0], t[1], t[2], X, Z, y) && (!found || y > best)) + { + best = y; + found = true; + } + } + if (found) + OutY = best; + return found; + }; + + auto terrain = std::make_unique(); + if (!terrain->create(center, cells, cellsize, std::string(m_terrain_texture), sampler)) + { + WriteLog("Editor: terrain capture failed", logtype::generic); + return; + } + m_terrains.push_back(std::move(terrain)); + + // remove the original instance (recorded as a deletion so it can be undone) + std::string as_text; + model->export_as_text(as_text); + push_snapshot(model, EditorSnapshot::Action::Delete, as_text); + nullify_history_pointers(model); + remove_from_hierarchy(model); + m_node = nullptr; + m_dragging = false; + ui()->set_node(nullptr); + simulation::State.delete_model(model); +} + void editor_mode::render_gizmo() { - if (!m_gizmo_enabled) + // the transform gizmo is suppressed while sculpting terrain, so the brush owns the mouse + if (!m_gizmo_enabled || m_terrain_sculpt) { m_gizmo_using = false; return; @@ -651,8 +1087,10 @@ void editor_mode::render_gizmo() void editor_mode::update_camera(double const Deltatime) { - // account for keyboard-driven motion - // if focus animation active, interpolate camera toward target + Camera.Update(); + + // focus animation runs after Camera.Update() so it overrides any residual velocity/rotation; + // it smoothly drives both position and orientation toward the framed object if (m_focus_active) { m_focus_time += Deltatime; @@ -660,15 +1098,24 @@ void editor_mode::update_camera(double const Deltatime) if (t >= 1.0) t = 1.0; // smoothstep easing - double s = t * t * (3.0 - 2.0 * t); - Camera.Pos = glm::mix(m_focus_start_pos, m_focus_target_pos, s); - Camera.LookAt = glm::mix(m_focus_start_angle, m_focus_target_angle, s); + float const s = static_cast(t * t * (3.0 - 2.0 * t)); + + Camera.Pos = glm::mix(m_focus_start_pos, m_focus_target_pos, static_cast(s)); + + // interpolate angles, taking the shortest path around the yaw wrap-around + constexpr float TWO_PI = 6.283185307179586f; + float const dyaw = std::remainder(m_focus_target_angle.y - m_focus_start_angle.y, TWO_PI); + Camera.Angle.x = m_focus_start_angle.x + (m_focus_target_angle.x - m_focus_start_angle.x) * s; + Camera.Angle.y = m_focus_start_angle.y + dyaw * s; + Camera.Angle.z = m_focus_start_angle.z + (m_focus_target_angle.z - m_focus_start_angle.z) * s; + + // suppress any residual fly velocity so it doesn't fight the animation + Camera.Velocity = glm::dvec3(0.0); + if (t >= 1.0) m_focus_active = false; } - Camera.Update(); - // reset window state (will be set again if UI requires it) Global.CabWindowOpen = false; @@ -838,6 +1285,16 @@ void editor_mode::on_key(int const Key, int const Scancode, int const Action, in } break; + case GLFW_KEY_END: + if (is_press(Action) && m_node) + { + // Unreal-style "snap to floor": drop the selected node onto the surface below it. + // works against triangle geometry (shape_node terrain / opaque shapes); once a proper + // editable terrain mesh exists, dropping onto it works without further changes here. + snap_to_ground(m_node); + } + break; + default: break; } @@ -859,6 +1316,14 @@ void editor_mode::on_mouse_button(int const Button, int const Action, int const return; } + // in terrain sculpt mode the left button paints the terrain instead of picking nodes + if (m_terrain_sculpt && Button == GLFW_MOUSE_BUTTON_LEFT) + { + mouseHold = is_press(Action); + m_input.mouse.button(Button, Action); + return; + } + if (Button == GLFW_MOUSE_BUTTON_LEFT) { diff --git a/application/editormode.h b/application/editormode.h index 30fc771e..a586e430 100644 --- a/application/editormode.h +++ b/application/editormode.h @@ -15,6 +15,9 @@ http://mozilla.org/MPL/2.0/. #include "vehicle/Camera.h" #include "scene/sceneeditor.h" #include "scene/scenenode.h" +#include "editor/editorTerrain.hpp" + +#include class editor_mode : public application_mode { @@ -136,6 +139,30 @@ class editor_mode : public application_mode // focus camera smoothly on specified node void start_focus(scene::basic_node *node, double duration = 0.6); + // drops the node straight down onto the nearest surface below (terrain or another object) + void snap_to_ground(scene::basic_node *node); + + // editable terrain patches created in the editor + void render_terrain_ui(); + // creates a large terrain as a grid of adjacent chunks (each its own editable patch) + void create_chunked_terrain(); + // raises/lowers terrain under the cursor while the left mouse button is held in sculpt mode + void handle_terrain_sculpt(double Deltatime); + // returns the terrain patch (if any) whose footprint covers the given world point + editor_terrain *terrain_at(double X, double Z); + // samples the selected model instance's geometry into a new editable terrain patch, then removes it + void capture_terrain(); + std::vector> m_terrains; + bool m_terrain_sculpt{false}; // when true, LMB sculpts terrain instead of picking + int m_terrain_cells{32}; // grid resolution (quads per side) + int m_terrain_chunks{4}; // chunks per side for a chunked terrain + float m_terrain_cellsize{2.0f}; // metres per quad + float m_terrain_baseheight{0.0f}; // flat starting height + float m_terrain_brush_radius{12.0f}; + float m_terrain_brush_strength{4.0f}; // metres per second while held (one-shot for the buttons) + float m_terrain_simplify_error{0.5f}; // flatness tolerance (m) for mesh simplification + char m_terrain_texture[128]{""}; // optional ground texture name + // hierarchy management void add_to_hierarchy(scene::basic_node *node); void remove_from_hierarchy(scene::basic_node *node); diff --git a/editor/editorTerrain.cpp b/editor/editorTerrain.cpp new file mode 100644 index 00000000..fd9f50ee --- /dev/null +++ b/editor/editorTerrain.cpp @@ -0,0 +1,315 @@ +/* +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 "editor/editorTerrain.hpp" + +#include "scene/scene.h" +#include "scene/scenenode.h" +#include "simulation/simulation.h" +#include "rendering/renderer.h" +#include "model/vertex.h" + +#include +#include +#include + +namespace +{ + constexpr double kPi = 3.14159265358979323846; +} + +bool editor_terrain::create(glm::dvec3 const &Center, int Cells, float CellSize, std::string const &TextureName, + height_sampler const &Sampler) +{ + if (Cells < 1 || CellSize <= 0.0f || simulation::Region == nullptr) + return false; + + m_cells = Cells; + m_cellsize = CellSize; + double const half = 0.5 * static_cast(Cells) * CellSize; + m_x0 = Center.x - half; + m_z0 = Center.z - half; + m_heights.assign(static_cast(Cells + 1) * (Cells + 1), static_cast(Center.y)); + + // optionally seed the grid by sampling whatever geometry is already there (terrain capture) + if (Sampler) + { + for (int iz = 0; iz <= Cells; ++iz) + for (int ix = 0; ix <= Cells; ++ix) + { + double const vx = m_x0 + static_cast(ix) * CellSize; + double const vz = m_z0 + static_cast(iz) * CellSize; + double y; + if (Sampler(vx, vz, y)) + m_heights[index(ix, iz)] = static_cast(y); + } + } + + m_material = TextureName.empty() ? null_handle : GfxRenderer->Fetch_Material(TextureName); + + // section-level shapes are rendered relative to the section centre, so that is our geometry origin + scene::basic_section &sec = simulation::Region->section(Center); + sec.create_geometry(); // ensure existing section geometry is already built (idempotent) + m_origin = sec.m_area.center; + m_section = &sec; + + std::vector verts; + build_vertices(verts, false); + m_vertexcount = verts.size(); + + scene::shape_node shape; + shape.make_terrain(m_material, std::move(verts), m_origin); + + // upload to a dedicated bank; the renderer resolves draw calls by handle regardless of bank + m_bank = GfxRenderer->Create_Bank(); + shape.create_geometry(m_bank); // sets the shape's geometry handle, clears its CPU vertices + m_geometry = shape.data().geometry; + + glm::dvec3 const shapecenter = shape.data().area.center; + float const shaperadius = shape.radius(); // cached inside make_terrain, vertices already gone + + sec.m_shapes.emplace_back(std::move(shape)); + // extend the section bounds so the new terrain isn't frustum-culled at its edges + sec.m_area.radius = std::max( + sec.m_area.radius, + static_cast(glm::length(sec.m_area.center - shapecenter) + shaperadius)); + + return true; +} + +glm::dvec3 editor_terrain::vertex_position(int Ix, int Iz) const +{ + return glm::dvec3( + m_x0 + static_cast(Ix) * m_cellsize, + static_cast(m_heights[index(Ix, Iz)]), + m_z0 + static_cast(Iz) * m_cellsize); +} + +glm::vec3 editor_terrain::vertex_normal(int Ix, int Iz) const +{ + // central differences on the heightfield; clamp to edges + int const xl = std::max(0, Ix - 1), xr = std::min(m_cells, Ix + 1); + int const zl = std::max(0, Iz - 1), zr = std::min(m_cells, Iz + 1); + float const hl = m_heights[index(xl, Iz)], hr = m_heights[index(xr, Iz)]; + float const hd = m_heights[index(Ix, zl)], hu = m_heights[index(Ix, zr)]; + float const dx = static_cast((xr - xl)) * m_cellsize; + float const dz = static_cast((zr - zl)) * m_cellsize; + glm::vec3 n(-(hr - hl) / (dx > 0.f ? dx : 1.f), 1.0f, -(hu - hd) / (dz > 0.f ? dz : 1.f)); + return glm::normalize(n); +} + +world_vertex editor_terrain::make_vertex(int Ix, int Iz) const +{ + world_vertex v; + v.position = vertex_position(Ix, Iz); + v.normal = vertex_normal(Ix, Iz); + v.texture = glm::vec2(static_cast(Ix), static_cast(Iz)); + return v; +} + +// emits one quad (two upward-facing triangles) spanning grid corners (X0,Z0)..(X1,Z1) +void editor_terrain::emit_quad(int X0, int Z0, int X1, int Z1, std::vector &Out) const +{ + world_vertex const v00 = make_vertex(X0, Z0); + world_vertex const v10 = make_vertex(X1, Z0); + world_vertex const v01 = make_vertex(X0, Z1); + world_vertex const v11 = make_vertex(X1, Z1); + + Out.push_back(v00); + Out.push_back(v01); + Out.push_back(v10); + + Out.push_back(v11); + Out.push_back(v10); + Out.push_back(v01); +} + +// true if every grid vertex inside the block stays within Error of the bilinear plane of its corners +bool editor_terrain::block_flat(int X0, int Z0, int X1, int Z1, float Error) const +{ + float const h00 = m_heights[index(X0, Z0)]; + float const h10 = m_heights[index(X1, Z0)]; + float const h01 = m_heights[index(X0, Z1)]; + float const h11 = m_heights[index(X1, Z1)]; + double const wx = X1 - X0, wz = Z1 - Z0; + + for (int iz = Z0; iz <= Z1; ++iz) + for (int ix = X0; ix <= X1; ++ix) + { + double const tx = (wx > 0.0) ? (ix - X0) / wx : 0.0; + double const tz = (wz > 0.0) ? (iz - Z0) / wz : 0.0; + double const top = h00 + tx * (h10 - h00); + double const bot = h01 + tx * (h11 - h01); + double const interp = top + tz * (bot - top); + if (std::abs(static_cast(m_heights[index(ix, iz)]) - interp) > Error) + return false; + } + return true; +} + +// adaptive quadtree: collapse flat blocks into a single quad, otherwise split into four +void editor_terrain::emit_block(int X0, int Z0, int X1, int Z1, float Error, std::vector &Out) const +{ + bool const splitx = (X1 - X0) > 1; + bool const splitz = (Z1 - Z0) > 1; + + if ((!splitx && !splitz) || block_flat(X0, Z0, X1, Z1, Error)) + { + emit_quad(X0, Z0, X1, Z1, Out); + return; + } + + int const xm = splitx ? (X0 + X1) / 2 : X1; + int const zm = splitz ? (Z0 + Z1) / 2 : Z1; + + emit_block(X0, Z0, xm, zm, Error, Out); + if (splitx) + emit_block(xm, Z0, X1, zm, Error, Out); + if (splitz) + emit_block(X0, zm, xm, Z1, Error, Out); + if (splitx && splitz) + emit_block(xm, zm, X1, Z1, Error, Out); +} + +void editor_terrain::build_vertices(std::vector &Out, bool Simplify) const +{ + Out.clear(); + Out.reserve(static_cast(m_cells) * m_cells * 6); + + if (Simplify) + { + emit_block(0, 0, m_cells, m_cells, m_simplify_error, Out); + return; + } + + for (int iz = 0; iz < m_cells; ++iz) + for (int ix = 0; ix < m_cells; ++ix) + emit_quad(ix, iz, ix + 1, iz + 1, Out); +} + +void editor_terrain::regenerate(bool Simplify) +{ + if (!valid()) + return; + + std::vector verts; + build_vertices(verts, Simplify); + + gfx::vertex_array gpuverts; + gpuverts.reserve(verts.size()); + for (auto const &v : verts) + gpuverts.emplace_back(gfx::basic_vertex::convert(v, m_origin)); + gfx::userdata_array nouserdata; + + // fast path: same vertex count -> in-place swap into the existing chunk + if (gpuverts.size() == m_vertexcount && (m_geometry.bank != 0 || m_geometry.chunk != 0)) + { + GfxRenderer->Replace(gpuverts, nouserdata, m_geometry, GL_TRIANGLES); + return; + } + + // count changed (optimize / un-optimize): upload a fresh chunk and point the shape at it + gfx::geometry_handle const newhandle = GfxRenderer->Insert(gpuverts, nouserdata, m_bank, GL_TRIANGLES); + if (m_section != nullptr) + { + for (auto &shape : m_section->m_shapes) + { + auto const h = shape.data().geometry; + if (h.bank == m_geometry.bank && h.chunk == m_geometry.chunk) + { + shape.geometry(newhandle); + break; + } + } + } + m_geometry = newhandle; + m_vertexcount = gpuverts.size(); +} + +void editor_terrain::optimize(float ErrorMetres) +{ + m_simplify = true; + m_simplify_error = (ErrorMetres > 0.0f ? ErrorMetres : 0.01f); + regenerate(true); +} + +void editor_terrain::unoptimize() +{ + m_simplify = false; + regenerate(false); +} + +bool editor_terrain::contains(double X, double Z) const +{ + double const x1 = m_x0 + static_cast(m_cells) * m_cellsize; + double const z1 = m_z0 + static_cast(m_cells) * m_cellsize; + return (X >= m_x0 && X <= x1 && Z >= m_z0 && Z <= z1); +} + +double editor_terrain::height_at(double X, double Z) const +{ + double const fx = (X - m_x0) / m_cellsize; + double const fz = (Z - m_z0) / m_cellsize; + int ix = static_cast(std::floor(fx)); + int iz = static_cast(std::floor(fz)); + ix = std::clamp(ix, 0, m_cells - 1); + iz = std::clamp(iz, 0, m_cells - 1); + double const tx = std::clamp(fx - ix, 0.0, 1.0); + double const tz = std::clamp(fz - iz, 0.0, 1.0); + + double const h00 = m_heights[index(ix, iz)]; + double const h10 = m_heights[index(ix + 1, iz)]; + double const h01 = m_heights[index(ix, iz + 1)]; + double const h11 = m_heights[index(ix + 1, iz + 1)]; + + // matches the triangulation in build_vertices + if (tx + tz <= 1.0) + return h00 + tx * (h10 - h00) + tz * (h01 - h00); + return h11 + (1.0 - tx) * (h01 - h11) + (1.0 - tz) * (h10 - h11); +} + +bool editor_terrain::sculpt(double X, double Z, double Radius, double Strength) +{ + if (!valid() || Radius <= 0.0) + return false; + + bool changed = false; + for (int iz = 0; iz <= m_cells; ++iz) + for (int ix = 0; ix <= m_cells; ++ix) + { + double const vx = m_x0 + static_cast(ix) * m_cellsize; + double const vz = m_z0 + static_cast(iz) * m_cellsize; + double const d = std::sqrt((vx - X) * (vx - X) + (vz - Z) * (vz - Z)); + if (d > Radius) + continue; + // smooth cosine falloff: full strength at the centre, zero at the rim + double const falloff = 0.5 * (std::cos(kPi * d / Radius) + 1.0); + m_heights[index(ix, iz)] += static_cast(Strength * falloff); + changed = true; + } + + if (changed) + { + // sculpting edits the full-resolution mesh (fixed vertex count => fast in-place update); + // the user can re-run optimize() afterwards to simplify again + m_simplify = false; + regenerate(false); + } + return changed; +} + +glm::dvec3 editor_terrain::centre() const +{ + double const c = 0.5 * static_cast(m_cells) * m_cellsize; + double y = 0.0; + if (!m_heights.empty()) + y = m_heights[index(m_cells / 2, m_cells / 2)]; + return glm::dvec3(m_x0 + c, y, m_z0 + c); +} diff --git a/editor/editorTerrain.hpp b/editor/editorTerrain.hpp new file mode 100644 index 00000000..cbefb05d --- /dev/null +++ b/editor/editorTerrain.hpp @@ -0,0 +1,97 @@ +/* +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 "utilities/Classes.h" // material_handle +#include "interfaces/ITexture.h" // null_handle +#include "rendering/geometrybank.h" // gfx::geometry_handle / geometrybank_handle + +namespace scene { class basic_section; } + +// Editor-owned, editable terrain patch. +// +// The engine's scene::shape_node drops its CPU-side vertices the moment it uploads them to the GPU, +// so it can't be edited or raycast after load. This class keeps the authoritative, editable data +// (a regular grid heightmap) on the CPU, generates a shape_node purely for rendering, and answers +// height/raycast queries directly from the heightmap (fast and exact). Sculpting updates the +// heightmap and pushes the new vertex positions into the shape's existing geometry chunk. +class editor_terrain +{ + public: + editor_terrain() = default; + + // builds an NxN-cell grid centred on Center, each quad CellSize metres across, using the + // material fetched from TextureName (empty => untextured). when Sampler is supplied it provides + // the starting height at each grid vertex (returns false to fall back to Center.y) - used to + // capture existing terrain. returns false on failure. + using height_sampler = std::function; + bool create(glm::dvec3 const &Center, int Cells, float CellSize, std::string const &TextureName, + height_sampler const &Sampler = {}); + + // true if (X,Z) lies within the terrain's horizontal footprint + bool contains(double X, double Z) const; + // surface height at (X,Z) (bilinear over the covering quad); only valid when contains() is true + double height_at(double X, double Z) const; + + // raises/lowers vertices within Radius of (X,Z) by Strength (metres, signed), with a smooth + // falloff; regenerates the rendered geometry (full resolution). returns true if anything changed. + bool sculpt(double X, double Z, double Radius, double Strength); + + // rebuilds the rendered mesh, collapsing regions flatter than ErrorMetres into larger quads + // (adaptive quadtree). the editable heightmap is untouched, so sculpting/raycast stay exact. + void optimize(float ErrorMetres); + // rebuilds the rendered mesh at full resolution (undoes optimize) + void unoptimize(); + + // horizontal centre and extent, handy for the UI / camera framing + glm::dvec3 centre() const; + float extent() const { return m_cells * m_cellsize; } + bool valid() const { return m_cells > 0; } + bool optimized() const { return m_simplify; } + // rendered triangle count (drops after optimize) + std::size_t triangles() const { return m_vertexcount / 3; } + // full-resolution triangle count, for reference + std::size_t full_triangles() const { return static_cast(m_cells) * m_cells * 2; } + + private: + int index(int Ix, int Iz) const { return Iz * (m_cells + 1) + Ix; } + glm::dvec3 vertex_position(int Ix, int Iz) const; + glm::vec3 vertex_normal(int Ix, int Iz) const; + world_vertex make_vertex(int Ix, int Iz) const; + // fills Out with the GL_TRIANGLES world-space vertex list; Simplify enables adaptive merging + void build_vertices(std::vector &Out, bool Simplify) const; + // adaptive quadtree helpers (used when Simplify is on) + bool block_flat(int X0, int Z0, int X1, int Z1, float Error) const; + void emit_block(int X0, int Z0, int X1, int Z1, float Error, std::vector &Out) const; + void emit_quad(int X0, int Z0, int X1, int Z1, std::vector &Out) const; + // rebuilds and re-uploads the rendered geometry (Replace when the count is unchanged, otherwise + // a fresh chunk whose handle is swapped into the shape) + void regenerate(bool Simplify); + + int m_cells{0}; // quads per side; (m_cells+1)^2 grid vertices + float m_cellsize{1.0f}; // metres per quad + double m_x0{0.0}, m_z0{0.0}; // world position of grid corner (ix=0, iz=0) + std::vector m_heights; // per-vertex world Y, row-major (m_cells+1)^2 + + material_handle m_material{null_handle}; + gfx::geometrybank_handle m_bank{0, 0}; // geometry bank owning the rendered chunk + gfx::geometry_handle m_geometry{0, 0}; // rendered chunk + std::size_t m_vertexcount{0}; // current chunk's vertex count (for Replace vs recreate) + glm::dvec3 m_origin{0.0}; // origin the GPU vertices are stored relative to + scene::basic_section *m_section{nullptr}; // section holding the shape, for handle swaps + + bool m_simplify{false}; // whether the rendered mesh is currently simplified + float m_simplify_error{0.5f}; // flatness tolerance used by optimize() +}; diff --git a/scene/scenenode.cpp b/scene/scenenode.cpp index 784fb14f..eb369751 100644 --- a/scene/scenenode.cpp +++ b/scene/scenenode.cpp @@ -423,6 +423,32 @@ shape_node::convert( TSubModel const *Submodel ) { return *this; } +// builds an opaque, always-visible shape from a world-space GL_TRIANGLES vertex list +shape_node & +shape_node::make_terrain( material_handle const Material, std::vector Vertices, glm::dvec3 const Origin ) { + + m_data.material = Material; + m_data.translucent = false; + m_data.visible = true; + m_data.rangesquared_min = 0.0; + m_data.rangesquared_max = std::numeric_limits::max(); + m_data.origin = Origin; + m_data.vertices = std::move( Vertices ); + + // bounding area from the supplied geometry + m_data.area.center = glm::dvec3( 0.0 ); + if( false == m_data.vertices.empty() ) { + for( auto const &vertex : m_data.vertices ) { + m_data.area.center += vertex.position; + } + m_data.area.center /= static_cast( m_data.vertices.size() ); + } + invalidate_radius(); + radius(); // force recompute now, while vertices are still present + + return *this; +} + // adds content of provided node to already enclosed geometry. returns: true if merge could be performed bool shape_node::merge( shape_node &Shape ) { diff --git a/scene/scenenode.h b/scene/scenenode.h index 10be9c1b..a4714705 100644 --- a/scene/scenenode.h +++ b/scene/scenenode.h @@ -120,6 +120,10 @@ public: // imports data from provided submodel shape_node & convert( TSubModel const *Submodel ); + // builds an opaque, always-visible shape from a world-space GL_TRIANGLES vertex list, stored + // relative to Origin. used by the editor's terrain to own editable geometry it can re-upload. + shape_node & + make_terrain( material_handle const Material, std::vector Vertices, glm::dvec3 const Origin ); // adds content of provided node to already enclosed geometry. returns: true if merge could be performed bool merge( shape_node &Shape ); @@ -138,6 +142,9 @@ public: // set origin point void origin( glm::dvec3 Origin ); + // replaces the renderable geometry handle (used by the editor when it re-uploads terrain geometry) + void + geometry( gfx::geometry_handle const &Handle ); // data access shapenode_data const & data() const; @@ -163,6 +170,12 @@ void shape_node::origin( glm::dvec3 Origin ) { m_data.origin = Origin; } +// replaces the renderable geometry handle +inline +void +shape_node::geometry( gfx::geometry_handle const &Handle ) { + m_data.geometry = Handle; +} // data access inline shape_node::shapenode_data const &