From adc6328a09c5dcaf2e67bada22a353a0261f220f Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Thu, 1 Oct 2026 21:31:43 +0200 Subject: [PATCH] feat(editor): orthophoto fitted to terrain and optionally part of the scene --- application/editormode.cpp | 118 ++++++--- editor/editorOrthophoto.cpp | 514 +++++++++++++++++++++++++++++++++--- editor/editorOrthophoto.hpp | 73 ++++- editor/editorSettings.cpp | 6 + editor/editorSettings.hpp | 2 + 5 files changed, 631 insertions(+), 82 deletions(-) diff --git a/application/editormode.cpp b/application/editormode.cpp index 4da9fe98..f4e1e2d2 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -30,6 +30,7 @@ http://mozilla.org/MPL/2.0/. #include "imgui/imgui.h" +#include "imgui/imgui_internal.h" #include "imgui/ImGuizmo.h" #include "utilities/Logs.h" #include @@ -231,6 +232,8 @@ namespace void gather_shape_triangles(scene::shape_node const &Shape, glm::dvec2 const &Min, glm::dvec2 const &Max, std::vector &Out) { auto const &data = Shape.data(); + if (editor_orthophoto::owns(data.geometry)) + return; // the orthophoto laid over the ground isn't ground itself if (data.area.radius >= 0.0f) { double const r = data.area.radius; @@ -268,6 +271,41 @@ namespace } } + // ground triangles overlapping an XZ rectangle (Min/Max are x,z). shapes hold the (legacy) terrain, + // model instances at least ModelRadius large (if ModelsAsGround) the terrain tiles + void gather_ground_triangles(glm::dvec2 const &Min, glm::dvec2 const &Max, bool const ModelsAsGround, float const ModelRadius, std::vector &Out) + { + glm::dvec2 const center = (Min + Max) * 0.5; + float const radius = static_cast(glm::length(Max - Min) * 0.5); + auto const sections = simulation::Region->sections(glm::dvec3(center.x, 0.0, center.y), radius); // copy, the result is a scratchpad + for (auto *section : sections) + { + for (auto const &shape : section->m_shapes) + gather_shape_triangles(shape, Min, Max, Out); + for (auto &cell : section->m_cells) + { + double const r = cell.m_area.radius; + if (cell.m_area.center.x + r < Min.x || cell.m_area.center.x - r > Max.x || cell.m_area.center.z + r < Min.y || cell.m_area.center.z - r > Max.y) + continue; + for (auto const &shape : cell.m_shapesopaque) + gather_shape_triangles(shape, Min, Max, Out); + if (false == ModelsAsGround) + continue; + for (auto *instance : cell.m_instancesopaque) + { + if (instance == nullptr || instance->Model() == nullptr || instance->radius() < ModelRadius) + continue; + std::vector modeltriangles; + gather_submodel_triangles(instance->Model()->Root, instance_matrix(*instance), modeltriangles); + for (auto const &t : modeltriangles) + if (std::max({t[0].x, t[1].x, t[2].x}) >= Min.x && std::min({t[0].x, t[1].x, t[2].x}) <= Max.x && std::max({t[0].z, t[1].z, t[2].z}) >= Min.y && + std::min({t[0].z, t[1].z, t[2].z}) <= Max.y) + Out.push_back(t); + } + } + } + } + // scenery file name without path and extension std::string scenery_stem() { @@ -298,6 +336,10 @@ editor_mode::editor_mode() { // the area fill settings live in the node bank window, in the tab of the fill mode ui()->set_fill_options([this]() { render_area_fill(); }); ui()->set_gizmo_options([this]() { render_gizmo_options(); }); + // the orthophoto is fitted onto the same ground as the area fill uses, terrain tile models included + m_orthophoto.ground_source([](glm::dvec2 const &Min, glm::dvec2 const &Max, std::vector &Out) { + gather_ground_triangles(Min, Max, true, 50.0f, Out); + }); } editor_ui *editor_mode::ui() const @@ -1131,6 +1173,8 @@ void editor_mode::load_orthophoto_settings() config.opacity = stored.opacity; config.year = stored.year; config.hires = stored.hires; + config.drape = stored.drape; + config.in_scene = stored.in_scene; config.north = 0.0; config.east = 0.0; EditorSettings.orthophoto_origin(scenery, config.north, config.east); @@ -1147,6 +1191,8 @@ void editor_mode::save_orthophoto_settings() stored.opacity = config.opacity; stored.year = config.year; stored.hires = config.hires; + stored.drape = config.drape; + stored.in_scene = config.in_scene; if (config.north != 0.0 || config.east != 0.0) EditorSettings.orthophoto_origin(m_orthophoto_scenery, config.north, config.east); EditorSettings.save(); @@ -1193,13 +1239,52 @@ void editor_mode::render_orthophoto_ui() int const span = static_cast((2 * config.radius + 1) * editor_orthophoto::tile_size); ImGui::TextDisabled("%d x %d m", span, span); + // ImGui 1.73 has no public disabled state yet, the internal item flag does the job + auto const begin_disabled = [](bool Disabled) { + if (!Disabled) + return; + ImGui::PushItemFlag(ImGuiItemFlags_Disabled, true); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * 0.5f); + }; + auto const end_disabled = [](bool Disabled) { + if (!Disabled) + return; + ImGui::PopStyleVar(); + ImGui::PopItemFlag(); + }; + + if (ImGui::Checkbox("Fit to terrain", &config.drape)) + changed = persist = true; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Lays the imagery onto the ground geometry (terrain shapes, terrain tile models, editor terrain).\n" + "Places without any ground use the height below."); + if (config.drape) + { + ImGui::SameLine(); + if (ImGui::Button("Refit")) + m_orthophoto.refit(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Samples the ground again, after it was edited"); + } + + begin_disabled(config.drape); ImGui::SetNextItemWidth(160.0f); changed |= ImGui::DragFloat("Height (m)", &config.height, 0.1f, -1000.0f, 3000.0f, "%.2f"); persist |= ImGui::IsItemDeactivatedAfterEdit(); + end_disabled(config.drape); + if (ImGui::Checkbox("Covered by objects and terrain", &config.in_scene)) + changed = persist = true; + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Draws the imagery as part of the scene, so tracks, models and terrain in front of it hide it.\n" + "It is opaque then and lit like the scene; mouse placement lands on it as on any surface.\n" + "Not available with the Better Renderer, which doesn't pick up geometry added in the editor."); + + begin_disabled(config.in_scene); ImGui::SetNextItemWidth(160.0f); changed |= ImGui::SliderFloat("Opacity", &config.opacity, 0.0f, 1.0f, "%.2f"); persist |= ImGui::IsItemDeactivatedAfterEdit(); + end_disabled(config.in_scene); // newest imagery, or the newest imagery taken up to the end of the selected year int const thisyear = static_cast(std::chrono::year_month_day{std::chrono::floor(std::chrono::system_clock::now())}.year()); @@ -1636,37 +1721,9 @@ void editor_mode::run_area_fill() points.push_back(p); } - // ground geometry within the area. shapes hold the (legacy) terrain, large instances the terrain tiles + // ground geometry within the area std::vector triangles; - glm::dvec2 const center = (bmin + bmax) * 0.5; - float const radius = static_cast(glm::length(bmax - bmin) * 0.5); - auto const sections = simulation::Region->sections(glm::dvec3(center.x, 0.0, center.y), radius); // copy, the result is a scratchpad - for (auto *section : sections) - { - for (auto const &shape : section->m_shapes) - gather_shape_triangles(shape, bmin, bmax, triangles); - for (auto &cell : section->m_cells) - { - double const r = cell.m_area.radius; - if (cell.m_area.center.x + r < bmin.x || cell.m_area.center.x - r > bmax.x || cell.m_area.center.z + r < bmin.y || cell.m_area.center.z - r > bmax.y) - continue; - for (auto const &shape : cell.m_shapesopaque) - gather_shape_triangles(shape, bmin, bmax, triangles); - if (false == m_fill_models_as_ground) - continue; - for (auto *instance : cell.m_instancesopaque) - { - if (instance == nullptr || instance->Model() == nullptr || instance->radius() < terrain_model_radius) - continue; - std::vector modeltriangles; - gather_submodel_triangles(instance->Model()->Root, instance_matrix(*instance), modeltriangles); - for (auto const &t : modeltriangles) - if (std::max({t[0].x, t[1].x, t[2].x}) >= bmin.x && std::min({t[0].x, t[1].x, t[2].x}) <= bmax.x && std::max({t[0].z, t[1].z, t[2].z}) >= bmin.y && - std::min({t[0].z, t[1].z, t[2].z}) <= bmax.y) - triangles.push_back(t); - } - } - } + gather_ground_triangles(bmin, bmax, m_fill_models_as_ground, terrain_model_radius, triangles); triangle_grid const ground(std::move(triangles), bmin, bmax); auto const terrains = active_terrains(); @@ -2055,6 +2112,7 @@ void editor_mode::exit() m_history.clear(); m_fill_last.clear(); m_orthophoto.cancel_pending(); + m_orthophoto.detach_scene(); // the layer is an editor aid, keep it out of the other modes // drop selection so a stale/dangling node pointer isn't used on the next editor session m_node = nullptr; diff --git a/editor/editorOrthophoto.cpp b/editor/editorOrthophoto.cpp index 74137b62..3d478eb9 100644 --- a/editor/editorOrthophoto.cpp +++ b/editor/editorOrthophoto.cpp @@ -11,6 +11,9 @@ http://mozilla.org/MPL/2.0/. #include "editor/editorOrthophoto.hpp" #include "rendering/renderer.h" +#include "scene/scene.h" +#include "simulation/simulation.h" +#include "model/vertex.h" #include "utilities/Logs.h" #include "utilities/utilities.h" #include "imgui/imgui.h" @@ -30,7 +33,9 @@ http://mozilla.org/MPL/2.0/. #include #include #include +#include #include +#include namespace { @@ -544,12 +549,88 @@ template int clip_polygon(clip_vertex const *In, int Count, } return count; } + +// --------------------------------------------------------------------------------------------- +// ground fit and in-scene geometry + +constexpr int ground_grid{32}; // ground samples per tile side (8 m) for the draped overlay +constexpr double surface_offset{0.05}; // lift of the in-scene imagery above the ground it covers, against z-fighting +constexpr double steepest_ground{0.3}; // minimal normal.y of ground triangles receiving the imagery +constexpr float no_height{-std::numeric_limits::max()}; + +gfx::geometrybank_handle scene_bank{0, 0}; // created on first use, shared by every tile + +std::string slot_name(int Index) +{ + // script-style generated resource: no file behind it, the content is supplied through update_from_memory() + return "internal_src:editor_orthophoto_" + std::to_string(Index); +} + +// clips a polygon in the XZ plane against Point[Axis] <= Limit (Below) or Point[Axis] >= Limit +int clip_xz(glm::dvec3 const *In, int Count, glm::dvec3 *Out, int Axis, double Limit, bool Below) +{ + auto const inside = [&](glm::dvec3 const &Point) { return Below ? Point[Axis] <= Limit : Point[Axis] >= Limit; }; + int count{0}; + for (int i = 0; i < Count; ++i) + { + glm::dvec3 const &a = In[i]; + glm::dvec3 const &b = In[(i + 1) % Count]; + bool const ina = inside(a); + if (ina) + Out[count++] = a; + if (ina != inside(b)) + Out[count++] = a + (b - a) * ((Limit - a[Axis]) / (b[Axis] - a[Axis])); + } + return count; +} + +// height of triangle abc above (X,Z), if the vertical line through it hits the triangle +bool triangle_height(glm::dvec3 const &A, glm::dvec3 const &B, glm::dvec3 const &C, double X, double Z, double &Out) +{ + 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 den = ux * vz - vx * uz; + if (std::abs(den) < 1e-9) + return false; + double const wx = X - A.x, wz = Z - A.z; + double const s = (wx * vz - vx * wz) / den; + double const t = (ux * wz - wx * uz) / den; + double constexpr epsilon{1e-9}; + if (s < -epsilon || t < -epsilon || s + t > 1.0 + epsilon) + return false; + Out = A.y + s * (B.y - A.y) + t * (C.y - A.y); + return true; +} + +// bilinear lookup in a tile heightfield; U runs west->east, V north->south, both 0..1 +double sample_heights(std::vector const &Heights, double U, double V) +{ + double const fx = std::clamp(U, 0.0, 1.0) * ground_grid; + double const fy = std::clamp(V, 0.0, 1.0) * ground_grid; + int const ix = std::min(static_cast(fx), ground_grid - 1); + int const iy = std::min(static_cast(fy), ground_grid - 1); + double const tx = fx - ix, ty = fy - iy; + auto const at = [&](int X, int Y) { return static_cast(Heights[static_cast(Y) * (ground_grid + 1) + X]); }; + double const top = at(ix, iy) + (at(ix + 1, iy) - at(ix, iy)) * tx; + double const bottom = at(ix, iy + 1) + (at(ix + 1, iy + 1) - at(ix, iy + 1)) * tx; + return top + (bottom - top) * ty; +} + +// smooth normal from the heightfield (u grows towards -x, v towards -z) +glm::vec3 heights_normal(std::vector const &Heights, double U, double V) +{ + double constexpr delta{1.0 / ground_grid}; + double const dhdu = (sample_heights(Heights, U + delta, V) - sample_heights(Heights, U - delta, V)) / (2.0 * delta * editor_orthophoto::tile_size); + double const dhdv = (sample_heights(Heights, U, V + delta) - sample_heights(Heights, U, V - delta)) / (2.0 * delta * editor_orthophoto::tile_size); + return glm::normalize(glm::vec3(static_cast(dhdu), 1.0f, static_cast(dhdv))); +} } // namespace // --------------------------------------------------------------------------------------------- editor_orthophoto::~editor_orthophoto() { + // the scene geometry is left alone: on shutdown the region may already be gone { std::lock_guard lock(m_mutex); m_stop = true; @@ -576,6 +657,11 @@ std::string editor_orthophoto::cache_directory() return std::string(path.begin(), path.end()); } +bool editor_orthophoto::owns(gfx::geometry_handle const &Geometry) +{ + return scene_bank.bank != 0 && Geometry.bank == scene_bank.bank; +} + void editor_orthophoto::enabled(bool State) { if (State == m_enabled) @@ -598,9 +684,13 @@ void editor_orthophoto::enabled(bool State) void editor_orthophoto::settings(config const &Config) { bool const sourcechanged = (Config.year != m_config.year || Config.hires != m_config.hires); + bool const shapechanged = (Config.height != m_config.height || Config.drape != m_config.drape || Config.in_scene != m_config.in_scene); m_config = Config; m_config.radius = std::clamp(m_config.radius, 0, max_radius); m_config.opacity = std::clamp(m_config.opacity, 0.0f, 1.0f); + if (shapechanged) + for (auto &entry : m_tiles) + entry.second.scene_dirty = true; if (!sourcechanged) return; cancel_pending(); @@ -610,6 +700,22 @@ void editor_orthophoto::settings(config const &Config) ++m_generation; } +void editor_orthophoto::refit() +{ + // the current shapes stay until their tile is sampled again, so nothing blinks + for (auto &entry : m_tiles) + entry.second.grounded = false; +} + +void editor_orthophoto::detach_scene() +{ + for (auto &entry : m_tiles) + { + remove_shape(entry.second); + release_geometry(entry.second); + } +} + void editor_orthophoto::cancel_pending() { { @@ -636,7 +742,7 @@ editor_orthophoto::statistics editor_orthophoto::stats() const for (auto const &entry : m_tiles) { tile const &t = entry.second; - if (t.level > 0 && !t.empty && t.texture != null_handle) + if (t.level > 0 && !t.empty && t.slot.texture != null_handle) ++out.resident; if (t.requested != 0) ++out.loading; @@ -665,6 +771,12 @@ editor_orthophoto::tile_key editor_orthophoto::camera_tile(glm::dvec3 const &Cam return {static_cast(std::floor(east / tile_size)), static_cast(std::floor(north / tile_size))}; } +glm::dvec2 editor_orthophoto::tile_corner(tile_key const &Key) const +{ + // image u runs west->east (towards -x), v north->south (towards -z) + return {m_config.east - Key.first * tile_size, (Key.second + 1) * tile_size - m_config.north}; +} + void editor_orthophoto::start_workers() { if (!m_workers.empty()) @@ -673,27 +785,31 @@ void editor_orthophoto::start_workers() m_workers.emplace_back(&editor_orthophoto::worker, this); } -texture_handle editor_orthophoto::acquire_texture() +editor_orthophoto::texture_slot editor_orthophoto::acquire_slot() { - if (!m_free_textures.empty()) + if (!m_free_slots.empty()) { - texture_handle const handle = m_free_textures.back(); - m_free_textures.pop_back(); - return handle; + texture_slot const slot = m_free_slots.back(); + m_free_slots.pop_back(); + return slot; } - // script-style generated texture: no file behind it, the content is supplied through update_from_memory() - return GfxRenderer->Fetch_Texture("internal_src:editor_orthophoto_" + std::to_string(m_texture_count++), true); + texture_slot slot; + slot.index = m_texture_count++; + slot.texture = GfxRenderer->Fetch_Texture(slot_name(slot.index), true); + return slot; } void editor_orthophoto::release_tile(tile &Tile) { - if (Tile.texture == null_handle) + remove_shape(Tile); + release_geometry(Tile); + if (Tile.slot.texture == null_handle) return; // shrink to a single texel to give the gpu memory back; the slot is reused by the next tile std::uint8_t const texel[4] = {0, 0, 0, 0}; - GfxRenderer->Texture(Tile.texture).update_from_memory(1, 1, texel); - m_free_textures.push_back(Tile.texture); - Tile.texture = null_handle; + GfxRenderer->Texture(Tile.slot.texture).update_from_memory(1, 1, texel); + m_free_slots.push_back(Tile.slot); + Tile.slot = texture_slot{}; } void editor_orthophoto::schedule(tile_key const &Key, tile &Tile, int Level) @@ -737,17 +853,291 @@ std::size_t editor_orthophoto::apply(result &Result, double Now) release_tile(t); return 0; } - if (t.texture == null_handle) - t.texture = acquire_texture(); - if (t.texture == null_handle) + if (t.slot.texture == null_handle) + t.slot = acquire_slot(); + if (t.slot.texture == null_handle) { t.level = 0; return 0; } - GfxRenderer->Texture(t.texture).update_from_memory(Result.width, Result.height, Result.pixels.data()); + // a shape already in the scene shows the new content right away, it refers to the same texture + GfxRenderer->Texture(t.slot.texture).update_from_memory(Result.width, Result.height, Result.pixels.data()); return Result.pixels.size(); } +void editor_orthophoto::sample_ground(tile_key const &Key, tile &Tile) +{ + Tile.grounded = true; + Tile.scene_dirty = true; + Tile.heights.clear(); + Tile.surface.clear(); + + glm::dvec2 const corner = tile_corner(Key); + glm::dvec2 const min(corner.x - tile_size, corner.y - tile_size); + glm::dvec2 const max(corner.x, corner.y); + std::vector triangles; + m_ground(min, max, triangles); + + int constexpr samples{ground_grid + 1}; + double constexpr step{tile_size / ground_grid}; + std::vector heights(static_cast(samples) * samples, no_height); + + for (auto const &triangle : triangles) + { + glm::dvec3 a = triangle[0], b = triangle[1], c = triangle[2]; + glm::dvec3 normal = glm::cross(b - a, c - a); + if (normal.y < 0.0) + { + // keep every triangle facing up, with the winding the editor terrain uses + std::swap(b, c); + normal = -normal; + } + double const length = glm::length(normal); + if (length < 1e-9 || normal.y / length < steepest_ground) + continue; // walls and the like don't receive the imagery + + glm::dvec3 polygon[8] = {a, b, c}; + glm::dvec3 scratch[8]; + int count = clip_xz(polygon, 3, scratch, 0, max.x, true); + count = clip_xz(scratch, count, polygon, 0, min.x, false); + count = clip_xz(polygon, count, scratch, 2, max.y, true); + count = clip_xz(scratch, count, polygon, 2, min.y, false); + for (int k = 1; k + 1 < count; ++k) + { + Tile.surface.push_back(polygon[0]); + Tile.surface.push_back(polygon[k]); + Tile.surface.push_back(polygon[k + 1]); + } + + // heightfield samples under the (whole) triangle, the highest surface wins + double const xlo = std::min({a.x, b.x, c.x}), xhi = std::max({a.x, b.x, c.x}); + double const zlo = std::min({a.z, b.z, c.z}), zhi = std::max({a.z, b.z, c.z}); + int const i0 = std::clamp(static_cast(std::ceil((corner.x - xhi) / step)), 0, samples - 1); + int const i1 = std::clamp(static_cast(std::floor((corner.x - xlo) / step)), 0, samples - 1); + int const j0 = std::clamp(static_cast(std::ceil((corner.y - zhi) / step)), 0, samples - 1); + int const j1 = std::clamp(static_cast(std::floor((corner.y - zlo) / step)), 0, samples - 1); + for (int j = j0; j <= j1; ++j) + for (int i = i0; i <= i1; ++i) + { + double y; + if (triangle_height(a, b, c, corner.x - i * step, corner.y - j * step, y)) + { + float &sample = heights[static_cast(j) * samples + i]; + sample = std::max(sample, static_cast(y)); + } + } + } + + // holes (places without ground) take the average of their neighbours, spreading inwards + std::size_t missing = std::count(heights.begin(), heights.end(), no_height); + if (missing == heights.size()) + return; // no ground at all: the tile stays flat at the layer height + while (missing > 0) + { + std::vector next = heights; + for (int j = 0; j < samples; ++j) + for (int i = 0; i < samples; ++i) + { + if (heights[static_cast(j) * samples + i] != no_height) + continue; + double sum{0.0}; + int found{0}; + int const offsets[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; + for (auto const &offset : offsets) + { + int const x = i + offset[0], y = j + offset[1]; + if (x < 0 || y < 0 || x >= samples || y >= samples) + continue; + float const neighbour = heights[static_cast(y) * samples + x]; + if (neighbour == no_height) + continue; + sum += neighbour; + ++found; + } + if (found > 0) + { + next[static_cast(j) * samples + i] = static_cast(sum / found); + --missing; + } + } + heights.swap(next); + } + Tile.heights = std::move(heights); + auto const range = std::minmax_element(Tile.heights.begin(), Tile.heights.end()); + Tile.height_min = *range.first; + Tile.height_max = *range.second; +} + +void editor_orthophoto::remove_shape(tile &Tile) +{ + if (Tile.section == nullptr) + return; + if (m_region == simulation::Region) + { + auto &shapes = Tile.section->m_shapes; + for (auto it = shapes.begin(); it != shapes.end(); ++it) + { + auto const &geometry = it->data().geometry; + if (geometry.bank == Tile.geometry.bank && geometry.chunk == Tile.geometry.chunk) + { + shapes.erase(it); + break; + } + } + } + Tile.section = nullptr; +} + +void editor_orthophoto::release_geometry(tile &Tile) +{ + // the chunk stays in the bank and is reused by a tile needing the same capacity + if (Tile.geometry.chunk != 0) + m_free_chunks.emplace(Tile.capacity, Tile.geometry); + Tile.geometry = gfx::geometry_handle{0, 0}; + Tile.capacity = 0; +} + +void editor_orthophoto::build_shape(tile_key const &Key, tile &Tile) +{ + remove_shape(Tile); + Tile.scene_dirty = false; + if (Tile.level == 0 || Tile.empty || Tile.slot.texture == null_handle || simulation::Region == nullptr) + return; + if (Tile.slot.material == null_handle) + Tile.slot.material = GfxRenderer->Fetch_Material(slot_name(Tile.slot.index), true); + + glm::dvec2 const corner = tile_corner(Key); + bool const draped = m_config.drape && !Tile.surface.empty(); + auto const uv_of = [&](glm::dvec3 const &Point) { return glm::vec2((corner.x - Point.x) / tile_size, (corner.y - Point.z) / tile_size); }; + + std::vector vertices; + auto const add = [&](glm::dvec3 const &Point, double Lift) { + world_vertex vertex; + vertex.position = Point + glm::dvec3(0.0, Lift, 0.0); + vertex.texture = uv_of(Point); + // smooth normals from the heightfield, the clipped triangles alone would look faceted + vertex.normal = (draped && !Tile.heights.empty()) ? heights_normal(Tile.heights, vertex.texture.x, vertex.texture.y) : glm::vec3(0.0f, 1.0f, 0.0f); + vertices.push_back(vertex); + }; + double ylo, yhi; + if (draped) + { + vertices.reserve(Tile.surface.size()); + for (auto const &point : Tile.surface) + add(point, surface_offset); + ylo = Tile.height_min; + yhi = Tile.height_max + surface_offset; + } + else + { + // flat quad at the layer height, triangles facing up like the draped surface + double const y = m_config.height; + glm::dvec3 const nw(corner.x, y, corner.y), ne(corner.x - tile_size, y, corner.y); + glm::dvec3 const sw(corner.x, y, corner.y - tile_size), se(corner.x - tile_size, y, corner.y - tile_size); + for (auto const &point : {nw, sw, ne, se, ne, sw}) + add(point, 0.0); + ylo = yhi = y; + } + + glm::dvec3 const centre(corner.x - tile_size * 0.5, (ylo + yhi) * 0.5, corner.y - tile_size * 0.5); + scene::basic_section §ion = simulation::Region->section(centre); + section.create_geometry(); // existing section geometry has to be in place before we add ours (idempotent) + glm::dvec3 const origin = section.m_area.center; + + // chunks are padded to a few fixed sizes (whole triangles), so ones freed by other tiles can be refilled in place + std::size_t capacity{6}; + while (capacity < vertices.size()) + capacity *= 2; + gfx::vertex_array gpuvertices; + gpuvertices.reserve(capacity); + for (auto const &vertex : vertices) + gpuvertices.emplace_back(gfx::basic_vertex::convert(vertex, origin)); + world_vertex padding; // degenerate triangles at the origin + padding.position = origin; + padding.normal = glm::vec3(0.0f, 1.0f, 0.0f); + padding.texture = glm::vec2(0.0f); + gpuvertices.resize(capacity, gfx::basic_vertex::convert(padding, origin)); + gfx::userdata_array userdata; + + if (Tile.geometry.chunk != 0 && Tile.capacity != capacity) + release_geometry(Tile); + if (Tile.geometry.chunk == 0) + { + auto const reusable = m_free_chunks.find(capacity); + if (reusable != m_free_chunks.end()) + { + Tile.geometry = reusable->second; + Tile.capacity = capacity; + m_free_chunks.erase(reusable); + } + } + if (Tile.geometry.chunk != 0) + { + GfxRenderer->Replace(gpuvertices, userdata, Tile.geometry, GL_TRIANGLES); + } + else + { + if (scene_bank.bank == 0) + scene_bank = GfxRenderer->Create_Bank(); + Tile.geometry = GfxRenderer->Insert(gpuvertices, userdata, scene_bank, GL_TRIANGLES); + Tile.capacity = capacity; + if (Tile.geometry.chunk == 0) + { + Tile.capacity = 0; + return; + } + } + + // the shape gets just two bounding vertices: they give it its area, and leave no triangles behind for + // code which reads the source vertices of the section shapes (e.g. snapping nodes to the ground) + std::vector bounds(2, padding); + bounds[0].position = glm::dvec3(corner.x - tile_size, ylo, corner.y - tile_size); + bounds[1].position = glm::dvec3(corner.x, yhi, corner.y); + scene::shape_node shape; + shape.make_terrain(Tile.slot.material, std::move(bounds), origin); + shape.geometry(Tile.geometry); + section.m_shapes.emplace_back(std::move(shape)); + // extend the section bounds so the tile isn't culled at its edges + double const reach = glm::length(glm::dvec3(tile_size * 0.5, (yhi - ylo) * 0.5, tile_size * 0.5)); + section.m_area.radius = std::max(section.m_area.radius, static_cast(glm::length(section.m_area.center - centre) + reach)); + + Tile.section = §ion; + m_region = simulation::Region; +} + +void editor_orthophoto::update_scene() +{ + if (m_region != simulation::Region) + { + // another scenery was loaded: the sections holding our shapes are gone, forget them without touching + for (auto &entry : m_tiles) + entry.second.section = nullptr; + m_region = simulation::Region; + } + if (!m_config.in_scene) + { + detach_scene(); + return; + } + if (simulation::Region == nullptr) + return; + + int built{0}; + for (auto &entry : m_tiles) + { + tile &t = entry.second; + if (t.section != nullptr && !t.scene_dirty) + continue; + if (t.level == 0 || t.empty) + continue; + if (m_config.drape && !t.grounded && m_ground) + continue; // wait for the ground fit; a shape already in place stays until then + build_shape(entry.first, t); + if (++built >= 8) + break; // the rest next frame + } +} + void editor_orthophoto::update(glm::dvec3 const &Camera) { double const now = steady_seconds(); @@ -813,6 +1203,30 @@ void editor_orthophoto::update(glm::dvec3 const &Camera) continue; schedule(key, t, level); } + + // ground fit, one tile per frame (it walks the scene geometry), nearest first + if (m_config.drape && m_ground && simulation::Region != nullptr) + { + tile *nearest{nullptr}; + tile_key nearestkey{0, 0}; + int nearestdistance{INT_MAX}; + for (auto &entry : m_tiles) + { + if (entry.second.grounded || entry.second.empty) + continue; + int const d = distance(entry.first); + if (d < nearestdistance) + { + nearestdistance = d; + nearest = &entry.second; + nearestkey = entry.first; + } + } + if (nearest != nullptr) + sample_ground(nearestkey, *nearest); + } + + update_scene(); } void editor_orthophoto::worker() @@ -871,56 +1285,74 @@ void editor_orthophoto::worker() void editor_orthophoto::draw(glm::mat4 const &ViewProjection, glm::dvec3 const &CameraPos, float ScreenWidth, float ScreenHeight) const { - if (!m_enabled || m_config.opacity <= 0.0f || ScreenWidth <= 0.0f || ScreenHeight <= 0.0f) + if (!m_enabled || m_config.in_scene || m_config.opacity <= 0.0f || ScreenWidth <= 0.0f || ScreenHeight <= 0.0f) return; ImDrawList *drawlist = ImGui::GetBackgroundDrawList(); + // without vertex offset support in the backend the whole list has to fit 16-bit indices + std::size_t const vertexbudget = (ImGui::GetIO().BackendFlags & ImGuiBackendFlags_RendererHasVtxOffset) ? vertex_budget * 8 : vertex_budget; ImU32 const color = IM_COL32(255, 255, 255, static_cast(m_config.opacity * 255.0f + 0.5f)); - auto const tosceen = [&](glm::vec4 const &Clip) { return ImVec2((Clip.x / Clip.w * 0.5f + 0.5f) * ScreenWidth, (0.5f - Clip.y / Clip.w * 0.5f) * ScreenHeight); }; + auto const toscreen = [&](glm::vec4 const &Clip) { return ImVec2((Clip.x / Clip.w * 0.5f + 0.5f) * ScreenWidth, (0.5f - Clip.y / Clip.w * 0.5f) * ScreenHeight); }; // slightly wider than the view so clipped edges never show on screen float constexpr guard{1.02f}; - auto const outside = [&](glm::vec4 const *Corners) { - auto const all = [&](auto const &Test) { return Test(Corners[0]) && Test(Corners[1]) && Test(Corners[2]) && Test(Corners[3]); }; + auto const outside = [&](glm::vec4 const *Corners, int Count) { + auto const all = [&](auto const &Test) { + for (int i = 0; i < Count; ++i) + if (!Test(Corners[i])) + return false; + return true; + }; return all([](glm::vec4 const &v) { return v.w < near_w; }) || all([&](glm::vec4 const &v) { return v.x > v.w * guard; }) || all([&](glm::vec4 const &v) { return v.x < -v.w * guard; }) || all([&](glm::vec4 const &v) { return v.y > v.w * guard; }) || all([&](glm::vec4 const &v) { return v.y < -v.w * guard; }); }; + // clip-space change per metre of height + glm::vec4 const up = ViewProjection * glm::vec4(0.0f, 1.0f, 0.0f, 0.0f); - double const height = static_cast(m_config.height); for (auto const &entry : m_tiles) { tile const &t = entry.second; - if (t.level == 0 || t.empty || t.texture == null_handle) + if (t.level == 0 || t.empty || t.slot.texture == null_handle) continue; - std::size_t const textureid = GfxRenderer->Texture(t.texture).get_id(); + std::size_t const textureid = GfxRenderer->Texture(t.slot.texture).get_id(); if (textureid == 0 || textureid == static_cast(static_cast(-1))) continue; + bool const draped = m_config.drape && !t.heights.empty(); + double const flat = m_config.height; + double const ylo = draped ? t.height_min : flat; + double const yhi = draped ? t.height_max : flat; + // world extent of the tile; u runs west->east (-x), v runs north->south (-z), matching the image rows - double const westx = m_config.east - entry.first.first * tile_size; - double const northz = (entry.first.second + 1) * tile_size - m_config.north; - auto const relative = [&](double U, double V) { return glm::vec3(glm::dvec3(westx - U * tile_size, height, northz - V * tile_size) - CameraPos); }; - // the tile is a parallelogram and the projection is linear before the divide, so clip positions interpolate exactly + glm::dvec2 const corner = tile_corner(entry.first); + auto const relative = [&](double U, double V) { return glm::vec3(glm::dvec3(corner.x - U * tile_size, 0.0, corner.y - V * tile_size) - CameraPos); }; + // the projection is linear before the divide, so clip positions interpolate exactly: c = c00 + du*u + dv*v + up*height glm::vec4 const c00 = ViewProjection * glm::vec4(relative(0.0, 0.0), 1.0f); glm::vec4 const du = ViewProjection * glm::vec4(relative(1.0, 0.0), 1.0f) - c00; glm::vec4 const dv = ViewProjection * glm::vec4(relative(0.0, 1.0), 1.0f) - c00; - glm::vec4 const corners[4] = {c00, c00 + du, c00 + du + dv, c00 + dv}; - if (outside(corners)) + auto const clip = [&](float U, float V, double Height) { return c00 + du * U + dv * V + up * static_cast(Height); }; + glm::vec4 const box[8] = {clip(0, 0, ylo), clip(1, 0, ylo), clip(1, 1, ylo), clip(0, 1, ylo), clip(0, 0, yhi), clip(1, 0, yhi), clip(1, 1, yhi), clip(0, 1, yhi)}; + if (outside(box, 8)) continue; - // ImGui interpolates texture coordinates affinely, so subdivide finer where the camera is close - double const nearestx = std::clamp(CameraPos.x, westx - tile_size, westx); - double const nearestz = std::clamp(CameraPos.z, northz - tile_size, northz); - double const distance = glm::length(glm::dvec3(nearestx, height, nearestz) - CameraPos); + // ImGui interpolates texture coordinates affinely, so subdivide finer where the camera is close; + // draped tiles also need enough segments to follow the ground + double const nearestx = std::clamp(CameraPos.x, corner.x - tile_size, corner.x); + double const nearestz = std::clamp(CameraPos.z, corner.y - tile_size, corner.y); + double const nearesty = std::clamp(CameraPos.y, ylo, yhi); + double const distance = glm::length(glm::dvec3(nearestx, nearesty, nearestz) - CameraPos); double const step = std::clamp(distance * 0.2, 4.0, tile_size); - int const segments = std::clamp(static_cast(std::ceil(tile_size / step)), 1, 64); + int segments = std::clamp(static_cast(std::ceil(tile_size / step)), 1, 64); + if (draped) + segments = std::max(segments, distance < 300.0 ? ground_grid : distance < 800.0 ? ground_grid / 2 : ground_grid / 4); float const segmentsize = 1.0f / static_cast(segments); + auto const height_at = [&](float U, float V) { return draped ? sample_heights(t.heights, U, V) : flat; }; drawlist->PushTextureID(reinterpret_cast(textureid)); for (int j = 0; j < segments; ++j) for (int i = 0; i < segments; ++i) { - if (static_cast(drawlist->VtxBuffer.Size) + 16 > vertex_budget) + if (static_cast(drawlist->VtxBuffer.Size) + 16 > vertexbudget) { drawlist->PopTextureID(); return; @@ -928,13 +1360,13 @@ void editor_orthophoto::draw(glm::mat4 const &ViewProjection, glm::dvec3 const & float const u0 = i * segmentsize, u1 = (i + 1) * segmentsize; float const v0 = j * segmentsize, v1 = (j + 1) * segmentsize; clip_vertex quad[4] = { - {c00 + du * u0 + dv * v0, {u0, v0}}, - {c00 + du * u1 + dv * v0, {u1, v0}}, - {c00 + du * u1 + dv * v1, {u1, v1}}, - {c00 + du * u0 + dv * v1, {u0, v1}}, + {clip(u0, v0, height_at(u0, v0)), {u0, v0}}, + {clip(u1, v0, height_at(u1, v0)), {u1, v0}}, + {clip(u1, v1, height_at(u1, v1)), {u1, v1}}, + {clip(u0, v1, height_at(u0, v1)), {u0, v1}}, }; glm::vec4 const positions[4] = {quad[0].position, quad[1].position, quad[2].position, quad[3].position}; - if (outside(positions)) + if (outside(positions, 4)) continue; // clip against the near plane and the (slightly widened) sides of the view @@ -956,7 +1388,7 @@ void editor_orthophoto::draw(glm::mat4 const &ViewProjection, glm::dvec3 const & drawlist->PrimWriteIdx(static_cast(base + k + 1)); } for (int k = 0; k < count; ++k) - drawlist->PrimWriteVtx(tosceen(buffer0[k].position), ImVec2(buffer0[k].uv.x, buffer0[k].uv.y), color); + drawlist->PrimWriteVtx(toscreen(buffer0[k].position), ImVec2(buffer0[k].uv.x, buffer0[k].uv.y), color); } drawlist->PopTextureID(); } diff --git a/editor/editorOrthophoto.hpp b/editor/editorOrthophoto.hpp index 40fdf98e..5f246db4 100644 --- a/editor/editorOrthophoto.hpp +++ b/editor/editorOrthophoto.hpp @@ -9,10 +9,12 @@ http://mozilla.org/MPL/2.0/. #pragma once +#include #include #include #include #include +#include #include #include #include @@ -21,19 +23,28 @@ http://mozilla.org/MPL/2.0/. #include #include -#include "interfaces/ITexture.h" // texture_handle +#include "interfaces/ITexture.h" // texture_handle +#include "utilities/Classes.h" // material_handle +#include "rendering/geometrybank.h" // gfx::geometry_handle -// Editor aid: aerial orthophoto tiles from geoportal.gov.pl (GUGiK WMS), drawn as a translucent -// horizontal layer around the camera. +namespace scene +{ +class basic_region; +class basic_section; +} // namespace scene + +// Editor aid: aerial orthophoto tiles from geoportal.gov.pl (GUGiK WMS), shown around the camera either +// on a horizontal plane or fitted onto the terrain. // // Tiles are aligned to an absolute 256 m grid in PUWG 1992 (EPSG:2180), so the disk cache is shared // between sceneries. The scenery is placed on that grid through the PUWG coordinates of its (0,0,0) // point; MaSzyna's +Z points north and +X points west, hence easting = E0 - x, northing = N0 + z. // // Downloading, cache I/O and image decoding run on worker threads; the main thread only uploads -// finished images (a bounded amount per frame) and emits the overlay geometry. The overlay goes into -// the ImGui background draw list, like the other editor viewport overlays, so it works with every -// renderer backend, but it isn't depth-tested against the scene. +// finished images (a bounded amount per frame) and emits the geometry. By default the layer goes into +// the ImGui background draw list, like the other editor viewport overlays: translucent and drawn over +// everything. Optionally it is put into the scene instead (opaque shapes in the region sections, like the +// editor terrain), so tracks, models and terrain in front of it cover it. class editor_orthophoto { public: @@ -42,12 +53,18 @@ class editor_orthophoto double north{0.0}; // PUWG 1992 X (northing) of the scenery origin double east{0.0}; // PUWG 1992 Y (easting) of the scenery origin int radius{2}; // tiles loaded around the camera tile (Chebyshev distance) - float height{0.0f}; // world Y of the layer - float opacity{0.6f}; // 0..1 + float height{0.0f}; // world Y of the layer (with drape: only where no ground was found) + float opacity{0.6f}; // 0..1, overlay only int year{0}; // 0 = newest available imagery, otherwise newest imagery taken up to the end of that year bool hires{false}; // 4096 px tiles from the high-resolution product next to the camera, where it exists + bool drape{true}; // fit the imagery onto the ground geometry instead of a flat plane + bool in_scene{false}; // render as opaque scene geometry, covered by whatever is in front of it }; + using world_triangle = std::array; + // supplies the ground triangles overlapping an XZ rectangle (Min/Max are x,z); used to drape the imagery + using ground_query = std::function &Out)>; + struct statistics { int resident{0}; // tiles with imagery on screen @@ -70,6 +87,13 @@ class editor_orthophoto config const &settings() const { return m_config; } // changes of year / resolution invalidate resident tiles, the rest applies immediately void settings(config const &Config); + void ground_source(ground_query Query) { m_ground = std::move(Query); } + // samples the ground again (after it was edited); tiles are refitted progressively + void refit(); + // takes the scene geometry out of the region (e.g. when the editor is left); it's re-created on the next update + void detach_scene(); + // true for geometry created by the layer, so ground queries can skip it + static bool owns(gfx::geometry_handle const &Geometry); // main thread, once per frame: schedules tiles around the camera and uploads finished ones void update(glm::dvec3 const &Camera); @@ -115,22 +139,46 @@ class editor_orthophoto std::vector pixels; // RGBA8, first row = north edge }; + struct texture_slot + { + int index{-1}; // names the texture and its material + texture_handle texture{null_handle}; + material_handle material{null_handle}; // created on first use by the scene geometry + }; + struct tile { - texture_handle texture{null_handle}; + texture_slot slot; int level{0}; // size of the imagery currently held (0 = nothing yet) int requested{0}; // level of the job scheduled for this tile (0 = none) bool empty{false}; int failures{0}; double retry_at{0.0}; // steady clock seconds + // ground fit + bool grounded{false}; // ground was sampled + std::vector heights; // (ground_grid+1)^2 world Y, rows north->south, columns west->east; empty if no ground + float height_min{0.0f}; + float height_max{0.0f}; + std::vector surface; // upward facing ground triangles clipped to the tile (3 vertices each) + // scene geometry + scene::basic_section *section{nullptr}; // section holding the shape (nullptr = not in the scene) + gfx::geometry_handle geometry{0, 0}; + std::size_t capacity{0}; // vertex count of the geometry chunk + bool scene_dirty{true}; // the shape needs to be rebuilt }; static int level_for(int Distance, bool Hires); tile_key camera_tile(glm::dvec3 const &Camera) const; + glm::dvec2 tile_corner(tile_key const &Key) const; // world (x,z) of the north-west corner void schedule(tile_key const &Key, tile &Tile, int Level); std::size_t apply(result &Result, double Now); // returns bytes uploaded void release_tile(tile &Tile); - texture_handle acquire_texture(); + texture_slot acquire_slot(); + void sample_ground(tile_key const &Key, tile &Tile); + void build_shape(tile_key const &Key, tile &Tile); + void remove_shape(tile &Tile); + void release_geometry(tile &Tile); + void update_scene(); void start_workers(); void worker(); @@ -138,8 +186,11 @@ class editor_orthophoto bool m_enabled{false}; std::map m_tiles; // main thread only tile_key m_camera_tile{0, 0}; - std::vector m_free_textures; // pool of texture slots, reused between tiles + std::vector m_free_slots; // pool of textures, reused between tiles int m_texture_count{0}; + ground_query m_ground; + scene::basic_region *m_region{nullptr}; // region the scene geometry was put into + std::multimap m_free_chunks; // geometry chunks by capacity, reused between tiles // shared with the workers, guarded by m_mutex mutable std::mutex m_mutex; diff --git a/editor/editorSettings.cpp b/editor/editorSettings.cpp index 3f7956e2..3e7516c1 100644 --- a/editor/editorSettings.cpp +++ b/editor/editorSettings.cpp @@ -86,6 +86,10 @@ bool editorSettings::load() parse(value, m_orthophoto.year); else if (key == "ortho_4k") m_orthophoto.hires = (value == "1"); + else if (key == "ortho_drape") + m_orthophoto.drape = (value == "1"); + else if (key == "ortho_in_scene") + m_orthophoto.in_scene = (value == "1"); else if (key.rfind(origin_prefix, 0) == 0) { // northing;easting @@ -121,6 +125,8 @@ bool editorSettings::save() stream << "ortho_opacity " << m_orthophoto.opacity << "\n"; stream << "ortho_year " << m_orthophoto.year << "\n"; stream << "ortho_4k " << (m_orthophoto.hires ? 1 : 0) << "\n"; + stream << "ortho_drape " << (m_orthophoto.drape ? 1 : 0) << "\n"; + stream << "ortho_in_scene " << (m_orthophoto.in_scene ? 1 : 0) << "\n"; stream << std::fixed << std::setprecision(3); for (auto const &origin : m_orthophoto_origins) stream << origin.first << " " << origin.second.first << ";" << origin.second.second << "\n"; diff --git a/editor/editorSettings.hpp b/editor/editorSettings.hpp index 122a6f04..9d46a000 100644 --- a/editor/editorSettings.hpp +++ b/editor/editorSettings.hpp @@ -30,6 +30,8 @@ public: float opacity{0.6f}; int year{0}; bool hires{false}; + bool drape{true}; + bool in_scene{false}; }; orthophoto_settings &orthophoto() { return m_orthophoto; } // PUWG 1992 northing/easting of the scenery's (0,0,0) point; false when none was stored