Terrain improvement

This commit is contained in:
2026-09-04 21:52:47 +03:00
parent 4562db481a
commit b062d8331d
40 changed files with 10367 additions and 807 deletions
+173
View File
@@ -41,6 +41,9 @@ cd build-vscode/src/features/editScene
# Run terrain integration tests headless (1x1 hidden SDL window, no UI)
./editSceneEditor --headless --run-terrain-tests=1
# Run only matching test steps (substring match on step name)
TERRAIN_TEST_FILTER=terrainCompliance ./editSceneEditor --headless --run-terrain-tests=1
# Road wedge/segment self-intersection regression test (no scene needed,
# also registered as CTest roadGeometryOverlapTest)
./road_geometry_overlap_test
@@ -150,6 +153,19 @@ footprint using fixup chunks. Spawned instances are runtime-only:
"prefab spawn mode" (Terrain panel, ESC to exit) places spawners by clicking
the terrain.
**Streaming (M4):** when the active terrain has `streamingEnabled`, spawner
definitions persist per terrain page in
`heightmaps/<terrainId>/spawners/<px>_<py>.json` (`SpawnerRegionStore`,
absolute world-space doubles). `syncStreamedSpawners()` polls
`TerrainGroup::getTerrainSlots()` each frame: it creates a spawner entity per
def when its page loads and destroys the entity (writing any edits back to the
store, including defs whose position crossed a page boundary) when the page
unloads. Streamed entities carry `StreamedSpawnerTag{pageX, pageY, defId}` so
`SceneSerializer` excludes them from the scene JSON and editor edits can be
written back. `createSpawnPoint()` routes to the region store while streaming.
Scene-embedded `terrainPrefabSpawner` entities keep working as always-loaded
spawners for small or non-streaming scenes.
### RoadSystem
Per-terrain procedural roads (`RoadGraph` component, geometry built by
@@ -181,9 +197,108 @@ Per-terrain procedural roads (`RoadGraph` component, geometry built by
pedestrian strip per wedge outer curb (widened curb-offset chains —
see `ProceduralRoadGeometry.md` §15); terrain compliance starts its
falloff at the sidewalk outer edge.
- **Terrain compliance** ("Comply Terrain to Roads",
`RoadSystem::complyTerrain`): collects the top surface of every road
piece as world-space triangles, then treats "terrain must stay below
the slab underside" as a constraint-solving problem on the *rendered
page-lattice* vertices (one vertex per ~31 m, the only heights the
renderer interpolates between). `collectComplianceConstraints()`
clips each triangle against every lattice cell it overlaps and emits
one linear constraint per candidate point (cell corners, kink points
on the actual per-row triangulation diagonal — matching the
renderer/collider zigzag — and triangle vertices), with barycentric
weights on the 4 cell corners and target `roadTop -
ROAD_COMPLIANCE_SAG` (0.05). The
system is solved in two phases over cached vertex heights: (1) damped
Kaczmarz lower-only sweeps push violated constraints to feasibility,
each corner sinking by its barycentric-weight share of the excess;
(2) a raise-only relaxation lifts every vertex back to the highest
value its constraints allow (capped at the natural height), which
recovers the hysteresis overshoot of phase 1 so the roadbed follows
the road curvature instead of collapsing to the lowest road nearby.
The resulting per-vertex lowering is applied via
`TerrainSystem::lowerFixupCorners()`, which lowers only the 4 fixup
samples blending into that vertex and materializes sentinel corners
at the *current* surface so the post-write blend equals
`natural - delta` exactly. The footprint stays within the cells
touching the road instead of flattening the whole corridor.
- **Roads-to-terrain compliance** ("Comply Roads to Terrain",
`RoadSystem::complyRoadsToTerrain`): the inverse operation — shifts
road nodes vertically (both directions) so every point of every edge
stays at least `roadThickness + 0.05` above the current terrain.
Constraints are sampled along each half-edge (dense 0.5 m centreline
and curb samples extended backwards over the node wedge, every
lattice-line crossing of those lines, and every page vertex inside
the road footprint — lanes plus sidewalk only when enabled) against
`renderedHeightAt()` (the rendered page-lattice surface with the
actual per-row triangulation diagonal), and every sample is filtered
against the actual XZ footprint of the road top-surface triangles
(the sampling rectangle's corners stick out past the wedge fan /
end cap behind a node; bumps there must not lift the road). The
system is solved iteratively in three phases: (1) Kaczmarz projection
sweeps raise nodes to feasibility, distributing each violated
constraint's deficit between the node and its neighbour along the
constraint normal — a per-node "raise to the max lower bound" update
instead corners the solution onto whichever node the update order
hits first, leaving metres of hover over the other end; (2)
alternating damped lower-only relaxation and pairwise rebalancing —
lowering the small-coefficient side of a binding constraint while
raising the large-coefficient side to compensate keeps it satisfied
and strictly reduces the total node height, escaping the LP corners
plain relaxation sticks at; (3) a few raise-only passes clear the
residual violations phase 2 can introduce. Node
`verticalOffset` values are re-derived and the graph version is
bumped.
- **Region streaming (M5)**: when the terrain has `streamingEnabled`, the
road graph persists per terrain page in
`heightmaps/<terrainId>/roads/<px>_<py>.json` (`RoadRegionStore`,
absolute world-space doubles) instead of inline in the scene JSON —
`SceneSerializer::serializeTerrain` then writes only `roadConfig`. A
cross-page edge is stored in the region file of *each* endpoint page
with the foreign endpoint repeated inline; merges match nodes by world
XZ (0.01 eps) so reloaded pages never duplicate nodes, and edges are
only created once both endpoints are in the active graph. A non-empty
legacy inline graph is migrated to region files on the first sync.
`EditorUISystem::saveScene` calls `RoadSystem::flushRegionStore()`
before writing the scene. Page mesh rebuilds are gated by a
rebase-invariant FNV-1a content signature (world positions quantized
to cm), so a render-origin rebase (which shifts node render positions
via `RoadSystem::onRenderOriginChanged`, forwarded from
`TerrainSystem::onRenderOriginChanged`) does not re-dirty pages;
navmesh dirtying is debounced ~15 quiet frames while streaming.
Road edit mode exits with ESC (same as prefab spawn mode).
### Terrain Streaming & Navigation
- `RenderOriginSystem` (singleton, `RenderOriginSystem::getInstance()`,
created in `EditorApp`) owns the floating render origin: render =
world - renderOrigin. When the camera moves further than
`REBASE_THRESHOLD` (8192 render units) from the origin, the origin
snaps to integer world coordinates near the camera and every
render-space position shifts by the exact negated delta. Convert with
`worldToRender(double,double,double)` / `renderToWorld(Ogre::Vector3)`
(returns `JPH::DVec3`).
- With `TerrainComponent::streamingEnabled`, the world spans up to
`worldSizeUnits` (default 40,000,000) units per axis — 20000 x 20000
pages at the default 2000-unit page size, clamped to 32768 for
TerrainGroup's signed 16-bit slot packing. `updateStreamingWindow()`
loads `pageLoadRadius` pages around the camera (2 loads/frame) and
unloads beyond `pageHoldRadius` (4 unloads/frame), all synchronously.
Base heights come from `TerrainComponent::baseNoise` (FastNoiseLite,
double precision); edits write through the LRU-capped fixup layer.
- Editor navigation (M3): `EditorCamera` has wheel zoom
(`handleMouseWheel`), Shift boost (x10) and configurable fly speed
(`setFlySpeed`, 1..100000 units/s). **Tools -> Navigation** opens
`NavigationPanel` (teleport by world coords or page index via the
static `teleportCamera`/`teleportToPage` helpers; named world
bookmarks persisted as a top-level `bookmarks` array in the scene
JSON, tolerated when missing). **Tools -> World Map** opens
`WorldMapPanel` (ImGui canvas over the ImGui-free `WorldMapData`:
heights/pages/roads/spawners/bookmarks/camera overlays, drag pan,
wheel zoom, double-click teleport). The Terrain panel has a compact
"Navigation" section sharing the same backend.
### Player Character Resolution
Never resolve the player by matching `PlayerControllerComponent::targetCharacterName`
@@ -283,6 +398,61 @@ Make sure documentation, tests and examples are always in sync.
- Use `editScene::isGameMode()` / `editScene::isGamePlaying()` for mode checks
when you don't have an `EditorApp` pointer.
- Terrain code has THREE coordinate spaces (M2): **world** space (absolute,
double precision, up to 40,000,000 x 40,000,000 units — authoritative),
**render** space (float, `render = world - renderOrigin`, what scene nodes,
mouse rays and the editor camera live in) and **physical** heightmap space
(what `fillPageHeightData`, fixup chunks, `sampleHeightAt`/
`sampleBaseHeightAt` and `writeFixup`/`writeFixupSample` use; physical X is
shifted by half a page, physical Z is mirrored within each page). Convert
render->world with `TerrainSystem::renderToWorldX/Y/Z`, world->physical with
`TerrainSystem::visualToPhysicalX/Z` (doubles). `RenderOriginSystem`
(singleton, `EditorApp::getRenderOriginSystem()`) owns the render origin and
rebases when the editor camera moves past `REBASE_THRESHOLD` (8192) units:
root-level entity nodes shift by the exact negated delta (recomputed from
`TransformComponent::worldX/Y/Z` when `hasWorldPosition` is set — serialized
as `"worldPosition"` in scene JSON), and `TerrainSystem::onRenderOriginChanged`
repositions terrain pages precisely from doubles (Ogre's own slot position
math is single-precision and loses metres at page indices near 20000).
Terrain Jolt colliders live in absolute world space (`JPH::RVec3` overloads
in `physics/physics.h`); `TerrainSystem::raycastTerrain` converts its ray to
world space for the collider query.
- Terrain streaming groundwork: `TerrainComponent::streamingEnabled`
(default false) switches the base-height source from the legacy
`m_heightData` buffer to on-demand procedural evaluation of
`TerrainComponent::baseNoise` (`TerrainSystem::computeBaseNoise`,
FastNoiseLite OpenSimplex2, sampled in double precision). Page streaming IS
active in streaming mode: a window of `pageLoadRadius`/`pageHoldRadius`
pages around the camera loads/unloads each frame (2 loads per frame cap),
physical pages are indexed [0, N-1] with N = worldSizeUnits/worldSize
(clamped to 32768 for TerrainGroup's signed 16-bit slot packing), and blend
maps/aux maps are stored per page under
`heightmaps/<terrainId>/`. `setHeightAt` writes through the fixup layer,
and analytic fallbacks serve unloaded pages (`getHeightAt`,
`raycastTerrain`). Fixup chunks are
LRU-capped (`TerrainSystem::setFixupChunkCap`, default 64); evicted
dirty chunks are flushed to disk. While a terrain is active the editor
camera far clip and linear fog follow `TerrainComponent::farClipDistance`
/ `fogEnabled` / `fogStart` / `fogEnd` and are restored on deactivate.
- Physical terrain page `(x, y)` lives in Ogre TerrainGroup slot `(x, -y)`
because `ALIGN_X_Z` negates Z — `TerrainSystem::isPageLoaded(x, y)` and the
region stores take PHYSICAL page coords and convert internally. Getting
this mapping wrong places loads/saves on the mirrored page.
- Never store world positions in floats for anything that must stay exact at
the far end of the world: floats lose ~4 units at 40,000,000. Use
`TransformComponent::worldX/Y/Z` doubles (or region files) as the
authoritative position and derive render-space floats from them.
- Streamed content is NOT in the scene JSON: `StreamedSpawnerTag` spawner
entities and (when streaming) `roadNodes`/`roadEdges` live in the
`heightmaps/<terrainId>/{spawners,roads}/<px>_<py>.json` region stores.
Spawner store writes are immediate per mutation; road regions flush on
page unload and via `RoadSystem::flushRegionStore()` on scene save;
dirty fixup chunks flush on LRU eviction. Expect edits to survive only
through those paths.
- The `ecs.terrain` Lua brush functions (`sculpt`, `paint`, `paintAux`)
take absolute visual WORLD coordinates (render-origin independent) and
convert internally; only `terrain.sampleAux` takes PHYSICAL heightmap
coordinates (see `lua/LuaTerrainApi.hpp`).
- Systems that set animation states must not affect player-controlled entities;
check `e.has<PlayerControlledComponent>()` early.
- Spawner-targeted characters are transient instances; persistent player data
@@ -294,5 +464,8 @@ Make sure documentation, tests and examples are always in sync.
## Additional Docs
- `TerrainDoc.md` terrain system functional description (streaming, coordinate
spaces, region storage, editor tools)
- `TerrainImprovment2.md` streamed 40M-world plan with per-milestone status
- `docs/SaveLoadSystem.md` save file format and API details
- Root `AGENTS.md` project-wide build, style and architecture notes
+8
View File
@@ -24,6 +24,9 @@ set(EDITSCENE_SOURCES
systems/EditorSkyboxSystem.cpp
systems/EditorWaterPlaneSystem.cpp
systems/TerrainSystem.cpp
systems/RenderOriginSystem.cpp
systems/SpawnerRegionStore.cpp
systems/RoadRegionStore.cpp
systems/RoadSystem.cpp
systems/LightSystem.cpp
systems/CameraSystem.cpp
@@ -871,5 +874,10 @@ add_custom_command(TARGET editSceneEditor POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/tests/prefabs"
"${CMAKE_CURRENT_BINARY_DIR}/tests/prefabs"
# Project runtime prefabs (PrefabSystem resolves "prefabs/..."
# relative to the executable directory)
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/prefabs"
"${CMAKE_CURRENT_BINARY_DIR}/prefabs"
COMMENT "Copying resources to editSceneEditor build directory"
)
+31
View File
@@ -10,6 +10,7 @@
#include "systems/EditorSkyboxSystem.hpp"
#include "systems/EditorWaterPlaneSystem.hpp"
#include "systems/TerrainSystem.hpp"
#include "systems/RenderOriginSystem.hpp"
#include "systems/LightSystem.hpp"
#include "systems/CameraSystem.hpp"
#include "systems/LodSystem.hpp"
@@ -530,6 +531,16 @@ void EditorApp::setup()
m_physicsSystem->getPhysicsWrapper());
}
/* Floating render origin (M2): rebases render space when the
* camera wanders far from the origin so the world can span
* 40,000,000 x 40,000,000 units without float artifacts. */
m_renderOriginSystem =
std::make_unique<RenderOriginSystem>(m_world,
m_sceneMgr);
m_renderOriginSystem->setTerrainSystem(m_terrainSystem.get());
m_renderOriginSystem->setEditorCamera(m_camera.get());
m_renderOriginSystem->setAuxNodes(m_gridNode, m_axisNode);
// Apply debug setting if it was set before system creation
if (m_debugBuoyancy) {
m_buoyancySystem->setDebugEnabled(true);
@@ -1606,6 +1617,11 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
{
bool paused = (m_gamePlayState == GamePlayState::Paused);
/* Render origin rebase runs first so every system below sees the
* post-rebase render space. */
if (m_renderOriginSystem)
m_renderOriginSystem->update();
if (m_gameMode == GameMode::Editor) {
// Update camera
if (m_camera) {
@@ -1900,6 +1916,21 @@ bool EditorApp::mouseReleased(const OgreBites::MouseButtonEvent &evt)
return true;
}
bool EditorApp::mouseWheelRolled(const OgreBites::MouseWheelEvent &evt)
{
if (m_gameMode == GameMode::Game) {
return true;
}
/* Only zoom the editor camera when ImGui is not capturing the
* wheel (scrolling a panel must not zoom the viewport). */
ImGuiIO &io = ImGui::GetIO();
if (!io.WantCaptureMouse && m_camera) {
m_camera->handleMouseWheel(evt.y);
}
return true;
}
bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt)
{
m_currentModifiers = evt.keysym.mod;
+7
View File
@@ -42,6 +42,7 @@ class EditorSkyboxSystem;
class EditorWaterPlaneSystem;
class NormalDebugSystem;
class TerrainSystem;
class RenderOriginSystem;
class SmartObjectSystem;
class GoapRunnerSystem;
class PathFollowingSystem;
@@ -151,6 +152,7 @@ public:
bool mouseMoved(const OgreBites::MouseMotionEvent &evt) override;
bool mousePressed(const OgreBites::MouseButtonEvent &evt) override;
bool mouseReleased(const OgreBites::MouseButtonEvent &evt) override;
bool mouseWheelRolled(const OgreBites::MouseWheelEvent &evt) override;
bool keyPressed(const OgreBites::KeyboardEvent &evt) override;
bool keyReleased(const OgreBites::KeyboardEvent &evt) override;
@@ -224,6 +226,10 @@ public:
{
return m_camera.get();
}
RenderOriginSystem *getRenderOriginSystem() const
{
return m_renderOriginSystem.get();
}
AnimationTreeSystem *getAnimationTreeSystem() const
{
return m_animationTreeSystem.get();
@@ -291,6 +297,7 @@ private:
std::unique_ptr<EditorSkyboxSystem> m_skyboxSystem;
std::unique_ptr<EditorWaterPlaneSystem> m_waterPlaneSystem;
std::unique_ptr<TerrainSystem> m_terrainSystem;
std::unique_ptr<RenderOriginSystem> m_renderOriginSystem;
std::unique_ptr<EditorLightSystem> m_lightSystem;
std::unique_ptr<EditorCameraSystem> m_cameraSystem;
std::unique_ptr<EditorLodSystem> m_lodSystem;
+187
View File
@@ -0,0 +1,187 @@
# Terrain System — Functional Description
This document describes how the terrain in `src/features/editScene` works: what it can
do, how the data is organised, and how the editor and runtime systems interact with it.
For the implementation history see `TerrainImprovment2.md`; for contributor rules see
`AGENTS.md`.
## Overview
The terrain is a single `TerrainComponent`-driven Ogre `TerrainGroup` owned by
`TerrainSystem`. Two modes exist:
- **Legacy mode** (`streamingEnabled = false`): a small fixed grid, all pages defined
and loaded up front, base heights from a single in-memory heightmap buffer. Used by
old scenes and most headless tests.
- **Streaming mode** (`streamingEnabled = true`): a bounded world of up to
40,000,000 × 40,000,000 units (20,000 × 20,000 pages at the default 2000-unit page
size). Pages stream in around the camera; base heights are procedural; edits, paint
layers, spawners and roads persist in per-page sidecar files.
## Coordinate spaces
Four coordinate spaces coexist; converting at system boundaries is every caller's job:
- **WORLD** — absolute double-precision coordinates, authoritative. Region files,
bookmarks, the Navigation panel, `TransformComponent::worldX/Y/Z` and physics use it.
- **RENDER** — float, relative to the floating render origin:
`render = world - renderOrigin`. Scene nodes, mouse rays and cameras live here.
`RenderOriginSystem` (singleton) owns the origin and rebases when the camera moves
more than 8192 render units away from it, shifting every render-space position by the
exact negated delta. Convert with `RenderOriginSystem::worldToRender()` /
`renderToWorld()`.
- **PHYSICS** — Jolt `DVec3`/`RVec3` in absolute world coordinates
(`physics/physics.h`). Terrain colliders live here.
- **PHYSICAL HEIGHTMAP** — per-page vertex indices: physical X is shifted by half a
page, physical Z is mirrored within each page. Convert with
`TerrainSystem::visualToPhysicalX/Z` / `physicalToVisualX/Z`.
Page indices are PHYSICAL page coordinates `[0, N-1]`. The Ogre TerrainGroup slot for
physical page `(x, y)` is `(x, -y)` because `ALIGN_X_Z` negates Z — use
`TerrainSystem::isPageLoaded(x, y)` rather than raw slot math.
Never store a world position in a float; floats cannot represent 40,000,000 with
sub-metre precision.
## Configuration (`TerrainComponent`)
| Field | Default | Meaning |
|---|---|---|
| `streamingEnabled` | false | Opt in to the streamed world |
| `worldSizeUnits` | 40,000,000 | World extent per axis (units) |
| `pageLoadRadius` | 2 | Pages loaded around the camera page |
| `pageHoldRadius` | 3 | Pages kept before unloading (hysteresis) |
| `farClipDistance` | 6000 | Camera far clip while terrain is active |
| `fogEnabled` / `fogStart` / `fogEnd` | true / 2500 / 5500 | Linear fog hiding pop-in |
| `baseNoise` | seeded FastNoiseLite | Procedural base height source |
All fields serialize with the scene (`SceneSerializer::serializeTerrain`).
## Streaming window
Each frame `TerrainSystem::updateStreamingWindow()` computes the camera's page, then:
- defines and loads pages entering the load window (at most 2 per frame), and
- unloads pages beyond the hold radius (at most 4 per frame).
Loading is synchronous and amortized — Ogre's background WorkQueue is deliberately not
used (it caused shutdown hangs). Base heights for a page are evaluated on demand from
`baseNoise` (double precision, deterministic for a given seed); a legacy
`heightmaps/<terrainId>/heightmap.bin`, when present, is sampled as a "base patch"
inside its bounds for backward compatibility.
Sculpting never touches the base layer: `setHeightAt`/brushes write into the sparse
per-page **fixup chunk** store (`heightmaps/<terrainId>/terrain_fixup/x{cX}_z{cZ}.bin`),
which is lazily disk-loaded, LRU-capped, and saved on eviction when dirty. Blend maps
and aux maps are likewise per-page files under `heightmaps/<terrainId>/`.
Height queries (`getHeightAt`, `raycastTerrain`) hit loaded pages when possible and fall
back to the analytic base+fixup evaluation for far/unloaded terrain, so tools (teleport
snapping, world map) work anywhere in the world.
Each loaded page gets a static Jolt collider created at its page-index-computed world
position (double precision, no float round-trip); colliders are queued/removed with the
page lifecycle.
## Region storage layout
All per-terrain streamed data lives under `heightmaps/<terrainId>/`:
```
heightmaps/<terrainId>/
├── terrain_fixup/x{cX}_z{cZ}.bin # sparse sculpt edits (chunk = page)
├── blend/... aux/... # per-page paint layers
├── spawners/<px>_<py>.json # prefab spawner definitions (M4)
└── roads/<px>_<py>.json # road graph partition (M5)
```
`<px>_<py>` are physical page indices. Positions inside the JSON files are absolute
world-space doubles, so the files are immune to render-origin rebases.
## Prefab spawner streaming
Two kinds of prefab spawners exist:
- **Always-loaded**: scene-embedded `TerrainPrefabSpawnerComponent` entities — the
classic behaviour, unchanged, right for small scenes.
- **Streamed**: when the terrain streams, spawner definitions live in the region store.
`TerrainPrefabSpawnerSystem::syncStreamedSpawners()` watches the loaded page set,
creates a region spawner entity (tagged `StreamedSpawnerTag`, excluded from scene
JSON) when a page loads, and destroys it — writing position/distance edits back,
including moves across page boundaries — when it unloads. The distance-based
spawn/despawn of the actual prefab instances is unchanged.
The editor's click-to-place prefab spawn mode writes straight to the region store when
streaming.
## Road streaming
With streaming enabled, the road graph (`RoadGraph`: nodes + edges + `roadConfig`) is
partitioned per page:
- A node belongs to the page containing its position; a cross-page edge is written to
**both** endpoint pages' files (foreign endpoint repeated inline), so each file is
self-contained.
- On page load, `RoadSystem::syncRegions()` merges the file into the active graph,
matching nodes by world XZ (0.01 eps) and creating an edge only when both endpoints
exist — an edge into an unloaded neighbour appears when that neighbour loads.
- On page unload, the page's nodes and their edges are extracted back to the file.
- Legacy scenes with an inline `roadGraph` migrate to region files automatically on
first sync; the scene JSON then keeps only `roadConfig`.
- Saving the scene flushes the region store first
(`EditorUISystem::saveScene``RoadSystem::flushRegionStore()`).
Per-page road meshes, colliders and roadside prefabs were already page-scoped; they are
rebuilt only when a rebase-invariant content signature changes, and navmesh dirtying is
debounced (~15 quiet frames) so mass page transitions do not trigger rebuild storms.
## Editor navigation tools
- **Fly camera**: mouse-wheel zoom, Shift boost (×10), configurable speed
(`EditorCamera::setFlySpeed`, 1..100,000 units/s).
- **Tools → Navigation**: teleport to world X/Y/Z or to a page index (clamped to world
bounds, camera snapped above the terrain); named **bookmarks** stored in the scene
JSON `bookmarks` array.
- **Tools → World Map**: data-driven ImGui canvas (no RTT) showing coarse cached height
shading, loaded/unloaded pages, road polylines, prefab spawners, bookmarks and the
camera marker; drag to pan, wheel to zoom, double-click to teleport.
- `TerrainEditor` hosts a compact "Navigation" section with the same teleport backend.
## Serialization behaviour
- Scene JSON: terrain component settings, `roadConfig`, bookmarks, and all
non-streamed entities. When streaming, `roadNodes`/`roadEdges` and
`StreamedSpawnerTag` entities are **not** written — they live in the region stores.
- Region stores are written immediately on edit (spawners), on page unload, and on
scene save (roads).
- Loading an old scene with inline roads/spawners migrates them into region files on
first sync/save.
## Lua API coordinates
`lua/LuaTerrainApi.hpp` documents the coordinate space per function:
`terrain.sculpt` / `terrain.paintAux` take visual world coordinates,
`terrain.paint` converts world → render internally (rebase-safe), and
`terrain.sampleAux` takes physical heightmap coordinates.
## Testing
Headless integration suite (no display needed):
```bash
cd build-vscode/src/features/editScene
./editSceneEditor --headless --run-terrain-tests=1 # full suite
TERRAIN_TEST_FILTER=roadRegion ./editSceneEditor --headless --run-terrain-tests=1
```
Covers: streaming window follow/unload, procedural determinism, fixup persistence,
rebase stability and round-trip, bookmarks, world-map transforms, spawner region
round-trip and window lifecycle, road region store/streaming/migration. Success line:
`TERRAIN TESTS: ALL 1 ITERATIONS PASSED`.
## Known limitations
- The world is **bounded**, not looped: page indices and teleports clamp to
`[0, N-1]`. Wrap-around was explicitly scoped out (see `TerrainImprovment2.md`).
- Synchronous amortized loading can briefly lag behind very fast camera movement.
- Roads into an unloaded neighbour page render only once that page loads (by design).
@@ -0,0 +1,204 @@
# Terrain Improvement Plan 2 — Streamed 40M×40M World (with status)
Status legend: **[DONE]** implemented and verified, **[PARTIAL]** implemented with
deviations, **[OPEN]** not implemented.
Overall status: **all milestones complete** (verified 2026-09: build clean,
`TERRAIN TESTS: ALL 1 ITERATIONS PASSED`, `component_lua_test` 63/63).
## Goal
Convert `src/features/editScene` terrain from a fixed 3×3-page, always-loaded grid into a
camera-streamed, bounded 40,000,000 × 40,000,000-unit world with:
- Terrain page streaming — pages load/unload around the camera.
- Procedural base terrain + sparse persisted edits.
- Rendering origin rebasing — world coordinates stay `double`; rendering floats never see
huge values. Jolt runs with `JPH_DOUBLE_PRECISION`, so physics stays in world space.
- Editor navigation tools — teleport, world map with info layers, bookmarks, speed-scaled
fly camera.
- Terrain-attached prefab streaming — spawner definitions stored per region.
- Road streaming — road graph stored per region, loaded around the camera.
## Confirmed decisions
- Bounded world, **no wrap-around**. (The original request mentioned a looped world; this
was consciously scoped out. See "Open items" below.)
- Base terrain = procedural + sparse edits (no giant heightmap file).
- Precision = origin rebasing for rendering only; physics uses doubles already.
- All four editor tools wanted; world map must show info layers (prefabs, roads, pages).
## Architecture
### Coordinate model
- **World space**: `double`, authoritative. World bounds `[0, 40,000,000]` both axes →
pages `[0, 19999]` at the default 2000-unit page size (clamped to 32768 for
TerrainGroup's signed 16-bit slot packing).
- **Render space**: float, camera-centric. `RenderOriginSystem` owns a `JPH::DVec3`
origin; `render = world - origin`. Rebase when the camera moves further than 8192
render units from the origin: the origin snaps to integer world coordinates near the
camera and every render-space position shifts by the exact negated delta.
- **Physics space**: Jolt `DVec3`/`RVec3` in absolute world coordinates.
- **Physical heightmap space**: physical X shifted by half a page, physical Z mirrored
within each page; convert with `TerrainSystem::visualToPhysicalX/Z` /
`physicalToVisualX/Z`. Note: the Ogre TerrainGroup slot for physical page `(x, y)` is
`(x, -y)` because `ALIGN_X_Z` negates Z.
### Region/window model for content streaming
- Terrain pages stream in a window around the camera's page (`pageLoadRadius` = 2,
`pageHoldRadius` = 3 by default, configurable in `TerrainComponent`), synchronously,
amortized (2 loads + 4 unloads per frame) to avoid hitches and the WorkQueue shutdown
hangs seen with Ogre background loading.
- Sidecar content (prefab spawners, road graph) is stored **per page** under
`heightmaps/<terrainId>/spawners/<px>_<py>.json` and
`heightmaps/<terrainId>/roads/<px>_<py>.json` (the plan originally considered coarser
8×8-page regions; per-page files proved simpler and match the terrain page lifecycle).
---
## Milestones
### M1 — Terrain page streaming core — [DONE]
- `TerrainComponent` gained `streamingEnabled` (default false; opt-in per terrain),
`worldSizeUnits` (default 40,000,000), `pageLoadRadius`/`pageHoldRadius` (2/3),
`farClipDistance` (6000), linear fog (`fogEnabled`, 2500→5500) and `baseNoise`
(FastNoiseLite config). All serialized in `SceneSerializer`.
- Base heights are procedural-on-demand from `baseNoise` (double precision);
`setHeightAt`/sculpting write through the sparse per-page fixup chunk layer
(LRU-capped, save-on-evict). A legacy `heightmap.bin` keeps working as an optional
base patch for old scenes.
- `TerrainSystem::updateStreamingWindow()` loads/unloads pages around the camera page
each frame; old `m_pageMin/Max` full-grid loops were converted to iterate loaded
slots; blend/aux maps are per-page files under `heightmaps/<terrainId>/`.
- `getHeightAt`/`raycastTerrain` fall back to the analytic height path for unloaded
(far) pages.
- Camera far clip + fog come from `TerrainComponent` so pop-in is hidden at the hold
radius.
- Tests: page window follows camera, unload frees slots, procedural determinism across
reload, fixup persistence across unload/reload; legacy assumptions adapted.
### M2 — Rendering origin rebasing + double world positions — [DONE]
- New `systems/RenderOriginSystem.{hpp,cpp}` (singleton): `worldToRender(x,y,z)`,
`renderToWorld(Ogre::Vector3) -> JPH::DVec3`, 8192-unit rebase threshold; runs before
all other systems each frame.
- `TransformComponent` carries authoritative double `worldX/Y/Z` alongside the float
node-local position; serialization writes the doubles (float kept for legacy load).
- Physics wrapper gained `JPH::RVec3` overloads so world-space doubles flow without a
float round-trip; terrain page colliders are created at page-index-computed world
positions.
- `TerrainSystem` exposes `worldToRender`, `renderToWorldX/Y/Z`, `isPageLoaded(x,y)`
(physical page coords), `streamingCameraPage`, `getTerrainEntityId`, `getStreamingActive`,
`getStreamingPageCount`, `snapCameraAboveTerrain`.
- Tests: rebase stability (geometry shifts by exactly the origin delta),
world→render→world round-trip, far-corner teleport renders correct terrain.
### M3 — Editor navigation tools — [DONE]
- `EditorCamera`: mouse-wheel zoom (`handleMouseWheel`), Shift boost (×10), configurable
fly speed (`setFlySpeed`, 1..100,000 units/s).
- **Tools → Navigation** (`ui/NavigationPanel.hpp`, header-only): teleport by world
X/Y/Z or by page index (clamped to world bounds); named bookmarks
(`ui/WorldBookmark.hpp`) persisted in the scene JSON top-level `bookmarks` array
(tolerated when missing on load).
- **Tools → World Map** (`ui/WorldMapPanel.hpp` + `systems/WorldMapData.hpp`): ImGui
2D canvas (data-driven, not RTT) with coarse cached height shading, loaded/unloaded
page overlay, road polylines, prefab-spawner points, bookmarks, camera marker;
drag pan, wheel zoom, double-click teleport.
- `TerrainEditor` has a compact "Navigation" section sharing the same teleport backend.
- Tests: bookmark round-trip, world↔map transforms at extremes, navigation teleport.
### M4 — Terrain-attached prefab streaming — [DONE]
- New `systems/SpawnerRegionStore.{hpp,cpp}`: `SpawnerRegionDef` (prefabPath, world-space
double position, spawn/despawn distances, per-page id) in
`heightmaps/<terrainId>/spawners/<px>_<py>.json`, saved immediately on edit.
- `TerrainPrefabSpawnerSystem::syncStreamedSpawners()` polls
`TerrainGroup::getTerrainSlots()`: creates region spawner entities on page load
(tagged `StreamedSpawnerTag{pageX,pageY,defId}`), destroys them and writes edits back
(including page-boundary crossings) on unload. Existing distance-based spawn/despawn
logic is unchanged.
- Editor click-to-place (`createSpawnPoint`) writes to the region store when streaming;
scene-embedded spawner entities remain as "always-loaded" spawners for
small/non-streaming scenes.
- `SceneSerializer` excludes `StreamedSpawnerTag` entities from scene JSON and caches
parsed prefab JSON (`loadPrefabJsonCached`/`invalidatePrefabJsonCache`, invalidated on
prefab save/delete).
- Tests: region store round-trip, streamed spawner window lifecycle, prefab JSON cache.
### M5 — Road streaming — [DONE]
- New `systems/RoadRegionStore.{hpp,cpp}`: per-page files
`heightmaps/<terrainId>/roads/<px>_<py>.json` with nodes as absolute world-space
doubles and edges referencing per-file node ids (full lane/level/prefab-slot data).
- Membership rule: a node belongs to the page containing its position; an edge is stored
in the region file of **each** endpoint page (foreign endpoint repeated inline), so a
page file is self-contained. Merge matches nodes by world XZ (0.01 eps); an edge is
created only when both endpoints exist in the active graph; `hasEdge` prevents
duplicates.
- `RoadSystem::syncRegions()` runs each frame: merges region files of newly loaded pages,
extracts departing pages back to disk (two-phase: all departing pages are written
before any node is removed, because `removeNode` cascades incident edges).
- One-time migration: a non-empty inline `roadGraph` (legacy scene JSON) is partitioned
into region files on first sync; `SceneSerializer::serializeTerrain` then skips
`roadNodes`/`roadEdges` when `streamingEnabled` (keeps `roadConfig`).
- `EditorUISystem::saveScene` calls `RoadSystem::flushRegionStore()` before writing.
- Page mesh rebuilds are gated by a rebase-invariant FNV-1a content signature (world
positions quantized to cm) instead of re-dirtying all pages on every graph change;
navmesh dirtying is debounced (~15 quiet frames) while streaming.
- `TerrainSystem::onRenderOriginChanged` forwards to `RoadSystem::onRenderOriginChanged`
(shifts render-space node positions, bumps version; region files untouched).
- Notable fix found by tests: physical page assignment on the mirrored Z axis needs a
small epsilon (`floor((visualToPhysicalZ(wz) - 1e-6) / ws)`) so boundary nodes land in
the same page `connectNodes`' round-half rule picks.
- Tests: `roadRegionStore`, `roadRegionStreaming` (incl. cross-page `connectNodes` split,
rebase invariance, unload/reload remerge), `roadRegionMigration`.
### M6 — Docs, examples, cleanup — [DONE]
- Root `AGENTS.md`: "Road Region Streaming (M5)" + "Terrain Streaming Architecture"
sections (coordinate spaces, streaming window, region storage layout, navigation
tools, streamed vs always-loaded spawners).
- `src/features/editScene/AGENTS.md`: streaming paragraphs for
`TerrainPrefabSpawnerSystem` and `RoadSystem`, a "Terrain Streaming & Navigation"
section, and new caveats (physical page vs Ogre slot `(x,-y)`; never store world
positions in floats; streamed content lives in region stores; Lua coordinate
expectations).
- `TerrainRequirements.md`: stale "all pages loaded" / fixed-grid statements corrected.
- `lua/LuaTerrainApi`: per-function coordinate spaces documented in the header;
`terrain.paint` fixed to convert world→render (it painted at the wrong spot after a
rebase); `terrain.sculpt`/`paintAux` take visual world coords, `sampleAux` takes
physical coords.
- CMake: `prefabs/` is now copied next to the runtime resources (mirrors the existing
`tests/prefabs` copy), fixing the missing runtime prefab directory.
- Stale comments in `RoadSystem.hpp` / `TerrainSystem.hpp` / `RenderOriginSystem.hpp`
brought in line with the final behaviour.
---
## Verification
After every milestone and at completion:
- `cmake --build build-vscode --target editSceneEditor -j4` — clean.
- `cd build-vscode/src/features/editScene && ./editSceneEditor --headless --run-terrain-tests=1`
`TERRAIN TESTS: ALL 1 ITERATIONS PASSED` (single steps via `TERRAIN_TEST_FILTER=<substr>`).
- `./component_lua_test` — 63/63 passed.
## Open items / known limitations
- **Looped (wrap-around) world**: requested but explicitly scoped out; the world is
bounded and page indices/teleports clamp to `[0, N-1]`. Implementing a toroidal world
(wrapping streaming window, render origin, region stores, minimap) is a separate
feature.
- Page loads/unloads are synchronous and amortized (2+4 per frame); very fast camera
movement can outrun the window briefly. Ogre's background WorkQueue was deliberately
avoided (shutdown hangs).
- `connectNodes` boundary splitting uses render-space round-half math and can misplace
splits after a non-page-aligned rebase (nodes re-merge by position, so this is
cosmetic at worst).
- Roads crossing into an unloaded neighbour page appear only when the second page loads
(by design; both-files rule).
+126 -23
View File
@@ -188,8 +188,15 @@ std::unordered_map<uint64_t, TerrainCollider> mColliders;
`TerrainPaging`, `PagedWorld`, `TerrainPagedWorldSection`.
- Configure default `Ogre::Terrain::ImportData` from component values.
- Set the custom `CustomTerrainDefiner`.
- Call `loadAllTerrains(true)` for editor-mode synchronous setup, or rely on
paging for game mode.
- In legacy (non-streaming) mode: define every page in the configured
range and call `loadAllTerrains(true)` for editor-mode synchronous
setup (the camera is not attached to `PageManager`; the paging
objects are present so runtime game mode can attach one). With
`TerrainComponent::streamingEnabled` only the initial load window
around the camera is defined/loaded; `updateStreamingWindow()`
streams further pages in/out as the camera moves (see
`TerrainSystem::updateStreamingWindow` and the streaming section of
`AGENTS.md`).
3. **Deactivation / scene clear**
- Disable paging operations (`PageManager::setPagingOperationsEnabled(false)`)
@@ -238,8 +245,8 @@ Ogre's `WorkQueue`. The following rules prevent crashes, leaks, and use-after-fr
- **Do not drive paging from a worker thread in the editor.** In editor mode
the camera is intentionally **not** added to `PageManager`;
pages are loaded synchronously through `TerrainGroup::loadAllTerrains(
true)`.This prevents
pages are loaded synchronously — the initial set through `TerrainGroup::loadAllTerrains(
true)` (non-streaming) or the load window around the camera plus `loadTerrain(x, y, true)` per streamed page (streaming mode). This prevents
`CustomTerrainDefiner::define()` from being called on a
background thread and touching GL
/ ECS state unsafely.-
@@ -628,6 +635,12 @@ Use `EShapeSubType::User1` for the shape subtype.
at component creation (e.g. `std::random_device{}() | (uint64_t)time(0) << 32`),
serialized with the component, and survives save/reload cycles unchanged.
- **Default size**: 256x256.
- **Streaming mode**: with `TerrainComponent::streamingEnabled` the legacy
heightmap buffer is not the base-height source; base heights come from
on-demand procedural evaluation of `TerrainComponent::baseNoise`
(FastNoiseLite OpenSimplex2, double precision) and pages stream in/out in a
`pageLoadRadius`/`pageHoldRadius` window around the camera. Edits write
through the fixup chunks (4.2).
- Editable in the terrain editor with elevation and curve brushes.
- A fresh empty heightmap initializes all samples to `0.0f`.
@@ -1196,10 +1209,13 @@ target_link_libraries(editSceneEditor PUBLIC
- CMake (`OgrePaging`/`OgreTerrain` links) and `EditorApp` wiring.
- Component registration in `setupECS()` + editor module.
Definition of done: a paged terrain renders in the editor. In the current
editor mode the camera is not attached to `PageManager`, so all pages in the
configured range are loaded synchronously; the paging objects are present and
ready for runtime game mode where a camera can be attached.
Definition of done: a paged terrain renders in the editor. In legacy
(non-streaming) mode the camera is not attached to `PageManager`, so all pages
in the configured range are loaded synchronously; the paging objects are
present and ready for runtime game mode where a camera can be attached. With
`streamingEnabled` (added later) only the load window around the camera is
defined up front and `updateStreamingWindow()` streams pages in/out as the
camera moves.
### Milestone 2 — Physics integration ✅ DONE
@@ -2158,7 +2174,7 @@ and remain pending.
| M5.8 Mesh assembly per page | ✅ complete | page entities with `TriangleBufferComponent(proceduralContent)` + `RenderableComponent` + `NavMeshGeometrySource` + `LodComponent`, `roadPageMeshes` test |
| M5.9 Road physics colliders | ✅ complete | `createPageCollider`/`destroyPageCollider` in `RoadSystem`, asserted in `roadPageMeshes` |
| M5.9.5 Fixup chunk support | ✅ DONE (2026-07-29; grid corrected 2026-08-16) | `writeFixup`/`writeFixupSample`/`saveFixups`/`clearAllFixups`/`sampleFixupLocked` implemented in `TerrainSystem.cpp`; wired into `sampleHeightAtLocked`; "Clear All Fixups" UI button; chunk span corrected to the full per-page `worldSize` (see 4.2); `fixupChunks` test green |
| M5.10 Terrain compliance | ✅ DONE (2026-07-29; falloff added 2026-08-16) | `RoadSystem::complyTerrain()` walks road wedges/segments, writes fixup under each top-surface vertex, then fades over `laneWidth * 2` outside each curb (`writeComplianceFalloff`, per-sample writes); "Comply Terrain to Roads" button wired; `terrainCompliance` test green |
| M5.10 Terrain compliance | ✅ DONE (2026-07-29; falloff added 2026-08-16; clearance-hardened 2026-08-22; constraint-solver rework 2026-08-30) | `RoadSystem::complyTerrain()` collects road top-surface triangles and solves "rendered terrain stays 0.05 below the road top" as linear constraints on the rendered page-lattice vertices (actual per-row triangulation diagonal), with damped Kaczmarz lower-only sweeps plus a raise-only relaxation so the roadbed follows curved roads without phantom-diagonal over-excavation; per-vertex lowering is written via `TerrainSystem::lowerFixupCorners()` (sentinel corners materialize at the current surface, preserving the untouched surface exactly); "Comply Terrain to Roads" button wired; inverse `RoadSystem::complyRoadsToTerrain()` ("Comply Roads to Terrain") lifts/sinks nodes so edges stay `roadThickness + 0.05` above the terrain (constraints sampled against the rendered lattice surface, filtered to the actual road XZ footprint, solved with Kaczmarz deficit distribution + alternating lower relaxation and pairwise rebalancing); `terrainCompliance`, `terrainComplianceClearance` and `complyRoadsToTerrain` tests green |
| M5.11 Roadside prefab spawning | ✅ DONE (2026-07-30; respawn fix 2026-08-16) | `RoadSystem::spawnSidePrefabs()` creates instances via `PrefabSystem` at edge positions with terrain-snapped Y; tracked and destroyed on page unload AND on mesh rebuild; spawned instances are stripped of `EditorMarkerComponent` so they stay runtime-only; `roadSidePrefabs` test green |
| M5.12 Serialization + wiring | ✅ complete | serialization round-trip for roadConfig/nodes/edges/sidePrefabs; lifecycle + page detection + mesh/collider/prefab creation through M5.8/M5.9/M5.11; fixup save wired into SceneSerializer |
@@ -2507,8 +2523,10 @@ default `LESS` depth test) but double the mesh memory, draw calls, collider
bodies, and navmesh input. The `roadVisibilityDistance` expansion is kept only
for the page world AABB used in navmesh dirty marking (M5.8). Trade-off
accepted: a wedge disappears if its seed page unloads while a neighboring page
stays loaded — in the editor all pages are loaded synchronously, so this only
matters for future runtime paging.
stays loaded — with `streamingEnabled` pages genuinely stream in/out around
the camera, so this trade-off is live (the hold radius keeps a margin of
loaded pages beyond the load radius to limit it); in legacy mode all pages
are loaded synchronously and it never triggers.
**Navigation mesh input**: Road surfaces are walkable, so every generated road
page entity must be discoverable by `NavMeshSystem`. The `TriangleBufferComponent`
@@ -2970,23 +2988,108 @@ writers yet.
#### M5.10 Terrain compliance (conform, not flatten)
**Status (2026-08-16): ✅ DONE.** `RoadSystem::complyTerrain()` walks every
wedge and straight segment, samples the top-surface vertices, writes
`roadSurfaceY - roadThickness` into the fixup chunk at each (X,Z), applies a
smooth perpendicular falloff over `laneWidth * 2`
(`writeComplianceFalloff()`, per-sample fade-target writes; the fade pass
runs before the full-compliance pass so slab-underside values win on shared
samples), then marks affected pages dirty and saves the fixups. The
"Comply Terrain to Roads" button in `TerrainEditor` is wired and functional.
(The 2026-07-29 ✅ was wrong — the falloff did not exist and the fixup grid
was too dense for page meshes to see the writes; both fixed 2026-08-16,
covered by the `terrainCompliance` headless test.)
**Status (2026-08-30): ✅ DONE, constraint-solver rework.**
`RoadSystem::complyTerrain()` collects the road top-surface triangles
and solves "rendered terrain stays `ROAD_COMPLIANCE_SAG` (0.05 m) below
the road top" as linear constraints on the rendered page-lattice vertex
heights (see the 2026-08-30 note below), then writes the per-vertex
lowering as fixups via `TerrainSystem::lowerFixupCorners()`, marks
affected pages dirty and saves the fixups. The "Comply Terrain to
Roads" button in `TerrainEditor` is wired and functional.
(Supersedes the 2026-08-16 per-texel slab-underside writes with
perpendicular falloff and the 2026-08-22 clamp/cap passes; the original
2026-07-29 ✅ was wrong — the falloff did not exist and the fixup grid
was too dense for page meshes to see the writes.)
**2026-08-22 clearance hardening** (covered by the
`terrainComplianceClearance` headless test, which replicates
`terrain2_test.json`): guarantees the rendered terrain never
rises above the road top.
**2026-08-30 constraint-solver rework** (supersedes the earlier
clamp/cap passes): compliance is now handled as an iterative
constraint-solving problem on the *rendered page-lattice* vertices —
one vertex per `worldSize/(terrainSize-1)` (e.g. 31.25 m), the only
heights the renderer interpolates between:
- *Constraint collection* (`collectComplianceConstraints`): every road
top-surface triangle (wedges, sidewalk wedges, segment and sidewalk
band quads) is clipped against each lattice cell it overlaps; each
candidate maximum point (cell-corner polygon vertices plus the
polygon-edge intersections with the cell's *actual* triangulation
diagonal — the renderer/collider zigzag by row parity, not both
diagonals) contributes one linear constraint on the 4 cell-corner
heights with barycentric weights and target `roadTop -
ROAD_COMPLIANCE_SAG` (0.05 m). Constraining the phantom second
diagonal fabricated violations up to metres high and caused massive
over-excavation far from the road.
- *Phase 1 — damped Kaczmarz lower-only sweeps*: each violated
constraint lowers its corners by the minimal-norm share of the excess
(shares proportional to the barycentric weights). Lowering can never
violate a constraint, so the sweeps converge to feasibility.
- *Phase 2 — raise-only relaxation*: every vertex is repeatedly set to
the highest value its constraints allow given the current neighbours
(capped at the natural height), recovering the hysteresis overshoot of
phase 1 (a vertex lowered while its neighbours were still high would
otherwise stay low) so the roadbed follows the road curvature instead
of collapsing flat to the lowest road nearby.
- *Fixup application* (`TerrainSystem::lowerFixupCorners`): the
per-vertex lowering is written as fixups on the 4 samples blending
into that lattice vertex; sentinel (unwritten) corners materialize at
the *current* surface height, so the post-write blend at the vertex
equals `natural - delta` exactly instead of snapping to texel-corner
naturals (which could even raise the terrain).
The footprint stays within the lattice cells touching the road (only
lowering past the natural height never happens elsewhere) instead of
flattening the whole corridor.
**Comply Roads to Terrain** (`RoadSystem::complyRoadsToTerrain()`, button
next to "Comply Terrain to Roads", covered by the `complyRoadsToTerrain`
headless test): the inverse operation — shifts road nodes vertically (down
or up) so every edge stays at least `roadThickness + 0.05` above the current
terrain. The road surface along a half-edge is linear between the node
surface and the edge midpoint, so every sampled terrain point yields one
linear constraint `coef*Yn + w*Ynb >= required` on the two endpoint node
heights. Constraints are sampled against `renderedHeightAt()` — the
rendered page-lattice surface evaluated with the actual per-row cell
triangulation diagonal the renderer/collider use — at dense 0.5 m
intervals along the centreline and both curb lines (extended backwards
over node wedges/end caps), at every lattice-line crossing of those
lines, and at every page vertex inside the road footprint (lanes plus
sidewalk only when enabled). Because the sampling rectangle around a
half-edge sticks out past the wedge fan / end cap behind a node (by up
to `halfWidth * sqrt(2)` at the corners), every sample is then filtered
against the actual XZ footprint of the road top surface (the same
wedge/band triangles `complyTerrain` collects, in a coarse lookup grid)
— terrain bumps outside the road mesh would otherwise lift nodes over
ground the road never covers. Connected nodes are initialized to
terrain + elevation and the constraint system is solved iteratively in
three phases: (1) Kaczmarz projection sweeps raise nodes to feasibility,
distributing each violated constraint's deficit between the node and its
neighbour along the constraint normal (minimum-norm correction) — a
per-node "raise to the max lower bound" update instead corners the
solution onto whichever node the update order hits first (a constraint
with a small neighbour coefficient is most cheaply satisfied by raising
the *other* node), leaving metres of hover over the far end; (2)
alternating damped lower-only relaxation (tightens nodes against the
terrain, clearing the slack the projection phase leaves on constraints
processed before their neighbours rose) and pairwise rebalancing (a
binding constraint with unequal coefficients is rebalanced by lowering
its small-coefficient node while raising the large-coefficient node to
compensate, which keeps the constraint satisfied and strictly reduces
the total node height — plain lowering alone sticks at such LP corners);
(3) a few raise-only passes clear the residual violations the
simultaneous damping of phase 2 can introduce. Node
`verticalOffset` values are re-derived from the terrain and the graph
version is bumped on change.
The terrain must hug the underside of the road: it should not poke through the
road surface and should not leave gaps beneath it. The road surface itself is
not required to be flat — it follows the node heights and edge slopes.
**Algorithm**:
**Algorithm** (original per-texel design, superseded 2026-08-30 by the
lattice constraint solver described in the rework note above):
1. For every point on the generated road surface (edge samples + wedge samples),
compute the world position `roadSurfacePos` of the road top.
+33 -1
View File
@@ -1,5 +1,7 @@
#include "EditorCamera.hpp"
#include <OgreViewport.h>
#include <cmath>
#include <algorithm>
EditorCamera::EditorCamera(Ogre::SceneManager *sceneMgr,
Ogre::RenderWindow *window)
@@ -102,7 +104,8 @@ void EditorCamera::updateFPSMovement(float deltaTime)
// Apply movement
if (movement.squaredLength() > 0.0001f) {
movement.normalise();
m_target += movement * FPS_SPEED * deltaTime;
float speed = m_flySpeed * (m_keyShift ? SHIFT_BOOST : 1.0f);
m_target += movement * speed * deltaTime;
m_targetNode->setPosition(m_target);
m_cameraMan->setTarget(m_targetNode);
}
@@ -163,6 +166,7 @@ void EditorCamera::handleMouseRelease(const OgreBites::MouseButtonEvent &evt)
false; // Disable FPS mode when right mouse is released
// Reset key states
m_keyW = m_keyS = m_keyA = m_keyD = m_keyQ = m_keyE = false;
m_keyShift = false;
} else if (evt.button == OgreBites::BUTTON_MIDDLE) {
m_panning = false;
}
@@ -198,9 +202,28 @@ void EditorCamera::handleKeyboard(const OgreBites::KeyboardEvent &evt)
case 'E':
m_keyE = pressed;
break;
case OgreBites::SDLK_LSHIFT:
m_keyShift = pressed;
break;
}
}
void EditorCamera::handleMouseWheel(float y)
{
if (y == 0.0f)
return;
m_distance *= std::pow(0.85f, y);
if (m_distance < 0.05f)
m_distance = 0.05f;
if (m_distance > 500000.0f)
m_distance = 500000.0f;
}
void EditorCamera::setFlySpeed(float s)
{
m_flySpeed = std::max(1.0f, std::min(100000.0f, s));
}
void EditorCamera::focusOn(const Ogre::Vector3 &point)
{
m_target = point;
@@ -215,6 +238,15 @@ void EditorCamera::setPosition(const Ogre::Vector3 &pos)
m_cameraMan->setTarget(m_targetNode);
}
void EditorCamera::shiftPosition(const Ogre::Vector3 &offset)
{
m_position += offset;
m_target += offset;
m_targetNode->setPosition(m_target);
m_cameraNode->setPosition(m_cameraNode->getPosition() + offset);
m_cameraMan->setTarget(m_targetNode);
}
Ogre::Ray EditorCamera::getMouseRay(float screenX, float screenY) const
{
// Convert pixel coordinates to normalized viewport coordinates (0-1)
+40 -1
View File
@@ -49,6 +49,43 @@ public:
*/
void setPosition(const Ogre::Vector3 &pos);
/**
* Shift camera and target by a render-space offset (used by
* RenderOriginSystem on rebase)
*/
void shiftPosition(const Ogre::Vector3 &offset);
/**
* Orbit target (render space)
*/
Ogre::Vector3 getTarget() const
{
return m_target;
}
/**
* Orbit distance
*/
float getDistance() const
{
return m_distance;
}
/**
* Mouse wheel: zoom the orbit distance (multiplicative).
*/
void handleMouseWheel(float y);
/**
* Fly (FPS-mode) speed in units/second; Shift boosts x10.
* Range 1 .. 100000 (100 km/s) for streamed-world navigation.
*/
float getFlySpeed() const
{
return m_flySpeed;
}
void setFlySpeed(float s);
/**
* Get camera position
*/
@@ -103,11 +140,13 @@ private:
bool m_keyD;
bool m_keyQ;
bool m_keyE;
bool m_keyShift = false;
// Movement speeds
static constexpr float ROTATION_SPEED = 0.3f;
static constexpr float PAN_SPEED = 0.01f;
static constexpr float FPS_SPEED = 10.0f;
float m_flySpeed = 10.0f; // units/second, see setFlySpeed
static constexpr float SHIFT_BOOST = 10.0f;
};
#endif // EDITSCENE_EDITORCAMERA_HPP
@@ -73,6 +73,45 @@ struct TerrainComponent {
};
DetailNoise detailNoise;
/* --- Streaming / far-distance rendering (world streaming
* groundwork) ---
*
* streamingEnabled switches the base-height source from the single
* legacy heightmap buffer to on-demand procedural evaluation of
* baseNoise over the whole bounded world. Page streaming itself is
* NOT implemented yet: the fixed 3x3 page grid is still loaded; only
* the height source and the far-fallback paths change. */
bool streamingEnabled = false;
// Total bounded world extent per axis, in world units.
double worldSizeUnits = 40000000.0;
// Page streaming window radii (pages). Reserved for the streaming
// window task; currently unused.
int pageLoadRadius = 2;
int pageHoldRadius = 3;
// Camera far clip applied while the terrain is active.
float farClipDistance = 6000.0f;
// Linear fog applied while the terrain is active.
bool fogEnabled = true;
float fogStart = 2500.0f;
float fogEnd = 5500.0f;
// Procedural base terrain shape (streaming mode only). Evaluated
// on demand at any world coordinate via FastNoiseLite; replaces the
// legacy heightmap buffer when streamingEnabled is true.
struct BaseNoise {
int seed = 1337;
int octaves = 4;
float frequency = 0.0005f;
float amplitude = 40.0f;
float lacunarity = 2.0f;
float persistence = 0.5f;
};
BaseNoise baseNoise;
// Auxiliary maps (foliage density, material masks, etc.).
struct AuxMap {
std::string name;
@@ -39,4 +39,20 @@ struct TerrainPrefabSpawnerComponent {
flecs::entity_t spawnedEntity = 0;
};
/**
* Marks a spawner entity as owned by the streamed-region store (M4).
*
* Such entities are created/destroyed by TerrainPrefabSpawnerSystem as
* terrain pages stream in/out; their authoritative data lives in the
* SpawnerRegionStore page files (heightmaps/<terrainId>/spawners/), so
* SceneSerializer must NOT write them into the scene JSON. The tag
* also identifies the def for edit write-back (move/delete/property
* edits in the editor update the region store).
*/
struct StreamedSpawnerTag {
long pageX = 0;
long pageY = 0;
uint64_t defId = 0;
};
#endif // EDITSCENE_TERRAINPREFABSPAWNER_HPP
@@ -12,7 +12,21 @@ struct TransformComponent {
Ogre::Vector3 position = Ogre::Vector3::ZERO;
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
Ogre::Vector3 scale = Ogre::Vector3::UNIT_SCALE;
/* Authoritative double-precision world position (absolute world
* space, independent of the render origin — see
* systems/RenderOriginSystem). Only meaningful when
* hasWorldPosition is true; legacy scenes leave it false and rely
* on the float `position` (which then doubles as the world
* position because the render origin starts at 0). For root-level
* entities `position` stays the render-space node-local value
* (world - renderOrigin); on rebase RenderOriginSystem recomputes
* it exactly from these doubles. */
double worldX = 0.0;
double worldY = 0.0;
double worldZ = 0.0;
bool hasWorldPosition = false;
// Version tracking for change detection
unsigned int version = 0;
+5 -1
View File
@@ -111,7 +111,11 @@ static int luaTerrainPaint(lua_State *L)
if (lua_isinteger(L, 5))
ts->setPaintLayerIndex((int)lua_tointeger(L, 5));
ts->applySplatBrush(Ogre::Vector3(x, 0.0f, z));
/* applySplatBrush works in RENDER space (page-local blend-map
* mapping through the TerrainGroup origin), so convert the visual
* world coordinates the API advertises. */
Ogre::Vector3 rpos = ts->worldToRender((double)x, 0.0, (double)z);
ts->applySplatBrush(rpos);
lua_pushboolean(L, 1);
return 1;
}
@@ -25,6 +25,15 @@ namespace editScene
* terrain.setAuxPaintMap(name)
* terrain.saveHeightmap(), terrain.saveBlendMaps(), terrain.saveAuxMaps()
* terrain.loadHeightmap(), terrain.loadBlendMaps(), terrain.loadAuxMaps()
*
* COORDINATE SPACES (M2): sculpt/paint/paintAux take absolute visual
* WORLD coordinates (double-precision-capable, render-origin
* independent — the same space NavigationPanel teleports and world
* bookmarks use); each binding converts internally
* (world->physical for sculpt/paintAux via visualToPhysicalX/Z,
* world->render for paint via worldToRender). sampleAux takes
* PHYSICAL heightmap coordinates instead (what sampleAuxMap expects);
* convert a world position with visualToPhysicalX/Z first if needed.
*/
void registerLuaTerrainApi(lua_State *L);
+59 -3
View File
@@ -962,10 +962,21 @@ public:
const Ogre::Quaternion &rotation,
JPH::EMotionType motion, JPH::ObjectLayer layer,
ActivationListener *listener = nullptr)
{
return createBody(shape, mass, JoltPhysics::convert(position),
rotation, motion, layer, listener);
}
/* World-space (double precision) overload — absolute world
* positions must not round-trip through float (M2). */
JPH::BodyID createBody(const JPH::Shape *shape, float mass,
const JPH::RVec3 &position,
const Ogre::Quaternion &rotation,
JPH::EMotionType motion, JPH::ObjectLayer layer,
ActivationListener *listener = nullptr)
{
JPH::BodyCreationSettings bodySettings(
shape, JoltPhysics::convert(position),
JoltPhysics::convert(rotation), motion, layer);
shape, position, JoltPhysics::convert(rotation),
motion, layer);
if (mass > 0.001f) {
JPH::MassProperties msp;
msp.ScaleToMass(mass);
@@ -1562,9 +1573,15 @@ public:
}
void setPosition(JPH::BodyID id, const Ogre::Vector3 &position,
bool activate = true)
{
setPosition(id, JoltPhysics::convert(position), activate);
}
/* World-space (double precision) overload, see createBody. */
void setPosition(JPH::BodyID id, const JPH::RVec3 &position,
bool activate = true)
{
physics_system.GetBodyInterface().SetPosition(
id, JoltPhysics::convert(position),
id, position,
activate ? JPH::EActivation::Activate :
JPH::EActivation::DontActivate);
}
@@ -1665,6 +1682,24 @@ public:
}
return hadHit;
}
/* World-space (double precision) overload, see createBody. The
* hit position is returned in world space as well. */
bool raycastQuery(const JPH::RVec3 &startPoint,
const JPH::RVec3 &endPoint, JPH::RVec3 &position,
JPH::BodyID &id)
{
JPH::Vec3 direction = JPH::Vec3(endPoint - startPoint);
JPH::RRayCast ray{ startPoint, direction };
JPH::RayCastResult hit;
bool hadHit = physics_system.GetNarrowPhaseQuery().CastRay(
ray, hit, {},
JPH::SpecifiedObjectLayerFilter(Layers::NON_MOVING));
if (hadHit) {
position = ray.GetPointOnRay(hit.mFraction);
id = hit.mBodyID;
}
return hadHit;
}
bool bodyIsCharacter(JPH::BodyID id) const
{
return characterBodies.find(id) != characterBodies.end();
@@ -1848,6 +1883,14 @@ JPH::BodyID JoltPhysicsWrapper::createBody(const JPH::Shape *shape, float mass,
{
return phys->createBody(shape, mass, position, rotation, motion, layer);
}
JPH::BodyID JoltPhysicsWrapper::createBody(const JPH::Shape *shape, float mass,
const JPH::RVec3 &position,
const Ogre::Quaternion &rotation,
JPH::EMotionType motion,
JPH::ObjectLayer layer)
{
return phys->createBody(shape, mass, position, rotation, motion, layer);
}
JPH::BodyID JoltPhysicsWrapper::createBody(const JPH::Shape *shape, float mass,
Ogre::SceneNode *node,
JPH::EMotionType motion,
@@ -1956,6 +1999,12 @@ void JoltPhysicsWrapper::setPosition(JPH::BodyID id,
{
return phys->setPosition(id, position, activate);
}
void JoltPhysicsWrapper::setPosition(JPH::BodyID id,
const JPH::RVec3 &position,
bool activate)
{
return phys->setPosition(id, position, activate);
}
Ogre::Quaternion JoltPhysicsWrapper::getRotation(JPH::BodyID id)
{
return phys->getRotation(id);
@@ -2033,6 +2082,13 @@ bool JoltPhysicsWrapper::raycastQuery(Ogre::Vector3 startPoint,
return phys->raycastQuery(startPoint, endPoint, position, id);
}
bool JoltPhysicsWrapper::raycastQuery(const JPH::RVec3 &startPoint,
const JPH::RVec3 &endPoint,
JPH::RVec3 &position, JPH::BodyID &id)
{
return phys->raycastQuery(startPoint, endPoint, position, id);
}
bool JoltPhysicsWrapper::bodyIsCharacter(JPH::BodyID id) const
{
return phys->bodyIsCharacter(id);
+15
View File
@@ -163,6 +163,13 @@ public:
const Ogre::Vector3 &position,
const Ogre::Quaternion &rotation,
JPH::EMotionType motion, JPH::ObjectLayer layer);
/* World-space (double precision) overload — use when the position
* is an absolute world coordinate that must not round-trip
* through float (render origin rebasing, M2). */
JPH::BodyID createBody(const JPH::Shape *shape, float mass,
const JPH::RVec3 &position,
const Ogre::Quaternion &rotation,
JPH::EMotionType motion, JPH::ObjectLayer layer);
JPH::BodyID createBody(const JPH::Shape *shape, float mass,
Ogre::SceneNode *node, JPH::EMotionType motion,
JPH::ObjectLayer layer);
@@ -209,6 +216,9 @@ public:
Ogre::Vector3 getPosition(JPH::BodyID id);
void setPosition(JPH::BodyID id, const Ogre::Vector3 &position,
bool activate = true);
/* World-space (double precision) overload, see createBody. */
void setPosition(JPH::BodyID id, const JPH::RVec3 &position,
bool activate = true);
Ogre::Quaternion getRotation(JPH::BodyID id);
void setRotation(JPH::BodyID id, const Ogre::Quaternion &rotation,
bool activate = true);
@@ -237,6 +247,11 @@ public:
void removeContactListener(const JPH::BodyID &id);
bool raycastQuery(Ogre::Vector3 startPoint, Ogre::Vector3 endPoint,
Ogre::Vector3 &position, JPH::BodyID &id);
/* World-space (double precision) overload, see createBody. The
* hit position is returned in world space as well. */
bool raycastQuery(const JPH::RVec3 &startPoint,
const JPH::RVec3 &endPoint, JPH::RVec3 &position,
JPH::BodyID &id);
bool bodyIsCharacter(JPH::BodyID id) const;
void destroyCharacter(std::shared_ptr<JPH::Character> ch);
Ogre::SceneNode *getSceneNodeFromBodyID(JPH::BodyID id) const;
@@ -84,6 +84,9 @@ EditorUISystem::EditorUISystem(flecs::world &world,
m_characterRegistry.setSceneManager(m_sceneMgr);
m_characterRegistry.setEditorUISystem(this);
m_characterRegistry.initialize();
m_worldMapPanel.setWorld(&m_world);
m_worldMapPanel.setBookmarks(&m_navigationPanel.getBookmarks());
}
EditorUISystem::~EditorUISystem() = default;
@@ -341,28 +344,24 @@ void EditorUISystem::update(float deltaTime)
hit);
} else if (
ts->isAuxPainting()) {
Ogre::Vector3
brushPos =
hit;
brushPos.x = ts->visualToPhysicalX(
hit.x);
brushPos.z = ts->visualToPhysicalZ(
hit.z);
ts->applyAuxBrush(
ts->applyAuxBrushPhysical(
ts->getAuxPaintMapName(),
brushPos,
ts->visualToPhysicalX(
ts->renderToWorldX(
hit.x)),
ts->visualToPhysicalZ(
ts->renderToWorldZ(
hit.z)),
ts->getPaintRadius(),
ts->getPaintStrength());
} else {
Ogre::Vector3
brushPos =
hit;
brushPos.x = ts->visualToPhysicalX(
hit.x);
brushPos.z = ts->visualToPhysicalZ(
hit.z);
ts->applySculptBrush(
brushPos);
ts->applySculptBrushPhysical(
ts->visualToPhysicalX(
ts->renderToWorldX(
hit.x)),
ts->visualToPhysicalZ(
ts->renderToWorldZ(
hit.z)));
}
}
} else {
@@ -406,6 +405,16 @@ void EditorUISystem::update(float deltaTime)
renderPrefabBrowser();
renderCursorPanel();
// Render Navigation panel (teleport + world bookmarks)
if (m_showNavigation) {
m_navigationPanel.render(&m_showNavigation);
}
// Render World Map panel
if (m_showWorldMap) {
m_worldMapPanel.render(&m_showWorldMap);
}
// Render Action Database singleton editor window
if (m_showActionDatabaseSingleton) {
m_actionDatabaseSingletonEditor.render(
@@ -527,6 +536,12 @@ void EditorUISystem::renderHierarchyWindow()
if (ImGui::MenuItem("3D Cursor")) {
m_showCursorPanel = true;
}
if (ImGui::MenuItem("Navigation")) {
m_showNavigation = true;
}
if (ImGui::MenuItem("World Map")) {
m_showWorldMap = true;
}
ImGui::Separator();
if (ImGui::MenuItem(
"Action Database (Singleton)")) {
@@ -1306,6 +1321,16 @@ void EditorUISystem::saveScene(const std::string &filepath)
if (!m_serializer)
return;
// Sync navigation bookmarks into the scene file
m_serializer->setBookmarks(m_navigationPanel.getBookmarks());
// Streamed road networks persist to per-page region files, not the
// scene JSON (M5); flush the loaded window so the latest edits hit
// disk together with the rest of the scene.
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts && ts->getStreamingActive() && ts->getRoadSystem())
ts->getRoadSystem()->flushRegionStore();
if (m_serializer->saveToFile(filepath)) {
Ogre::LogManager::getSingleton().logMessage("Scene saved to: " +
filepath);
@@ -1322,6 +1347,7 @@ void EditorUISystem::loadScene(const std::string &filepath)
return;
if (m_serializer->loadFromFile(filepath, this)) {
m_navigationPanel.setBookmarks(m_serializer->getBookmarks());
Ogre::LogManager::getSingleton().logMessage(
"Scene loaded from: " + filepath);
} else {
@@ -11,6 +11,8 @@
#include "../ui/ActionDatabaseSingletonEditor.hpp"
#include "../ui/CharacterClassDatabaseEditor.hpp"
#include "../ui/AnimationTreeRegistryEditor.hpp"
#include "../ui/NavigationPanel.hpp"
#include "../ui/WorldMapPanel.hpp"
#include "../components/EntityName.hpp"
#include "../gizmo/Gizmo.hpp"
#include "../gizmo/RoadGizmo.hpp"
@@ -174,6 +176,8 @@ public:
void setEditorCamera(EditorCamera *camera)
{
m_editorCamera = camera;
m_navigationPanel.setEditorCamera(camera);
m_worldMapPanel.setEditorCamera(camera);
}
/**
@@ -328,6 +332,14 @@ private:
// Camera reference for cursor placement/rotation
EditorCamera *m_editorCamera = nullptr;
// Navigation panel (teleport + world bookmarks)
bool m_showNavigation = false;
NavigationPanel m_navigationPanel;
// World map panel (2D top-down map of the streamed world)
bool m_showWorldMap = false;
WorldMapPanel m_worldMapPanel;
// Action Database singleton editor state
bool m_showActionDatabaseSingleton = false;
ActionDatabaseSingletonEditor m_actionDatabaseSingletonEditor;
+24 -29
View File
@@ -51,39 +51,33 @@ flecs::entity PrefabSystem::createInstance(const std::string &prefabPath,
EditorUISystem *uiSystem)
{
// Read prefab root transform so we can preserve it as the base
// (cached parse, M4.4 — spawners instantiate repeatedly)
Ogre::Vector3 prefabPos(0, 0, 0);
Ogre::Quaternion prefabRot(Ogre::Quaternion::IDENTITY);
Ogre::Vector3 prefabScale(1, 1, 1);
try {
std::ifstream file(prefabPath);
if (file.is_open()) {
nlohmann::json prefabJson;
file >> prefabJson;
file.close();
if (prefabJson.contains("transform")) {
auto &t = prefabJson["transform"];
if (t.contains("position")) {
auto &p = t["position"];
prefabPos = Ogre::Vector3(
p.value("x", 0.0f),
p.value("y", 0.0f),
p.value("z", 0.0f));
}
if (t.contains("rotation")) {
auto &r = t["rotation"];
prefabRot = Ogre::Quaternion(
r.value("w", 1.0f),
r.value("x", 0.0f),
r.value("y", 0.0f),
r.value("z", 0.0f));
}
if (t.contains("scale")) {
auto &s = t["scale"];
prefabScale = Ogre::Vector3(
s.value("x", 1.0f),
s.value("y", 1.0f),
s.value("z", 1.0f));
}
const nlohmann::json *cached =
SceneSerializer::loadPrefabJsonCached(prefabPath);
if (cached && cached->contains("transform")) {
const auto &t = (*cached)["transform"];
if (t.contains("position")) {
const auto &p = t["position"];
prefabPos = Ogre::Vector3(p.value("x", 0.0f),
p.value("y", 0.0f),
p.value("z", 0.0f));
}
if (t.contains("rotation")) {
const auto &r = t["rotation"];
prefabRot = Ogre::Quaternion(r.value("w", 1.0f),
r.value("x", 0.0f),
r.value("y", 0.0f),
r.value("z", 0.0f));
}
if (t.contains("scale")) {
const auto &s = t["scale"];
prefabScale = Ogre::Vector3(s.value("x", 1.0f),
s.value("y", 1.0f),
s.value("z", 1.0f));
}
}
} catch (...) {
@@ -218,6 +212,7 @@ bool PrefabSystem::deletePrefab(const std::string &prefabPath)
m_lastError = "Failed to delete prefab: " + prefabPath;
return false;
}
SceneSerializer::invalidatePrefabJsonCache(prefabPath);
Ogre::LogManager::getSingleton().logMessage(
"PrefabSystem: Deleted prefab '" + prefabPath + "'");
return true;
@@ -0,0 +1,92 @@
#include "RenderOriginSystem.hpp"
#include "TerrainSystem.hpp"
#include "../camera/EditorCamera.hpp"
#include "../components/Transform.hpp"
#include <cmath>
RenderOriginSystem *RenderOriginSystem::s_instance = nullptr;
RenderOriginSystem::RenderOriginSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
: m_world(world), m_sceneMgr(sceneMgr)
{
s_instance = this;
}
RenderOriginSystem::~RenderOriginSystem()
{
if (s_instance == this)
s_instance = nullptr;
}
Ogre::Vector3 RenderOriginSystem::worldToRender(double x, double y,
double z) const
{
return Ogre::Vector3((float)(x - m_origin.GetX()),
(float)(y - m_origin.GetY()),
(float)(z - m_origin.GetZ()));
}
JPH::DVec3 RenderOriginSystem::renderToWorld(const Ogre::Vector3 &p) const
{
return JPH::DVec3(m_origin.GetX() + p.x, m_origin.GetY() + p.y,
m_origin.GetZ() + p.z);
}
void RenderOriginSystem::update()
{
if (!m_editorCamera || !m_editorCamera->getCamera())
return;
Ogre::Vector3 p = m_editorCamera->getCamera()->getDerivedPosition();
if (fabsf(p.x) < REBASE_THRESHOLD &&
fabsf(p.y) < REBASE_THRESHOLD && fabsf(p.z) < REBASE_THRESHOLD)
return;
/* Snap the new origin to integer world coordinates so render
* deltas stay exactly representable in float. */
JPH::DVec3 newOrigin(m_origin.GetX() + floor(p.x),
m_origin.GetY() + floor(p.y),
m_origin.GetZ() + floor(p.z));
rebase(newOrigin);
}
void RenderOriginSystem::rebase(const JPH::DVec3 &newOrigin)
{
JPH::DVec3 delta = newOrigin - m_origin;
if (delta == JPH::DVec3(0.0, 0.0, 0.0))
return;
Ogre::Vector3 deltaF((float)delta.GetX(), (float)delta.GetY(),
(float)delta.GetZ());
m_origin = newOrigin;
Ogre::SceneNode *root = m_sceneMgr->getRootSceneNode();
/* Shift root-level entity nodes. Entities with an authoritative
* double world position are recomputed exactly; legacy entities
* are shifted by the render delta. */
m_world.query<TransformComponent>().each(
[&](flecs::entity /*e*/, TransformComponent &tc) {
if (!tc.node ||
tc.node->getParentSceneNode() != root)
return;
if (tc.hasWorldPosition)
tc.position = worldToRender(tc.worldX,
tc.worldY,
tc.worldZ);
else
tc.position -= deltaF;
tc.node->setPosition(tc.position);
});
if (m_gridNode)
m_gridNode->setPosition(m_gridNode->getPosition() - deltaF);
if (m_axisNode)
m_axisNode->setPosition(m_axisNode->getPosition() - deltaF);
if (m_editorCamera)
m_editorCamera->shiftPosition(-deltaF);
if (m_terrainSystem)
m_terrainSystem->onRenderOriginChanged(deltaF);
}
@@ -0,0 +1,87 @@
#ifndef EDITSCENE_RENDERORIGINSYSTEM_HPP
#define EDITSCENE_RENDERORIGINSYSTEM_HPP
#pragma once
#include <Ogre.h>
#include <flecs.h>
#include <Jolt/Jolt.h>
#include <Jolt/Math/DVec3.h>
class EditorCamera;
class TerrainSystem;
/**
* RenderOriginSystem - floating render origin for huge streamed worlds.
*
* The world spans up to 40,000,000 x 40,000,000 units, far beyond
* what 32-bit float coordinates can represent. Rendering therefore
* happens in "render space": render = world - renderOrigin, keeping
* everything near the float origin around the camera. Authoritative
* absolute positions live in double precision (TransformComponent
* worldX/Y/Z, the TerrainSystem world origin); this system tracks the
* current render origin and rebases when the camera moves further
* than REBASE_THRESHOLD from it: the origin snaps to integer world
* coordinates near the camera and every render-space position is
* shifted by the exact negated delta.
*
* TerrainSystem keeps most of its public position APIs in render
* space; it converts to absolute world coordinates internally wherever
* precision matters and is notified of rebases via
* onRenderOriginChanged(). (Exception: the sculpt/aux brush apply
* APIs take physical heightmap coords see TerrainSystem.hpp.)
*/
class RenderOriginSystem {
public:
RenderOriginSystem(flecs::world &world, Ogre::SceneManager *sceneMgr);
~RenderOriginSystem();
static RenderOriginSystem *getInstance()
{
return s_instance;
}
void setTerrainSystem(TerrainSystem *ts)
{
m_terrainSystem = ts;
}
void setEditorCamera(EditorCamera *cam)
{
m_editorCamera = cam;
}
void setAuxNodes(Ogre::SceneNode *gridNode, Ogre::SceneNode *axisNode)
{
m_gridNode = gridNode;
m_axisNode = axisNode;
}
const JPH::DVec3 &getOrigin() const
{
return m_origin;
}
Ogre::Vector3 worldToRender(double x, double y, double z) const;
JPH::DVec3 renderToWorld(const Ogre::Vector3 &p) const;
/* Poll the editor camera and rebase once it has moved further
* than REBASE_THRESHOLD from the current origin. Call once per
* frame, before TerrainSystem::update(). */
void update();
/* Move the render origin to @p newOrigin (absolute world space)
* and shift the whole render scene by the negated delta. */
void rebase(const JPH::DVec3 &newOrigin);
static constexpr float REBASE_THRESHOLD = 8192.0f;
private:
static RenderOriginSystem *s_instance;
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
TerrainSystem *m_terrainSystem = nullptr;
EditorCamera *m_editorCamera = nullptr;
Ogre::SceneNode *m_gridNode = nullptr;
Ogre::SceneNode *m_axisNode = nullptr;
JPH::DVec3 m_origin = JPH::DVec3(0.0, 0.0, 0.0);
};
#endif // EDITSCENE_RENDERORIGINSYSTEM_HPP
@@ -0,0 +1,207 @@
#include "RoadRegionStore.hpp"
#include <nlohmann/json.hpp>
#include <OgreLogManager.h>
#include <filesystem>
#include <fstream>
void RoadRegionStore::setRootDirectory(const std::string &dir)
{
if (dir == m_rootDir)
return;
m_rootDir = dir;
clearCache();
}
void RoadRegionStore::clearCache()
{
m_pages.clear();
}
std::string RoadRegionStore::pageFilePath(const std::string &rootDir,
long pageX, long pageY)
{
return rootDir + "/roads/" + std::to_string(pageX) + "_" +
std::to_string(pageY) + ".json";
}
static void prefabToJson(const RoadRegionEdgePrefab &src, nlohmann::json &out)
{
out["prefabPath"] = src.prefabPath;
out["edgeT"] = src.edgeT;
out["lateralOffset"] = src.lateralOffset;
out["yOffset"] = src.yOffset;
}
static void prefabFromJson(const nlohmann::json &js,
RoadRegionEdgePrefab &out)
{
out.prefabPath = js.value("prefabPath", std::string());
out.edgeT = js.value("edgeT", 0.5f);
out.lateralOffset = js.value("lateralOffset", 0.0f);
out.yOffset = js.value("yOffset", 0.0f);
}
RoadRegionStore::PageData &RoadRegionStore::loadPage(long pageX, long pageY)
{
const uint64_t key = packPageKey(pageX, pageY);
auto it = m_pages.find(key);
if (it != m_pages.end())
return it->second;
PageData page;
if (!m_rootDir.empty()) {
const std::string path = pageFilePath(m_rootDir, pageX, pageY);
try {
std::ifstream file(path);
if (file.is_open()) {
nlohmann::json j;
file >> j;
if (j.contains("nodes") && j["nodes"].is_array()) {
for (const nlohmann::json &jn :
j["nodes"]) {
RoadRegionNode n;
n.id = jn.value("id",
(uint64_t)0);
n.x = jn.value("x", 0.0);
n.y = jn.value("y", 0.0);
n.z = jn.value("z", 0.0);
n.verticalOffset = jn.value(
"verticalOffset", 0.0f);
page.data.nodes.push_back(n);
}
}
if (j.contains("edges") && j["edges"].is_array()) {
for (const nlohmann::json &je :
j["edges"]) {
RoadRegionEdge e;
e.nodeAId = je.value(
"nodeA", (uint64_t)0);
e.nodeBId = je.value(
"nodeB", (uint64_t)0);
e.roadLevelA = je.value(
"roadLevelA", 0.0f);
e.roadLevelB = je.value(
"roadLevelB", 0.0f);
e.lanesPerDirectionOverride =
je.value("lanesPerDirectionOverride",
0);
e.lanesAtoB = je.value(
"lanesAtoB", (uint32_t)0);
e.lanesBtoA = je.value(
"lanesBtoA", (uint32_t)0);
if (je.contains("prefabLeft"))
prefabFromJson(
je["prefabLeft"],
e.prefabLeft);
if (je.contains("prefabRight"))
prefabFromJson(
je["prefabRight"],
e.prefabRight);
if (je.contains("prefabMid"))
prefabFromJson(
je["prefabMid"],
e.prefabMid);
page.data.edges.push_back(e);
}
}
page.loaded = true;
}
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"RoadRegionStore: failed to load " + path +
": " + e.what());
}
}
return m_pages.emplace(key, std::move(page)).first->second;
}
bool RoadRegionStore::getRegion(long px, long py, RoadRegionData *out)
{
if (m_rootDir.empty() || !out)
return false;
PageData &page = loadPage(px, py);
if (!page.loaded)
return false;
*out = page.data;
return true;
}
bool RoadRegionStore::saveRegion(long px, long py, const RoadRegionData &data)
{
if (m_rootDir.empty())
return false;
try {
const std::string dir = m_rootDir + "/roads";
std::filesystem::create_directories(dir);
nlohmann::json j;
j["version"] = "1.0";
j["page"] = { px, py };
j["nodes"] = nlohmann::json::array();
for (const RoadRegionNode &n : data.nodes) {
nlohmann::json jn;
jn["id"] = n.id;
jn["x"] = n.x;
jn["y"] = n.y;
jn["z"] = n.z;
jn["verticalOffset"] = n.verticalOffset;
j["nodes"].push_back(jn);
}
j["edges"] = nlohmann::json::array();
for (const RoadRegionEdge &e : data.edges) {
nlohmann::json je;
je["nodeA"] = e.nodeAId;
je["nodeB"] = e.nodeBId;
je["roadLevelA"] = e.roadLevelA;
je["roadLevelB"] = e.roadLevelB;
je["lanesPerDirectionOverride"] =
e.lanesPerDirectionOverride;
je["lanesAtoB"] = e.lanesAtoB;
je["lanesBtoA"] = e.lanesBtoA;
if (!e.prefabLeft.prefabPath.empty())
prefabToJson(e.prefabLeft, je["prefabLeft"]);
if (!e.prefabRight.prefabPath.empty())
prefabToJson(e.prefabRight, je["prefabRight"]);
if (!e.prefabMid.prefabPath.empty())
prefabToJson(e.prefabMid, je["prefabMid"]);
j["edges"].push_back(je);
}
const std::string path = pageFilePath(m_rootDir, px, py);
std::ofstream file(path);
if (!file.is_open()) {
Ogre::LogManager::getSingleton().logMessage(
"RoadRegionStore: failed to write " + path);
return false;
}
file << j.dump(4);
PageData &page = loadPage(px, py);
page.data = data;
page.loaded = true;
return true;
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"RoadRegionStore: save error: " +
std::string(e.what()));
return false;
}
}
void RoadRegionStore::removeRegionFile(long px, long py)
{
m_pages.erase(packPageKey(px, py));
if (m_rootDir.empty())
return;
try {
std::filesystem::remove(pageFilePath(m_rootDir, px, py));
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"RoadRegionStore: remove error: " +
std::string(e.what()));
}
}
@@ -0,0 +1,96 @@
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <vector>
/*
* One road node stored in a region file. Positions are absolute world
* space doubles so regions work at any distance from the origin
* (RoadNode.position is only float and cannot represent those).
*/
struct RoadRegionNode {
uint64_t id = 0;
double x = 0.0;
double y = 0.0;
double z = 0.0;
float verticalOffset = 0.0f;
};
struct RoadRegionEdgePrefab {
std::string prefabPath;
float edgeT = 0.5f;
float lateralOffset = 0.0f;
float yOffset = 0.0f;
};
/*
* One road edge stored in a region file. Endpoints are referenced by
* the region-local node ids of this file. Field semantics match
* RoadEdge (0 lane count = "use RoadConfig::lanesPerDirection").
*/
struct RoadRegionEdge {
uint64_t nodeAId = 0;
uint64_t nodeBId = 0;
float roadLevelA = 0.0f;
float roadLevelB = 0.0f;
int lanesPerDirectionOverride = 0;
uint32_t lanesAtoB = 0;
uint32_t lanesBtoA = 0;
RoadRegionEdgePrefab prefabLeft;
RoadRegionEdgePrefab prefabRight;
RoadRegionEdgePrefab prefabMid;
};
/*
* Contents of one road region file. Every edge lists the node ids it
* references; nodes that belong to a different page are repeated inline
* (foreign endpoints) so the file is self contained.
*/
struct RoadRegionData {
std::vector<RoadRegionNode> nodes;
std::vector<RoadRegionEdge> edges;
};
/*
* Owns the on-disk storage for road network region files. Files live
* under "<root>/roads/<px>_<py>.json" where px/py are PHYSICAL page
* indices derived from world positions, matching the terrain page that
* contains the node (round-half convention, same as
* RoadGraph::edgeStaysWithinOnePage).
*/
class RoadRegionStore
{
public:
void setRootDirectory(const std::string &dir);
const std::string &getRootDirectory() const
{
return m_rootDir;
}
static uint64_t packPageKey(long x, long y)
{
return ((uint64_t)(uint32_t)(int32_t)x << 32) |
(uint32_t)(int32_t)y;
}
static std::string pageFilePath(const std::string &rootDir,
long pageX, long pageY);
bool getRegion(long px, long py, RoadRegionData *out);
bool saveRegion(long px, long py, const RoadRegionData &data);
void removeRegionFile(long px, long py);
void clearCache();
private:
struct PageData {
RoadRegionData data;
bool loaded = false;
};
PageData &loadPage(long px, long py);
std::string m_rootDir;
std::map<uint64_t, PageData> m_pages;
};
File diff suppressed because it is too large Load Diff
+107 -27
View File
@@ -11,11 +11,13 @@
#include <cstdint>
#include <functional>
#include <memory>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
#include "../components/RoadGraph.hpp"
#include "RoadRegionStore.hpp"
namespace Ogre {
class TerrainGroup;
@@ -30,8 +32,8 @@ class JoltPhysicsWrapper;
* One entry exists for every loaded terrain page. The page generates the
* wedges and straight segments seeded by road nodes inside its bounds (see
* the page-assignment decision in TerrainRequirements.md, M5.4). Runtime
* objects (mesh entity, collider, roadside prefabs) are created in later
* milestones and destroyed together when the page unloads.
* objects (mesh entity, collider, roadside prefabs) are created by the
* M5.8+ passes and destroyed together when the page unloads.
*/
struct RoadPageGeometry {
long pageX = 0;
@@ -61,6 +63,15 @@ struct RoadPageGeometry {
/** Geometry needs (re)generation. */
bool dirty = true;
/**
* Content signature of the last bucket assignment (M5): an FNV-1a
* hash over the page's seed nodes (id + world position quantized to
* centimetres, so it survives render-origin rebases), their incident
* edges and the geometry-relevant RoadConfig fields. A graph change
* only re-dirties the page when this value changed.
*/
uint64_t signature = 0;
/**
* Rendering distance, navmesh contribution and RenderableComponent
* pointer have been applied to the created Ogre mesh (M5.8). Reset
@@ -71,7 +82,7 @@ struct RoadPageGeometry {
/**
* RoadSystem runtime owner of road visual aids, per-page road geometry
* state, and (future) road mesh generation.
* state, and road mesh generation.
*
* The class builds and updates ManualObject overlays for the road graph:
* node markers, edge lines, road-width indicators, and selection
@@ -260,32 +271,62 @@ public:
void snapNodesToTerrain(const std::vector<int> &nodeIds);
/**
* Terrain compliance (M5.10): writes fixup values under every
* road surface vertex so the terrain matches the road underside,
* then extends a linear fade over laneWidth * 2 perpendicular to
* each curb so the terrain shoulders rise smoothly from the road
* edge back to the natural height.
* Terrain compliance (M5.10): lowers the terrain under every
* road so the rendered surface stays below the road top minus a
* small sag. The rendered surface is the interpolation of the
* page-vertex lattice and the fixup layer can only move lattice
* vertices (TerrainSystem::lowerFixupCorners), so compliance is
* solved as a constraint problem: each road top-surface
* triangle imposes rendered(q) <= roadTop(q) - sag on the
* lattice cells it overlaps (for both triangulation
* diagonals), currently satisfied constraints are dropped, and
* the rest are solved with damped Kaczmarz sweeps over cached
* vertex heights (lowering is monotone, so the sweeps
* converge). The per-vertex deltas are then applied as fixups
* and every page touching a lowered vertex is marked dirty.
* Terrain beyond the road corridor keeps its natural height.
*
* @param terrainSystem the active TerrainSystem that owns the
* fixup layer (used to call writeFixup + markPageDirty).
* fixup layer (lowerFixupCorners + markPageDirty).
* @param roadThickness vertical thickness of the road slab.
* @param laneWidth lane width; the fade zone is laneWidth * 2.
* @param laneWidth unused (kept for API compatibility).
*/
void complyTerrain(class TerrainSystem *terrainSystem,
float roadThickness, float laneWidth);
/**
* Inverse of complyTerrain: shifts road nodes vertically (both
* directions) so every point of every road edge stays at least
* @p elevation above the RENDERED terrain surface (the
* page-lattice interpolation, conservatively the max over both
* cell triangulation diagonals) not the fine heightmap, so
* roads are not lifted over sub-lattice bumps that never
* render. Connected nodes are initialized to rendered terrain
* + elevation, a damped relaxation moves every node toward the
* maximum lower bound its constraints impose given current
* neighbour heights, and a few raise-only passes clear the
* damping residual so no constraint is left violated.
* Constraints sample the corridor at dense 0.5 m intervals
* along each half-edge plus every crossing of the centreline,
* curb and end lines with the page-vertex lattice, plus every
* lattice vertex inside the road band. Samples behind a node
* (the wedge fan) clamp to s = 0 so they constrain the node
* height directly instead of extrapolating the half-edge line
* backwards. Pass roadThickness + a small margin for
* @p elevation so the whole slab (not just the top) stays
* above the terrain. Node verticalOffset values are
* re-derived from the terrain and the graph version is bumped
* on change.
*/
void complyRoadsToTerrain(class TerrainSystem *terrainSystem,
float elevation);
/** Road physics body IDs for debug-draw filtering. */
const std::set<JPH::BodyID> &getRoadBodyIds() const
{
return m_roadBodyIds;
}
/** Compute target terrain height for road compliance falloff. */
static float computeComplianceHeight(
float roadSurfaceY, float roadThickness,
float baseHeight, float lateralDistance,
float halfRoadWidth, float fadeWidth);
/**
* Per-page road geometry state, keyed by
* TerrainGroup::packIndex(pageX, pageY) (M5.4).
@@ -338,6 +379,23 @@ public:
return m_edgePrefabs;
}
/**
* Render-origin rebase (M5): shift every graph node position by
* -delta (node positions are render-space floats) and rebuild the
* derived buckets. Region files store absolute world doubles and
* are rebase-invariant, so they are not touched.
*/
void onRenderOriginChanged(const Ogre::Vector3 &delta);
/**
* Persist the streamed road network (M5): every page that currently
* has nodes in the active graph is extracted to its region file
* (same extraction as the page-unload path). Called on scene save
* and at the end of the legacy inline-data migration. No-op when
* terrain streaming is not active.
*/
void flushRegionStore();
private:
void createManualObjects();
void destroyManualObjects();
@@ -355,6 +413,23 @@ private:
void destroyPageGeometry(RoadPageGeometry &pg);
void clearPageGeometry();
bool pageKeyForNode(int nodeId, uint64_t &outKey) const;
uint64_t computePageSignature(uint64_t key) const;
void hashNodeWorldPos(uint64_t &h, const RoadNode &n) const;
/* Road region streaming (M5): per-page region files under
* heightmaps/<terrainId>/roads/, merged/extracted as terrain pages
* load/unload. */
void syncRegions();
void resetRegionState();
bool roadStreamingActive() const;
bool pageForWorldPos(double wx, double wz, long &outX,
long &outY) const;
void migrateGraphToRegions(RoadGraph &graph);
void loadRegion(long px, long py, RoadGraph &graph);
void buildRegionData(long px, long py, const RoadGraph &graph,
RoadRegionData &out) const;
int findNodeByWorldPos(const RoadGraph &graph, double wx,
double wz) const;
/* Mesh assembly (M5.8). */
void buildPageMeshes(RoadPageGeometry &pg);
@@ -362,22 +437,12 @@ private:
flecs::entity ensureRoadMaterial(const RoadConfig &cfg);
flecs::entity ensureRoadLodSettings(const RoadConfig &cfg);
void markNavMeshDirty();
void applyNavMeshDirty();
/* Physics colliders (M5.9). */
void createPageCollider(RoadPageGeometry &pg, const std::string &meshName);
void destroyPageCollider(RoadPageGeometry &pg);
/* Terrain compliance falloff (M5.10 step 4): writes the linear
* fade zone perpendicular to one half-edge, from its curb out to
* sideWidth + laneWidth*2. sideSign = +1 for the right side of the
* half-edge direction, -1 for the left side. */
void writeComplianceFalloff(class TerrainSystem *terrainSystem,
const RoadGraph &graph,
const RoadHalfEdge &halfEdge,
const Ogre::Vector3 &nodePos,
float sideSign, float sideWidth,
float roadThickness, float laneWidth);
/* Road prefab spawning (improvement plan §3.2): per-edge records
* with distance hysteresis and edit-mode force spawn. */
void updateEdgePrefabs();
@@ -411,6 +476,21 @@ private:
std::unordered_map<uint64_t, RoadPageGeometry> m_wedgeBuckets;
uint64_t m_geometryGraphVersion = 0;
/* Road region streaming state (M5). m_loadedRegions holds the
* RoadRegionStore::packPageKey of every physical page whose region
* data has been merged into the graph. */
RoadRegionStore m_regionStore;
std::string m_regionRoot;
std::set<uint64_t> m_loadedRegions;
bool m_roadMigrated = false;
/* Navmesh dirty-marking debounce while streaming: set by
* markNavMeshDirty, applied after 15 frames without region or page
* geometry churn. */
bool m_navMeshDirtyPending = false;
int m_navMeshQuietFrames = 0;
bool m_streamActivity = false;
/** Per-edge road prefab spawn state (improvement plan §3.2),
* keyed by edgePrefabKey(nodeA, nodeB). */
std::unordered_map<uint64_t, EdgePrefabRecord> m_edgePrefabs;
@@ -1,4 +1,5 @@
#include "SceneSerializer.hpp"
#include "RenderOriginSystem.hpp"
#include "ItemRegistry.hpp"
#include "ContainerStateRegistry.hpp"
#include "ItemStateRegistry.hpp"
@@ -70,6 +71,35 @@ SceneSerializer::SceneSerializer(flecs::world &world,
{
}
/* Prefab JSON cache (M4.4). */
static std::unordered_map<std::string, nlohmann::json> s_prefabJsonCache;
const nlohmann::json *
SceneSerializer::loadPrefabJsonCached(const std::string &filepath)
{
auto it = s_prefabJsonCache.find(filepath);
if (it != s_prefabJsonCache.end())
return &it->second;
try {
std::ifstream file(filepath);
if (!file.is_open())
return nullptr;
nlohmann::json j;
file >> j;
file.close();
return &s_prefabJsonCache.emplace(filepath, std::move(j))
.first->second;
} catch (const std::exception &) {
return nullptr;
}
}
void SceneSerializer::invalidatePrefabJsonCache(const std::string &filepath)
{
s_prefabJsonCache.erase(filepath);
}
bool SceneSerializer::saveToFile(const std::string &filepath)
{
try {
@@ -77,9 +107,12 @@ bool SceneSerializer::saveToFile(const std::string &filepath)
scene["version"] = "1.0";
scene["entities"] = nlohmann::json::array();
// Collect all entities with EditorMarkerComponent
// Collect all entities with EditorMarkerComponent; region-store
// spawners (M4) are persisted per terrain page instead and
// must not land in the scene JSON.
m_world.query_builder<>()
.with<EditorMarkerComponent>()
.without<StreamedSpawnerTag>()
.build()
.each([&](flecs::entity entity) {
// Only save root entities (children will be saved recursively)
@@ -93,6 +126,17 @@ bool SceneSerializer::saveToFile(const std::string &filepath)
// Save ActionDatabase singleton at scene level
scene["actionDatabase"] = serializeActionDatabase();
// Save editor world bookmarks at scene level
scene["bookmarks"] = nlohmann::json::array();
for (const WorldBookmark &b : m_bookmarks) {
nlohmann::json jb;
jb["name"] = b.name;
jb["x"] = b.x;
jb["y"] = b.y;
jb["z"] = b.z;
scene["bookmarks"].push_back(jb);
}
// Write to file
std::ofstream file(filepath);
if (!file.is_open()) {
@@ -145,6 +189,19 @@ bool SceneSerializer::loadFromFile(const std::string &filepath,
deserializeActionDatabase(scene["actionDatabase"]);
}
// Load editor world bookmarks (tolerate old scenes without them)
m_bookmarks.clear();
if (scene.contains("bookmarks") && scene["bookmarks"].is_array()) {
for (const nlohmann::json &jb : scene["bookmarks"]) {
WorldBookmark b;
b.name = jb.value("name", std::string("Bookmark"));
b.x = jb.value("x", 0.0);
b.y = jb.value("y", 0.0);
b.z = jb.value("z", 0.0);
m_bookmarks.push_back(b);
}
}
// Clear entity map for new load
m_entityMap.clear();
@@ -984,6 +1041,9 @@ bool SceneSerializer::savePrefab(flecs::entity rootEntity,
}
file << prefab.dump(4);
file.close();
/* The cached parse (if any) is now stale. */
invalidatePrefabJsonCache(filepath);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Prefab save error: ") + e.what();
@@ -1032,15 +1092,14 @@ bool SceneSerializer::instantiatePrefab(flecs::entity instanceEntity,
EditorUISystem *uiSystem)
{
try {
std::ifstream file(filepath);
if (!file.is_open()) {
/* Cached parse (M4.4): the same prefab may be instantiated
* by hundreds of streamed spawners. */
const nlohmann::json *cached = loadPrefabJsonCached(filepath);
if (!cached) {
m_lastError = "Failed to open prefab: " + filepath;
return false;
}
nlohmann::json prefabJson;
file >> prefabJson;
file.close();
const nlohmann::json &prefabJson = *cached;
// Save entity map state — prefabs use their own local IDs
auto savedMap = m_entityMap;
@@ -1217,6 +1276,15 @@ nlohmann::json SceneSerializer::serializeTransform(flecs::entity entity)
{ "y", transform.position.y },
{ "z", transform.position.z } };
/* Authoritative double-precision world position (M2). Written
* only when set; legacy scenes have no render origin and the
* float position doubles as world position. */
if (transform.hasWorldPosition) {
json["worldPosition"] = { { "x", transform.worldX },
{ "y", transform.worldY },
{ "z", transform.worldZ } };
}
json["rotation"] = { { "w", transform.rotation.w },
{ "x", transform.rotation.x },
{ "y", transform.rotation.y },
@@ -1405,6 +1473,23 @@ void SceneSerializer::deserializeTransform(flecs::entity entity,
pos.value("z", 0.0f));
}
/* Authoritative world position (M2). When present it wins: the
* render-space node position is recomputed through the current
* render origin so scenes load correctly regardless of where the
* origin happens to be. */
if (json.contains("worldPosition")) {
auto &wp = json["worldPosition"];
transform.worldX = wp.value("x", 0.0);
transform.worldY = wp.value("y", 0.0);
transform.worldZ = wp.value("z", 0.0);
transform.hasWorldPosition = true;
RenderOriginSystem *ro = RenderOriginSystem::getInstance();
if (ro)
transform.position = ro->worldToRender(
transform.worldX, transform.worldY,
transform.worldZ);
}
// Read rotation
if (json.contains("rotation")) {
auto &rot = json["rotation"];
@@ -4230,6 +4315,26 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
detailNoiseJson["persistence"] = tc.detailNoise.persistence;
json["detailNoise"] = detailNoiseJson;
/* Streaming / far-distance rendering (additive; old scenes keep the
* defaults when these keys are missing). */
json["streamingEnabled"] = tc.streamingEnabled;
json["worldSizeUnits"] = tc.worldSizeUnits;
json["pageLoadRadius"] = tc.pageLoadRadius;
json["pageHoldRadius"] = tc.pageHoldRadius;
json["farClipDistance"] = tc.farClipDistance;
json["fogEnabled"] = tc.fogEnabled;
json["fogStart"] = tc.fogStart;
json["fogEnd"] = tc.fogEnd;
nlohmann::json baseNoiseJson;
baseNoiseJson["seed"] = tc.baseNoise.seed;
baseNoiseJson["octaves"] = tc.baseNoise.octaves;
baseNoiseJson["frequency"] = tc.baseNoise.frequency;
baseNoiseJson["amplitude"] = tc.baseNoise.amplitude;
baseNoiseJson["lacunarity"] = tc.baseNoise.lacunarity;
baseNoiseJson["persistence"] = tc.baseNoise.persistence;
json["baseNoise"] = baseNoiseJson;
nlohmann::json roadConfigJson;
roadConfigJson["roadMeshTemplate"] = tc.roadGraph.config.roadMeshTemplate;
roadConfigJson["laneWidth"] = tc.roadGraph.config.laneWidth;
@@ -4252,41 +4357,48 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
tc.roadGraph.config.prefabDespawnDistance;
json["roadConfig"] = roadConfigJson;
nlohmann::json roadNodesJson = nlohmann::json::array();
for (auto &n : tc.roadGraph.nodes) {
nlohmann::json nj;
nj["id"] = n.id;
nj["position"] = { n.position.x, n.position.y, n.position.z };
nj["verticalOffset"] = n.verticalOffset;
roadNodesJson.push_back(nj);
}
json["roadNodes"] = roadNodesJson;
/* Streaming terrains persist the road network in per-page region
* files (heightmaps/<terrainId>/roads/) via RoadSystem; the inline
* data would only cover the currently loaded window and go stale.
* roadConfig stays inline it is global, not per page. */
if (!tc.streamingEnabled) {
nlohmann::json roadNodesJson = nlohmann::json::array();
for (auto &n : tc.roadGraph.nodes) {
nlohmann::json nj;
nj["id"] = n.id;
nj["position"] = { n.position.x, n.position.y,
n.position.z };
nj["verticalOffset"] = n.verticalOffset;
roadNodesJson.push_back(nj);
}
json["roadNodes"] = roadNodesJson;
nlohmann::json roadEdgesJson = nlohmann::json::array();
for (auto &e : tc.roadGraph.edges) {
nlohmann::json ej;
ej["nodeA"] = e.nodeA;
ej["nodeB"] = e.nodeB;
ej["roadLevelA"] = e.roadLevelA;
ej["roadLevelB"] = e.roadLevelB;
ej["lanesPerDirectionOverride"] = e.lanesPerDirectionOverride;
ej["lanesAtoB"] = e.lanesAtoB;
ej["lanesBtoA"] = e.lanesBtoA;
nlohmann::json roadEdgesJson = nlohmann::json::array();
for (auto &e : tc.roadGraph.edges) {
nlohmann::json ej;
ej["nodeA"] = e.nodeA;
ej["nodeB"] = e.nodeB;
ej["roadLevelA"] = e.roadLevelA;
ej["roadLevelB"] = e.roadLevelB;
ej["lanesPerDirectionOverride"] = e.lanesPerDirectionOverride;
ej["lanesAtoB"] = e.lanesAtoB;
ej["lanesBtoA"] = e.lanesBtoA;
auto writeSlot = [](const RoadEdgePrefabSlot &slot) {
nlohmann::json sj;
sj["prefabPath"] = slot.prefabPath;
sj["edgeT"] = slot.edgeT;
sj["lateralOffset"] = slot.lateralOffset;
sj["yOffset"] = slot.yOffset;
return sj;
};
ej["prefabLeft"] = writeSlot(e.prefabLeft);
ej["prefabRight"] = writeSlot(e.prefabRight);
ej["prefabMid"] = writeSlot(e.prefabMid);
roadEdgesJson.push_back(ej);
auto writeSlot = [](const RoadEdgePrefabSlot &slot) {
nlohmann::json sj;
sj["prefabPath"] = slot.prefabPath;
sj["edgeT"] = slot.edgeT;
sj["lateralOffset"] = slot.lateralOffset;
sj["yOffset"] = slot.yOffset;
return sj;
};
ej["prefabLeft"] = writeSlot(e.prefabLeft);
ej["prefabRight"] = writeSlot(e.prefabRight);
ej["prefabMid"] = writeSlot(e.prefabMid);
roadEdgesJson.push_back(ej);
}
json["roadEdges"] = roadEdgesJson;
}
json["roadEdges"] = roadEdgesJson;
/* Persist binary heightmap, blend maps, and aux maps alongside the JSON. */
TerrainSystem *ts = TerrainSystem::getInstance();
@@ -4350,6 +4462,27 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
tc.detailNoise.persistence = dnj.value("persistence", 0.5f);
}
/* Streaming / far-distance rendering. Missing keys keep the
* TerrainComponent defaults so old scenes load unchanged. */
tc.streamingEnabled = json.value("streamingEnabled", false);
tc.worldSizeUnits = json.value("worldSizeUnits", 40000000.0);
tc.pageLoadRadius = json.value("pageLoadRadius", 2);
tc.pageHoldRadius = json.value("pageHoldRadius", 3);
tc.farClipDistance = json.value("farClipDistance", 6000.0f);
tc.fogEnabled = json.value("fogEnabled", true);
tc.fogStart = json.value("fogStart", 2500.0f);
tc.fogEnd = json.value("fogEnd", 5500.0f);
if (json.contains("baseNoise")) {
auto &bnj = json["baseNoise"];
tc.baseNoise.seed = bnj.value("seed", 1337);
tc.baseNoise.octaves = bnj.value("octaves", 4);
tc.baseNoise.frequency = bnj.value("frequency", 0.0005f);
tc.baseNoise.amplitude = bnj.value("amplitude", 40.0f);
tc.baseNoise.lacunarity = bnj.value("lacunarity", 2.0f);
tc.baseNoise.persistence = bnj.value("persistence", 0.5f);
}
if (json.contains("roadConfig")) {
auto &rcj = json["roadConfig"];
tc.roadGraph.config.roadMeshTemplate =
@@ -7,6 +7,7 @@
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
#include "../ui/WorldBookmark.hpp"
// Forward declarations
class EditorUISystem;
@@ -29,6 +30,31 @@ public:
bool loadFromFile(const std::string &filepath,
EditorUISystem *uiSystem = nullptr);
/**
* Editor world bookmarks (world-space camera positions), saved
* in the scene JSON as a top-level "bookmarks" array.
*/
void setBookmarks(const std::vector<WorldBookmark> &bookmarks)
{
m_bookmarks = bookmarks;
}
const std::vector<WorldBookmark> &getBookmarks() const
{
return m_bookmarks;
}
/**
* Prefab JSON cache (M4.4): prefab files are read and parsed
* once, then served from memory (spawner-heavy streamed worlds
* instantiate the same prefabs repeatedly). Entries are
* invalidated by savePrefab(); callers that overwrite or delete
* a prefab file must call invalidatePrefabJsonCache().
* Returns nullptr when the file cannot be read or parsed.
*/
static const nlohmann::json *loadPrefabJsonCached(
const std::string &filepath);
static void invalidatePrefabJsonCache(const std::string &filepath);
/**
* Get last error message
*/
@@ -313,6 +339,9 @@ private:
Ogre::SceneManager *m_sceneMgr;
std::string m_lastError;
// Editor world bookmarks (saved at scene level)
std::vector<WorldBookmark> m_bookmarks;
// Track entity ID mapping for parent/child relationships
std::unordered_map<uint64_t, flecs::entity> m_entityMap;
@@ -0,0 +1,190 @@
#include "SpawnerRegionStore.hpp"
#include <nlohmann/json.hpp>
#include <OgreLogManager.h>
#include <filesystem>
#include <fstream>
void SpawnerRegionStore::setRootDirectory(const std::string &dir)
{
if (dir == m_rootDir)
return;
m_rootDir = dir;
clearCache();
}
void SpawnerRegionStore::clearCache()
{
m_pages.clear();
}
std::string SpawnerRegionStore::pageFilePath(const std::string &rootDir,
long pageX, long pageY)
{
return rootDir + "/spawners/" + std::to_string(pageX) + "_" +
std::to_string(pageY) + ".json";
}
SpawnerRegionStore::PageData &SpawnerRegionStore::loadPage(long pageX,
long pageY)
{
const uint64_t key = packPageKey(pageX, pageY);
auto it = m_pages.find(key);
if (it != m_pages.end())
return it->second;
PageData data;
if (!m_rootDir.empty()) {
const std::string path = pageFilePath(m_rootDir, pageX, pageY);
try {
std::ifstream file(path);
if (file.is_open()) {
nlohmann::json j;
file >> j;
data.nextId = j.value("nextId", 1);
if (j.contains("spawners") &&
j["spawners"].is_array()) {
for (const nlohmann::json &js :
j["spawners"]) {
SpawnerRegionDef def;
def.id = js.value("id", 0);
def.prefabPath = js.value(
"prefabPath",
std::string());
def.worldX = js.value("x", 0.0);
def.worldY = js.value("y", 0.0);
def.worldZ = js.value("z", 0.0);
def.spawnDistance = js.value(
"spawnDistance", 100.0f);
def.despawnDistance = js.value(
"despawnDistance", 200.0f);
data.defs.push_back(def);
if (def.id >= data.nextId)
data.nextId =
def.id + 1;
}
}
}
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"SpawnerRegionStore: failed to load " + path +
": " + e.what());
}
}
return m_pages.emplace(key, std::move(data)).first->second;
}
bool SpawnerRegionStore::savePage(long pageX, long pageY,
const PageData &data) const
{
if (m_rootDir.empty())
return false;
try {
const std::string dir = m_rootDir + "/spawners";
std::filesystem::create_directories(dir);
nlohmann::json j;
j["version"] = "1.0";
j["page"] = { pageX, pageY };
j["nextId"] = data.nextId;
j["spawners"] = nlohmann::json::array();
for (const SpawnerRegionDef &def : data.defs) {
nlohmann::json js;
js["id"] = def.id;
js["prefabPath"] = def.prefabPath;
js["x"] = def.worldX;
js["y"] = def.worldY;
js["z"] = def.worldZ;
js["spawnDistance"] = def.spawnDistance;
js["despawnDistance"] = def.despawnDistance;
j["spawners"].push_back(js);
}
const std::string path =
pageFilePath(m_rootDir, pageX, pageY);
std::ofstream file(path);
if (!file.is_open()) {
Ogre::LogManager::getSingleton().logMessage(
"SpawnerRegionStore: failed to write " + path);
return false;
}
file << j.dump(4);
return true;
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"SpawnerRegionStore: save error: " +
std::string(e.what()));
return false;
}
}
const std::vector<SpawnerRegionDef> &
SpawnerRegionStore::getSpawners(long pageX, long pageY)
{
if (m_rootDir.empty()) {
static const std::vector<SpawnerRegionDef> s_empty;
return s_empty;
}
return loadPage(pageX, pageY).defs;
}
const SpawnerRegionDef *SpawnerRegionStore::findById(long pageX, long pageY,
uint64_t id)
{
if (m_rootDir.empty())
return nullptr;
const PageData &data = loadPage(pageX, pageY);
for (const SpawnerRegionDef &def : data.defs) {
if (def.id == id)
return &def;
}
return nullptr;
}
uint64_t SpawnerRegionStore::addSpawner(long pageX, long pageY,
SpawnerRegionDef def)
{
if (m_rootDir.empty())
return 0;
PageData &data = loadPage(pageX, pageY);
def.id = data.nextId++;
data.defs.push_back(def);
if (!savePage(pageX, pageY, data))
return 0;
return def.id;
}
bool SpawnerRegionStore::removeSpawnerById(long pageX, long pageY,
uint64_t id)
{
if (m_rootDir.empty())
return false;
PageData &data = loadPage(pageX, pageY);
for (size_t i = 0; i < data.defs.size(); ++i) {
if (data.defs[i].id != id)
continue;
data.defs.erase(data.defs.begin() + i);
return savePage(pageX, pageY, data);
}
return false;
}
bool SpawnerRegionStore::updateSpawnerById(long pageX, long pageY,
uint64_t id,
const SpawnerRegionDef &def)
{
if (m_rootDir.empty())
return false;
PageData &data = loadPage(pageX, pageY);
for (SpawnerRegionDef &d : data.defs) {
if (d.id != id)
continue;
d = def;
d.id = id;
return savePage(pageX, pageY, data);
}
return false;
}
@@ -0,0 +1,93 @@
#ifndef EDITSCENE_SPAWNERREGIONSTORE_HPP
#define EDITSCENE_SPAWNERREGIONSTORE_HPP
#pragma once
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
/**
* One terrain-attached prefab spawner definition, persisted per
* region (region == one physical terrain page).
*
* Positions are absolute double WORLD coordinates (see
* RenderOriginSystem), so spawners survive render-origin rebases and
* stay exact at the far corners of a 40,000,000 x 40,000,000 world.
*/
struct SpawnerRegionDef {
std::string prefabPath;
double worldX = 0.0;
double worldY = 0.0;
double worldZ = 0.0;
float spawnDistance = 100.0f;
float despawnDistance = 200.0f;
/* Stable id, unique within the page file. Assigned by
* addSpawner(); streamed spawner entities reference their def by
* (pageX, pageY, id) via StreamedSpawnerTag. */
uint64_t id = 0;
};
/**
* SpawnerRegionStore (M4): persists terrain-attached prefab spawner
* definitions per terrain page in
* <rootDir>/spawners/<pageX>_<pageY>.json, with rootDir conventionally
* "heightmaps/<terrainId>". Pages are lazily loaded and cached in
* memory; every mutation rewrites the affected page file immediately
* (editor-paced writes, small files).
*
* The store is file/json only it knows nothing about entities,
* scene nodes, or the terrain; TerrainPrefabSpawnerSystem turns defs
* into entities for loaded pages and back.
*/
class SpawnerRegionStore {
public:
void setRootDirectory(const std::string &dir);
const std::string &getRootDirectory() const
{
return m_rootDir;
}
/* Drop all cached pages (terrain switch). */
void clearCache();
static std::string pageFilePath(const std::string &rootDir,
long pageX, long pageY);
static uint64_t packPageKey(long pageX, long pageY)
{
return ((uint64_t)(uint32_t)pageX << 32) |
(uint32_t)pageY;
}
/* All defs of a page (empty when none / no store configured).
* The returned reference is invalidated by any mutation of the
* same page. */
const std::vector<SpawnerRegionDef> &getSpawners(long pageX,
long pageY);
const SpawnerRegionDef *findById(long pageX, long pageY,
uint64_t id);
/* Assigns a fresh id, appends the def and rewrites the page
* file. Returns the assigned id (0 on failure). */
uint64_t addSpawner(long pageX, long pageY, SpawnerRegionDef def);
bool removeSpawnerById(long pageX, long pageY, uint64_t id);
bool updateSpawnerById(long pageX, long pageY, uint64_t id,
const SpawnerRegionDef &def);
private:
struct PageData {
std::vector<SpawnerRegionDef> defs;
uint64_t nextId = 1;
};
PageData &loadPage(long pageX, long pageY);
bool savePage(long pageX, long pageY, const PageData &data) const;
std::string m_rootDir;
std::unordered_map<uint64_t, PageData> m_pages;
};
#endif // EDITSCENE_SPAWNERREGIONSTORE_HPP
@@ -5,6 +5,7 @@
#include "PhysicsSystem.hpp"
#include "TerrainSystem.hpp"
#include "../components/TerrainPrefabSpawner.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include "../components/EntityName.hpp"
#include "../components/EditorMarker.hpp"
@@ -12,7 +13,9 @@
#include "../components/RigidBody.hpp"
#include <OgreCamera.h>
#include <OgreLogManager.h>
#include <algorithm>
#include <cmath>
#include <set>
#include <vector>
TerrainPrefabSpawnerSystem *TerrainPrefabSpawnerSystem::s_instance = nullptr;
@@ -230,6 +233,10 @@ void TerrainPrefabSpawnerSystem::update()
if (!m_initialized)
return;
/* Region store sync (M4): create/remove spawner entities as
* terrain pages stream in/out. */
syncStreamedSpawners();
const Ogre::Vector3 cameraPos = getCameraPosition();
m_query.each([&](flecs::entity e,
@@ -309,6 +316,10 @@ void TerrainPrefabSpawnerSystem::update()
rec.spawned = spawner.spawnedEntity != 0 ?
m_world.entity(spawner.spawnedEntity) :
flecs::entity::null();
/* Streamed spawners (M4): persist editor edits (moves,
* distance changes) back to the region store. */
writeBackStreamedSpawner(e, spawner, pos);
});
}
@@ -385,7 +396,10 @@ bool TerrainPrefabSpawnerSystem::complyTerrainToPrefab(
/* Pass 1 — falloff band around the footprint (written first so the
* full-flatten pass wins on shared chunk cells, same ordering as
* road compliance M5.10). */
* road compliance M5.10). The box is in visual world space but
* fixup chunks are sampled in physical heightmap space (X shifted
* by half a page, Z mirrored TerrainSystem::visualToPhysicalX/Z),
* so every read/write goes through the conversion. */
for (float x = minX - margin; x <= maxX + margin; x += texel) {
for (float z = minZ - margin; z <= maxZ + margin; z += texel) {
const float dx =
@@ -396,17 +410,20 @@ bool TerrainPrefabSpawnerSystem::complyTerrainToPrefab(
if (d <= 1e-4f || d > margin)
continue;
const float t = d / margin;
const float physX = ts->visualToPhysicalX(x);
const float physZ = ts->visualToPhysicalZ(z);
const float base = ts->sampleBaseHeightAt(
(long)std::floor(x), (long)std::floor(z));
ts->writeFixupSample(
x, z, targetY * (1.0f - t) + base * t);
(long)std::floor(physX), (long)std::floor(physZ));
ts->writeFixupSample(physX, physZ,
targetY * (1.0f - t) + base * t);
}
}
/* Pass 2 — full flatten under the footprint. */
for (float x = minX; x <= maxX + texel * 0.5f; x += texel)
for (float z = minZ; z <= maxZ + texel * 0.5f; z += texel)
ts->writeFixup(x, z, targetY);
ts->writeFixup(ts->visualToPhysicalX(x),
ts->visualToPhysicalZ(z), targetY);
/* Mark affected pages dirty so they resample with the fixups. */
if (Ogre::TerrainGroup *group = ts->getTerrainGroup()) {
@@ -486,6 +503,41 @@ flecs::entity TerrainPrefabSpawnerSystem::createSpawnPoint(
if (prefabPath.empty())
return flecs::entity::null();
TerrainSystem *ts = TerrainSystem::getInstance();
/* Streaming terrain (M4): the spawner def goes to the region
* store; the entity is (re)created by the page sync. */
if (ts && ts->getStreamingActive()) {
Ogre::Vector3 pos = position;
pos.y = ts->getHeightAt(position);
const double wx = ts->renderToWorldX(pos.x);
const double wy = ts->renderToWorldY(pos.y);
const double wz = ts->renderToWorldZ(pos.z);
long px, py;
if (!pageForWorld(wx, wz, px, py))
return flecs::entity::null();
ensureRegionStoreRoot();
SpawnerRegionDef def;
def.prefabPath = prefabPath;
def.worldX = wx;
def.worldY = wy;
def.worldZ = wz;
const uint64_t id = m_regionStore.addSpawner(px, py, def);
if (id == 0)
return flecs::entity::null();
def.id = id;
Ogre::LogManager::getSingleton().logMessage(
"TerrainPrefabSpawnerSystem: created streamed spawn point '" +
prefabPath + "' on page " + std::to_string(px) + "," +
std::to_string(py));
return createRegionEntity(def, px, py);
}
static int s_spawnCounter = 0;
flecs::entity e = m_world.entity();
@@ -495,7 +547,6 @@ flecs::entity TerrainPrefabSpawnerSystem::createSpawnPoint(
/* Snap to the terrain surface at placement time (section 6.4). */
Ogre::Vector3 pos = position;
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts && ts->isActive())
pos.y = ts->getHeightAt(position);
@@ -521,3 +572,302 @@ flecs::entity TerrainPrefabSpawnerSystem::createSpawnPoint(
return e;
}
/* ------------------------------------------------------------------ */
/* Streamed region spawners (M4) */
/* ------------------------------------------------------------------ */
void TerrainPrefabSpawnerSystem::ensureRegionStoreRoot()
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->isActive())
return;
flecs::entity te = m_world.entity(ts->getTerrainEntityId());
if (!te.is_alive() || !te.has<TerrainComponent>())
return;
const TerrainComponent &tc = te.get<TerrainComponent>();
const std::string root =
"heightmaps/" + std::to_string(tc.terrainId);
if (root != m_regionRoot) {
m_regionRoot = root;
m_regionStore.setRootDirectory(root);
}
}
bool TerrainPrefabSpawnerSystem::pageForWorld(double wx, double wz,
long &outX, long &outY) const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getStreamingActive() || !ts->getTerrainGroup())
return false;
const double ws = ts->getTerrainGroup()->getTerrainWorldSize();
const long pages = ts->getStreamingPageCount();
long px = (long)std::floor(ts->visualToPhysicalX(wx) / ws);
long py = (long)std::floor(ts->visualToPhysicalZ(wz) / ws);
if (px < 0)
px = 0;
if (py < 0)
py = 0;
if (px > pages - 1)
px = pages - 1;
if (py > pages - 1)
py = pages - 1;
outX = px;
outY = py;
return true;
}
flecs::entity TerrainPrefabSpawnerSystem::createRegionEntity(
const SpawnerRegionDef &def, long pageX, long pageY)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || def.prefabPath.empty())
return flecs::entity::null();
flecs::entity e = m_world.entity();
e.set<EntityNameComponent>(EntityNameComponent(
"RegionSpawner_" + std::to_string(pageX) + "_" +
std::to_string(pageY) + "_" + std::to_string(def.id)));
e.add<EditorMarkerComponent>();
StreamedSpawnerTag tag;
tag.pageX = pageX;
tag.pageY = pageY;
tag.defId = def.id;
e.set<StreamedSpawnerTag>(tag);
TransformComponent xform;
xform.node = m_sceneMgr->getRootSceneNode()->createChildSceneNode();
xform.position =
ts->worldToRender(def.worldX, def.worldY, def.worldZ);
xform.rotation = Ogre::Quaternion::IDENTITY;
xform.scale = Ogre::Vector3::UNIT_SCALE;
/* Authoritative world position: the render origin rebases
* recompute the node exactly (M2). */
xform.worldX = def.worldX;
xform.worldY = def.worldY;
xform.worldZ = def.worldZ;
xform.hasWorldPosition = true;
xform.applyToNode();
e.set<TransformComponent>(xform);
TerrainPrefabSpawnerComponent spawner;
spawner.prefabPath = def.prefabPath;
spawner.spawnDistanceSq = def.spawnDistance * def.spawnDistance;
spawner.despawnDistanceSq =
def.despawnDistance * def.despawnDistance;
e.set<TerrainPrefabSpawnerComponent>(spawner);
if (m_uiSystem)
m_uiSystem->addEntity(e);
m_regionEntities[SpawnerRegionStore::packPageKey(pageX, pageY)]
.push_back(e);
m_regionEntityDefIds[e.id()] = def.id;
return e;
}
void TerrainPrefabSpawnerSystem::unloadRegionEntities(uint64_t pageKey)
{
auto it = m_regionEntities.find(pageKey);
if (it == m_regionEntities.end())
return;
m_unloadingRegion = true;
for (flecs::entity e : it->second) {
if (!e.is_alive())
continue;
despawn(e);
m_regionEntityDefIds.erase(e.id());
if (m_uiSystem)
m_uiSystem->removeEntity(e);
if (e.has<TransformComponent>()) {
auto &xform = e.get_mut<TransformComponent>();
if (xform.node) {
try {
m_sceneMgr->destroySceneNode(
xform.node);
} catch (...) {
}
xform.node = nullptr;
}
}
e.destruct();
}
m_unloadingRegion = false;
m_regionEntities.erase(it);
}
void TerrainPrefabSpawnerSystem::unloadAllRegionEntities()
{
while (!m_regionEntities.empty())
unloadRegionEntities(m_regionEntities.begin()->first);
}
void TerrainPrefabSpawnerSystem::syncStreamedSpawners()
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getStreamingActive() || !ts->getTerrainGroup()) {
/* Streaming off: drop all region entities and the store
* root so a scene switch starts clean. */
if (!m_regionEntities.empty() || !m_regionRoot.empty()) {
unloadAllRegionEntities();
m_regionRoot.clear();
m_regionStore.clearCache();
}
return;
}
ensureRegionStoreRoot();
if (m_regionRoot.empty())
return;
/* Drop defs whose entity was deleted through the editor while
* the page was loaded. */
for (auto &kv : m_regionEntities) {
std::vector<flecs::entity> &vec = kv.second;
const long px = (long)(uint32_t)(kv.first >> 32);
const long py = (long)(uint32_t)(kv.first & 0xffffffffu);
for (size_t i = 0; i < vec.size();) {
flecs::entity e = vec[i];
if (e.is_alive()) {
++i;
continue;
}
uint64_t defId = 0;
/* The tag is gone with the entity; find the def by
* elimination is impossible, so the tag data is
* mirrored in the bookkeeping below. */
auto bt = m_regionEntityDefIds.find(e.id());
if (bt != m_regionEntityDefIds.end()) {
defId = bt->second;
m_regionEntityDefIds.erase(bt);
}
if (defId != 0)
m_regionStore.removeSpawnerById(px, py, defId);
vec.erase(vec.begin() + i);
}
}
/* Loaded physical pages (slot (x, y) is physical page (x, -y)). */
Ogre::TerrainGroup *group = ts->getTerrainGroup();
std::set<uint64_t> loaded;
for (const auto &kv : group->getTerrainSlots()) {
Ogre::TerrainGroup::TerrainSlot *slot = kv.second;
if (!slot || !slot->instance || !slot->instance->isLoaded())
continue;
loaded.insert(
SpawnerRegionStore::packPageKey(slot->x, -slot->y));
}
/* Create entities for newly loaded pages. */
for (uint64_t key : loaded) {
if (m_regionEntities.find(key) != m_regionEntities.end())
continue;
const long px = (long)(uint32_t)(key >> 32);
const long py = (long)(uint32_t)(key & 0xffffffffu);
/* Register even when the page has no defs so the page is
* not re-read from disk every frame. */
m_regionEntities[key] = std::vector<flecs::entity>();
for (const SpawnerRegionDef &def :
m_regionStore.getSpawners(px, py))
createRegionEntity(def, px, py);
}
/* Remove entities whose page unloaded. */
for (auto it = m_regionEntities.begin();
it != m_regionEntities.end();) {
if (loaded.find(it->first) == loaded.end()) {
const uint64_t key = it->first;
++it;
unloadRegionEntities(key);
} else {
++it;
}
}
}
void TerrainPrefabSpawnerSystem::writeBackStreamedSpawner(
flecs::entity e, const TerrainPrefabSpawnerComponent &spawner,
const Ogre::Vector3 &renderPos)
{
if (!e.has<StreamedSpawnerTag>())
return;
StreamedSpawnerTag &tag = e.get_mut<StreamedSpawnerTag>();
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return;
const double wx = ts->renderToWorldX(renderPos.x);
const double wy = ts->renderToWorldY(renderPos.y);
const double wz = ts->renderToWorldZ(renderPos.z);
const float sd = std::sqrt(std::max(0.0f, spawner.spawnDistanceSq));
const float dd =
std::sqrt(std::max(0.0f, spawner.despawnDistanceSq));
const SpawnerRegionDef *def =
m_regionStore.findById(tag.pageX, tag.pageY, tag.defId);
if (!def)
return;
/* Rebase guard: compare in WORLD space; a pure render-origin
* shift leaves these unchanged. */
const bool moved = std::fabs(def->worldX - wx) > 0.5 ||
std::fabs(def->worldY - wy) > 0.5 ||
std::fabs(def->worldZ - wz) > 0.5;
const bool changed = moved ||
def->prefabPath != spawner.prefabPath ||
std::fabs(def->spawnDistance - sd) > 0.5f ||
std::fabs(def->despawnDistance - dd) > 0.5f;
if (!changed)
return;
SpawnerRegionDef nd = *def;
nd.prefabPath = spawner.prefabPath;
nd.worldX = wx;
nd.worldY = wy;
nd.worldZ = wz;
nd.spawnDistance = sd;
nd.despawnDistance = dd;
long npx, npy;
if (moved && pageForWorld(wx, wz, npx, npy) &&
(npx != tag.pageX || npy != tag.pageY)) {
/* Crossed a page boundary: move the def to the new
* page's file and re-tag the entity. */
m_regionStore.removeSpawnerById(tag.pageX, tag.pageY,
tag.defId);
nd.id = 0;
const uint64_t newId = m_regionStore.addSpawner(npx, npy, nd);
if (newId == 0)
return;
const uint64_t oldKey = SpawnerRegionStore::packPageKey(
tag.pageX, tag.pageY);
auto it = m_regionEntities.find(oldKey);
if (it != m_regionEntities.end()) {
std::vector<flecs::entity> &vec = it->second;
vec.erase(std::remove(vec.begin(), vec.end(), e),
vec.end());
}
m_regionEntities[SpawnerRegionStore::packPageKey(npx, npy)]
.push_back(e);
m_regionEntityDefIds[e.id()] = newId;
tag.pageX = npx;
tag.pageY = npy;
tag.defId = newId;
} else {
m_regionStore.updateSpawnerById(tag.pageX, tag.pageY,
tag.defId, nd);
}
/* Keep the authoritative world position in sync. */
if (e.has<TransformComponent>()) {
TransformComponent &xform = e.get_mut<TransformComponent>();
xform.worldX = wx;
xform.worldY = wy;
xform.worldZ = wz;
xform.hasWorldPosition = true;
}
}
@@ -6,6 +6,9 @@
#include <Ogre.h>
#include <string>
#include <unordered_map>
#include <vector>
#include "SpawnerRegionStore.hpp"
class EditorCameraSystem;
class EditorUISystem;
@@ -133,6 +136,23 @@ public:
flecs::entity createSpawnPoint(const std::string &prefabPath,
const Ogre::Vector3 &position);
/* --- Streamed region spawners (M4) --- */
SpawnerRegionStore &getRegionStore()
{
return m_regionStore;
}
/* Total number of region-store-owned spawner entities currently
* alive (for tests/diagnostics). */
size_t getStreamedSpawnerCount() const
{
size_t n = 0;
for (const auto &kv : m_regionEntities)
n += kv.second.size();
return n;
}
private:
void spawnPrefab(flecs::entity spawnerEntity,
const struct TerrainPrefabSpawnerComponent &spawner,
@@ -176,6 +196,42 @@ private:
* camera moved more than this many units since the last evaluation
* of that spawner (squared: 10^2, section 6.3 item 4). */
static constexpr float CAM_REEVAL_DIST_SQ = 10.0f * 10.0f;
/* --- Streamed region spawners (M4) ---
*
* When the active terrain streams (TerrainComponent::
* streamingEnabled), spawner definitions live in the region store
* (heightmaps/<terrainId>/spawners/<pageX>_<pageY>.json) and only
* spawners on loaded pages exist as entities (tagged
* StreamedSpawnerTag, excluded from scene serialization).
* Scene-embedded spawner entities keep working as
* "always-loaded" spawners for small scenes. */
SpawnerRegionStore m_regionStore;
std::string m_regionRoot;
/* Page key (SpawnerRegionStore::packPageKey) -> entities created
* for that page. */
std::unordered_map<uint64_t, std::vector<flecs::entity> >
m_regionEntities;
/* Entity id -> region def id, so defs of entities deleted through
* the editor (tag unreadable once dead) can be removed from the
* store. */
std::unordered_map<flecs::entity_t, uint64_t> m_regionEntityDefIds;
/* Guard so the OnRemove observer does not write to the store
* while WE tear down region entities on page unload. */
bool m_unloadingRegion = false;
void syncStreamedSpawners();
void ensureRegionStoreRoot();
flecs::entity createRegionEntity(const SpawnerRegionDef &def,
long pageX, long pageY);
void unloadRegionEntities(uint64_t pageKey);
void unloadAllRegionEntities();
/* Physical page containing world (wx, wz); returns false when no
* streaming terrain is active. */
bool pageForWorld(double wx, double wz, long &outX, long &outY) const;
void writeBackStreamedSpawner(flecs::entity e,
const struct TerrainPrefabSpawnerComponent &spawner,
const Ogre::Vector3 &renderPos);
};
#endif // EDITSCENE_TERRAINPREFABSPAWNERSYSTEM_HPP
File diff suppressed because it is too large Load Diff
+272 -16
View File
@@ -75,10 +75,11 @@ public:
float sampleHeightAtLocked(long worldX, long worldZ) const;
/* Base terrain height (base heightmap + detail noise) at integer
* world coordinates, ignoring the fixup layer. Used by road
* compliance falloff (M5.10) so fade targets blend toward the
* natural terrain height even where fixups were already written
* nearby. */
* PHYSICAL heightmap coordinates, ignoring the fixup layer. Used
* by road compliance falloff (M5.10) so fade targets blend toward
* the natural terrain height even where fixups were already written
* nearby. Convert visual world positions with visualToPhysicalX/Z
* before calling. */
float sampleBaseHeightAt(long worldX, long worldZ) const;
float sampleBaseLocked(long worldX, long worldZ) const;
void setHeightAt(long worldX, long worldZ, float value);
@@ -91,6 +92,13 @@ public:
/* Fixup chunks (M5.9.5): sparse absolute-height overrides layered
* on top of base heightmap + detail noise during sampling.
*
* COORDINATE SPACE: all fixup/sampling APIs here take PHYSICAL
* heightmap coordinates (the space fillPageHeightData samples in).
* Visual world positions (editor clicks, road nodes, prefab
* bounds) must be converted with visualToPhysicalX/Z first
* physical X is shifted by half a page, physical Z is mirrored
* within each page.
*
* Chunk grid: one chunk spans the full PER-PAGE world size
* (TerrainComponent::worldSize) and holds 256x256 samples, so one
* sample covers worldSize/256 units the same density as the base
@@ -115,9 +123,19 @@ public:
*
* clearAllFixups() deletes every fixup file and the in-memory chunks
* and marks all loaded pages dirty. */
void writeFixup(float worldX, float worldZ, float height);
void writeFixupSample(float worldX, float worldZ, float height);
void writeFixup(double worldX, double worldZ, float height);
void writeFixupSample(double worldX, double worldZ, float height);
float getFixupTexelSize() const;
/* Lowers the four fixup sample nodes that bilinearly drive the
* height sampled at (worldX, worldZ) by @p delta, so the sampled
* height anywhere inside that chunk cell drops by exactly @p delta.
* Unwritten (sentinel) nodes are first materialized at the natural
* (base + noise) height the bilinear reader would substitute.
* Used by the road-compliance relaxation pass to sink rendered
* page vertices that poke through the road surface. PHYSICAL
* coords. */
void lowerFixupCorners(double worldX, double worldZ, float delta);
bool saveFixups();
void clearAllFixups();
size_t getFixupChunkCount() const;
@@ -144,6 +162,27 @@ public:
computeDetailNoise(long worldX, long worldZ,
const struct TerrainComponent::DetailNoise &n) const;
/* --- Procedural base noise (world streaming groundwork) ---
* Evaluates TerrainComponent::BaseNoise at any PHYSICAL world
* coordinate via FastNoiseLite (OpenSimplex2, seeded, same
* octave/persistence/lacunarity pattern as computeDetailNoise).
* Used as the base height source when streamingEnabled is true. */
float
computeBaseNoise(long worldX, long worldZ,
const struct TerrainComponent::BaseNoise &n) const;
/* Fixup chunk cache cap (LRU). Chunks beyond the cap are evicted
* least-recently-used first; dirty chunks are flushed to disk before
* eviction. Exposed for tests. */
void setFixupChunkCap(size_t cap)
{
m_fixupChunkCap = cap < 1 ? 1 : cap;
}
size_t getFixupChunkCap() const
{
return m_fixupChunkCap;
}
/* --- Sculpting (M3) --- */
enum class SculptTool { Raise, Lower, Smooth, Flatten };
bool getSculptMode() const
@@ -177,7 +216,14 @@ public:
m_sculptStrength = s;
}
void snapCameraAboveTerrain();
/* Despite the parameter name, the Vector3 overload forwards x/z
* straight to the Physical variant: PHYSICAL heightmap coords
* (legacy convention prefer applySculptBrushPhysical). */
void applySculptBrush(const Ogre::Vector3 &worldPos);
/* Double-precision physical-coords variant for the streaming
* world; the Vector3 overload loses ~4 units of precision at the
* far end of the 40,000,000-unit world. */
void applySculptBrushPhysical(double physX, double physZ);
/* --- Brush falloff shape (M4.3) --- */
enum class BrushFalloffShape {
@@ -200,10 +246,17 @@ public:
static float evaluateBrushFalloff(float distance, float radius,
BrushFalloffShape shape);
/* --- Aux-map editing (M4.4) --- */
/* --- Aux-map editing (M4.4) ---
* The Vector3 overload takes PHYSICAL heightmap coords (same legacy
* convention as applySculptBrush); sampleAuxMap takes PHYSICAL
* coords too. */
void applyAuxBrush(const std::string &auxMapName,
const Ogre::Vector3 &worldPos, float radius,
float delta);
/* Double-precision physical-coords variant (streaming world). */
void applyAuxBrushPhysical(const std::string &auxMapName,
double physX, double physZ, float radius,
float delta);
float sampleAuxMap(const std::string &auxMapName, long worldX,
long worldZ) const;
bool saveSceneAuxMaps(const struct TerrainComponent &tc);
@@ -260,6 +313,9 @@ public:
{
m_paintStrength = s;
}
/* Takes RENDER-space coords (blend-map texel mapping is page-local
* via TerrainGroup::convertWorldPositionToTerrainSlot against the
* render-space group origin). */
void applySplatBrush(const Ogre::Vector3 &worldPos);
/* --- Composite-map batching (M4.5) --- */
@@ -363,19 +419,76 @@ public:
return m_camera;
}
/* --- Streaming window introspection (M1b; tests) --- */
size_t getColliderCount() const
{
return mColliders.size();
}
bool getStreamingActive() const
{
return m_streamingActive;
}
long getStreamingPageCount() const
{
return m_streamWorldPages;
}
/* Page slot containing the streaming camera, clamped to
* [0, m_streamWorldPages-1]. Falls back to the group origin when
* no camera is available. */
void streamingCameraPage(long *outX, long *outY) const;
/* Physical page (x, y) lives in TerrainGroup slot (x, -y) because
* ALIGN_X_Z negates Z. */
bool isPageLoaded(long x, long y) const
{
if (!mTerrainGroup)
return false;
Ogre::Terrain *t = mTerrainGroup->getTerrain(x, -y);
return t && t->isLoaded();
}
/* --- Brush decal (M3) --- */
void updateBrushDecal(const Ogre::Vector3 &worldPos,
const Ogre::Vector3 &normal, float radius);
void hideBrushDecal();
/* Convert physical heightmap X to visual display X.
/* --- Coordinate spaces (M2) ---
*
* WORLD space: absolute double-precision world coordinates (up to
* 40,000,000 x 40,000,000 units). Terrain heightmap/physical
* coordinates are world coords relative to the terrain origin,
* i.e. physical = world - worldOrigin (+ the half-page shift and
* Z mirror below).
*
* RENDER space: float scene-graph coordinates around the floating
* render origin (RenderOriginSystem); render = world -
* renderOrigin. Most public position APIs of TerrainSystem
* (getHeightAt, raycastTerrain, brush decal, command queue,
* applySplatBrush) take RENDER space; conversion to world happens
* internally. Exception: the sculpt/aux brush apply APIs take
* PHYSICAL heightmap coords (see their declarations). */
/* Convert a render-space position to absolute world space. */
double renderToWorldX(float renderX) const;
double renderToWorldY(float renderY) const;
double renderToWorldZ(float renderZ) const;
Ogre::Vector3 worldToRender(double worldX, double worldY,
double worldZ) const;
/* Called by RenderOriginSystem after a rebase: @p delta is the
* world-space delta of the render origin (newOrigin - oldOrigin).
* Repositions the terrain group and all terrain-owned scene
* nodes into the new render space. */
void onRenderOriginChanged(const Ogre::Vector3 &delta);
/* Convert physical heightmap X to visual WORLD X.
* Terrain pages render column i at X = i*step - halfSize
* but the height data lives at X = pageMinX + i*step.
* Same for Z but Z-inverted (see physicalToVisualZ). */
float physicalToVisualX(float physicalX) const;
double physicalToVisualX(double physicalX) const;
/* Inverse: physical X corresponding to a given visual world X. */
float visualToPhysicalX(float visualX) const;
double visualToPhysicalX(double visualX) const;
/* Half the terrain world size (for coordinate math). */
float getTerrainWorldHalfSize() const;
@@ -385,14 +498,25 @@ public:
return mTerrainGroup;
}
/* Convert physical heightmap Z to visual display Z.
Ogre::SceneManager *getSceneManager() const
{
return m_sceneMgr;
}
/* Entity id of the active terrain (0 when none). */
flecs::entity_t getTerrainEntityId() const
{
return m_terrainEntityId;
}
/* Convert physical heightmap Z to visual WORLD Z.
* Terrain pages render row j at Z = pageMinZ + halfSize - j*step
* but the height data lives at Z = pageMinZ + j*step. */
float physicalToVisualZ(float physicalZ) const;
double physicalToVisualZ(double physicalZ) const;
/* Inverse: physical Z that maps to the given visual world Z.
* Uses visual page indexing, unlike physicalToVisualZ. */
float visualToPhysicalZ(float visualZ) const;
double visualToPhysicalZ(double visualZ) const;
TerrainCommandQueue &getCommandQueue()
{
@@ -410,9 +534,29 @@ private:
void updatePageGeometry(long pageX, long pageY);
void fillPageHeightData(Ogre::TerrainGroup *group, long x, long y,
float *heightMap);
long worldToPage(float worldCoord, float worldSize) const;
long worldToPage(double worldCoord, double worldSize) const;
void ensurePageColliders();
/* Streaming page window (M1b). Active only when the terrain was
* activated with TerrainComponent::streamingEnabled = true
* (m_streamingActive). The world is absolute and bounded, indexed
* in PHYSICAL page coords: page (x,z) covers the physical heightmap
* rect [x*worldSize, (x+1)*worldSize) x [z*worldSize,
* (z+1)*worldSize) with x,z in [0, m_streamWorldPages-1]; nothing
* outside that range is ever defined. Because TerrainGroup uses
* ALIGN_X_Z (which negates Z), the Ogre slot for physical page
* (x, z) is (x, -z). The TerrainGroup origin stays at the terrain
* entity's TransformComponent position, so the existing
* visualToPhysicalX/Z conversions apply unchanged. */
void updateStreamingWindow();
/* Both take PHYSICAL page coords in [0, m_streamWorldPages-1]. */
void streamLoadPage(long x, long y);
void applySculptBrushStreaming(double physX, double physZ);
/* Per-frame amortization for page streaming. */
static constexpr int STREAM_LOADS_PER_FRAME = 2;
static constexpr int STREAM_UNLOADS_PER_FRAME = 4;
struct TerrainCollider {
long pageX, pageY;
JPH::BodyID bodyId;
@@ -546,14 +690,93 @@ private:
* helpers do not touch ECS state while the heightmap mutex is held. */
struct TerrainComponent::DetailNoise m_detailNoise;
/* Streaming state (world streaming groundwork). Local copies of the
* TerrainComponent streaming fields, synced in activate()/update().
* When m_streamingEnabled is true the base height comes from
* m_baseNoise evaluated on demand instead of the m_heightData
* buffer. */
bool m_streamingEnabled = false;
struct TerrainComponent::BaseNoise m_baseNoise;
/* Streaming page window runtime state (M1b). m_streamingActive is
* latched in activate() from tc.streamingEnabled the update-time
* sync of m_streamingEnabled alone must not start the window on a
* legacy-activated terrain. m_streamWorldPages is the number of
* pages per axis (N), clamped so TerrainGroup::packIndex stays
* valid (slots are signed 16-bit). m_pageLoadRadius /
* m_pageHoldRadius mirror the component and are re-synced every
* frame so editor tweaks apply live. */
bool m_streamingActive = false;
long m_streamWorldPages = 1;
int m_pageLoadRadius = 2;
int m_pageHoldRadius = 3;
/* Render-space origin of the TerrainGroup (world origin - render
* origin). Refreshed from the double origins below on every
* rebase; after TerrainGroup::setOrigin() the loaded page
* positions are re-set precisely from doubles by
* repositionLoadedPages() because Ogre's slot position math is
* single-precision. */
Ogre::Vector3 m_groupOrigin = Ogre::Vector3::ZERO;
/* Absolute double-precision world-space origin of the terrain
* (latched in activate() from the terrain entity's transform) and
* the current render origin (tracked via onRenderOriginChanged).
* All heightmap/physical coordinate math is done relative to
* m_worldOrigin* in double precision. */
double m_worldOriginX = 0.0;
double m_worldOriginY = 0.0;
double m_worldOriginZ = 0.0;
double m_renderOriginX = 0.0;
double m_renderOriginY = 0.0;
double m_renderOriginZ = 0.0;
void repositionLoadedPages();
/* Brush falloff shape (M4.3). Runtime editor preference, not serialized. */
BrushFalloffShape m_brushFalloffShape = BrushFalloffShape::Linear;
/* Aux-map state (M4.4). Cached in memory while terrain is active. */
/* Aux-map state (M4.4). Cached in memory while terrain is active.
* Legacy mode (streamingEnabled=false) only: one whole-terrain map
* per aux map name. */
mutable std::unordered_map<std::string, std::vector<float> >
m_auxMapData;
std::unordered_set<std::string> m_dirtyAuxMaps;
/* Streaming aux maps (M1c): per-page chunks of resolution^2 floats
* covering one page each, stored under
* heightmaps/<terrainId>/aux/<name>/x<px>_z<pz>.bin using PHYSICAL
* page coords. Guarded by m_heightmapMutex; lazy-loaded from disk,
* LRU-evicted with save-if-dirty (same policy as fixup chunks). */
struct AuxChunk {
std::vector<float> data;
bool dirty = false;
mutable uint64_t lastAccess = 0;
};
mutable std::map<std::pair<std::string, uint64_t>, AuxChunk>
m_auxChunks;
std::string m_auxChunkDir;
size_t m_auxChunkCap = 64;
std::string auxChunkPath(const std::string &auxName, long px,
long pz) const;
const AuxChunk *
findAuxChunkLocked(const struct TerrainComponent::AuxMap &aux, long px,
long pz) const;
AuxChunk *
getAuxChunkForWriteLocked(const struct TerrainComponent::AuxMap &aux,
long px, long pz);
void evictAuxChunksLocked() const;
void saveAuxChunkLocked(const std::pair<std::string, uint64_t> &key,
AuxChunk &chunk) const;
/* Streaming blend maps (M1c): slot keys (packIndex) painted since
* the last save. Pages are saved on unload and on saveBlendMaps. */
mutable std::set<uint64_t> m_dirtyBlendPages;
std::string m_blendMapDir;
void savePageBlendMaps(const std::string &dirPath, long slotX,
long slotY) const;
void loadPageBlendMaps(const std::string &dirPath, long slotX,
long slotY);
/* Fixup chunks (M5.9.5): sparse absolute-height overrides stored as
* 256x256 float chunks under heightmaps/<terrainId>/terrain_fixup/.
* Guarded by m_heightmapMutex; loaded lazily from disk on first
@@ -561,6 +784,9 @@ private:
struct FixupChunk {
std::vector<float> samples;
bool dirty = false;
/* LRU stamp; bumped on every access while the heightmap
* mutex is held. */
mutable uint64_t lastAccess = 0;
};
static constexpr int FIXUP_CHUNK_RES = 256;
static constexpr float FIXUP_SENTINEL = -FLT_MAX;
@@ -575,12 +801,42 @@ private:
mutable std::map<std::pair<int, int>, FixupChunk> m_fixupChunks;
std::string m_fixupDir;
float sampleFixupLocked(float worldX, float worldZ) const;
/* Fixup chunk LRU eviction. m_fixupAccessCounter is a monotone
* stamp assigned on every chunk access; evictFixupChunksLocked()
* drops the lowest-stamp chunks (flushing dirty ones to disk) once
* the cache exceeds m_fixupChunkCap. */
mutable uint64_t m_fixupAccessCounter = 0;
size_t m_fixupChunkCap = 64;
float sampleFixupLocked(double worldX, double worldZ) const;
const FixupChunk *findFixupChunkLocked(int chunkX, int chunkZ) const;
void touchFixupChunkLocked(const FixupChunk &chunk) const;
void evictFixupChunksLocked() const;
void saveFixupChunkLocked(int chunkX, int chunkZ,
FixupChunk &chunk) const;
bool loadFixupChunk(int chunkX, int chunkZ,
std::vector<float> &out) const;
std::string fixupChunkPath(int chunkX, int chunkZ) const;
/* View settings driven by the active terrain (far clip + fog).
* Applied in activate() and re-checked in update() when the
* component values change; the previous camera/scene state is
* restored on deactivate(). */
bool m_savedViewValid = false;
float m_savedFarClip = 0.0f;
Ogre::FogMode m_savedFogMode = Ogre::FOG_NONE;
Ogre::ColourValue m_savedFogColour = Ogre::ColourValue::Black;
float m_savedFogDensity = 0.0f;
float m_savedFogStart = 0.0f;
float m_savedFogEnd = 0.0f;
float m_appliedFarClip = -1.0f;
bool m_appliedFog = false;
float m_appliedFogStart = -1.0f;
float m_appliedFogEnd = -1.0f;
void applyViewSettings(const struct TerrainComponent &tc);
void restoreViewSettings();
const std::vector<float> *
ensureAuxMapLoaded(const struct TerrainComponent::AuxMap &aux,
const std::string &path) const;
File diff suppressed because it is too large Load Diff
@@ -80,9 +80,41 @@ private:
static bool testFixupChunks(EditorApp &app, TerrainSystem *ts);
static bool testRoadEdgeLengthConstraint(EditorApp &app, TerrainSystem *ts);
static bool testTerrainCompliance(EditorApp &app, TerrainSystem *ts);
static bool testTerrainComplianceClearance(EditorApp &app,
TerrainSystem *ts);
static bool testComplyRoadsToTerrain(EditorApp &app,
TerrainSystem *ts);
static bool testRoadColliderInteraction(EditorApp &app, TerrainSystem *ts);
static bool testRoadSidePrefabs(EditorApp &app, TerrainSystem *ts);
static bool testTerrainPrefabSpawners(EditorApp &app, TerrainSystem *ts);
static bool testStreamingProceduralBase(EditorApp &app,
TerrainSystem *ts);
static bool testFixupChunkLRU(EditorApp &app, TerrainSystem *ts);
static bool testGetHeightAtFarFallback(EditorApp &app,
TerrainSystem *ts);
static bool testStreamingSerialization(EditorApp &app,
TerrainSystem *ts);
static bool testStreamingWindowFollowsCamera(EditorApp &app,
TerrainSystem *ts);
static bool testStreamingWorldBounds(EditorApp &app,
TerrainSystem *ts);
static bool testStreamingSculptWritesFixups(EditorApp &app,
TerrainSystem *ts);
static bool testStreamingBlendMapsSurviveUnload(EditorApp &app,
TerrainSystem *ts);
static bool testStreamingAuxMapPerPage(EditorApp &app,
TerrainSystem *ts);
static bool testRenderOriginBasics(EditorApp &app, TerrainSystem *ts);
static bool testStreamingFarCorner(EditorApp &app, TerrainSystem *ts);
static bool testBookmarkRoundTrip(EditorApp &app, TerrainSystem *ts);
static bool testWorldMapTransforms(EditorApp &app, TerrainSystem *ts);
static bool testNavigationTeleport(EditorApp &app, TerrainSystem *ts);
static bool testSpawnerRegionStore(EditorApp &app, TerrainSystem *ts);
static bool testStreamedSpawnerWindow(EditorApp &app, TerrainSystem *ts);
static bool testPrefabJsonCache(EditorApp &app, TerrainSystem *ts);
static bool testRoadRegionStore(EditorApp &app, TerrainSystem *ts);
static bool testRoadRegionStreaming(EditorApp &app, TerrainSystem *ts);
static bool testRoadRegionMigration(EditorApp &app, TerrainSystem *ts);
static bool testMemoryStability(EditorApp &app);
static void logResult(const TerrainTestResult &r);
@@ -0,0 +1,171 @@
#ifndef EDITSCENE_WORLDMAPDATA_HPP
#define EDITSCENE_WORLDMAPDATA_HPP
#pragma once
#include "TerrainSystem.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
/**
* View state and base-height cache for the world map panel (M3.4).
*
* ImGui-free so headless terrain tests can verify the transforms.
* All coordinates are double WORLD space; the panel converts to
* canvas pixels through worldToMap()/mapToWorld().
*
* Screen mapping: canvas +x = world +x, canvas +y (down) = world +z.
*/
class WorldMapData {
public:
/* World position shown at the canvas centre. */
double centerX = 0.0;
double centerZ = 0.0;
/* Zoom level in world units per canvas pixel. */
double metersPerPixel = 1000.0;
static constexpr double MIN_METERS_PER_PIXEL = 0.25;
static constexpr double MAX_METERS_PER_PIXEL = 1000000.0;
void worldToMap(double wx, double wz, double canvasW, double canvasH,
float &outX, float &outY) const
{
outX = (float)(canvasW * 0.5 +
(wx - centerX) / metersPerPixel);
outY = (float)(canvasH * 0.5 +
(wz - centerZ) / metersPerPixel);
}
void mapToWorld(double mx, double my, double canvasW, double canvasH,
double &outX, double &outZ) const
{
outX = centerX + (mx - canvasW * 0.5) * metersPerPixel;
outZ = centerZ + (my - canvasH * 0.5) * metersPerPixel;
}
void pan(double dxPixels, double dyPixels)
{
centerX -= dxPixels * metersPerPixel;
centerZ -= dyPixels * metersPerPixel;
}
/* Zoom keeping the world point under canvas pixel (mx, my)
* fixed. Positive wheelY zooms in. */
void zoomAt(double mx, double my, double canvasW, double canvasH,
double wheelY)
{
if (wheelY == 0.0)
return;
double wx, wz;
mapToWorld(mx, my, canvasW, canvasH, wx, wz);
metersPerPixel *= std::pow(0.8, wheelY);
metersPerPixel = std::max(MIN_METERS_PER_PIXEL,
std::min(MAX_METERS_PER_PIXEL,
metersPerPixel));
centerX = wx - (mx - canvasW * 0.5) * metersPerPixel;
centerZ = wz - (my - canvasH * 0.5) * metersPerPixel;
}
/* Fit the whole streamed world into the canvas. */
void fitWorld(TerrainSystem *ts, double canvasW, double canvasH)
{
if (!ts || !ts->getTerrainGroup())
return;
const double ws =
ts->getTerrainGroup()->getTerrainWorldSize();
const long pages = ts->getStreamingPageCount();
/* World centre of page 0 sits at the world origin (see
* physicalToVisualX/Z); the last page centre is
* (pages-1)*ws further out on both axes. */
const double x0 = ts->physicalToVisualX(0.5 * ws);
const double z0 = ts->physicalToVisualZ(0.5 * ws);
centerX = x0 + (double)(pages - 1) * 0.5 * ws;
centerZ = z0 + (double)(pages - 1) * 0.5 * ws;
const double span = ws * (double)pages;
const double dim = std::min(canvasW, canvasH);
if (dim > 0.0)
metersPerPixel = span / dim;
metersPerPixel = std::max(MIN_METERS_PER_PIXEL,
std::min(MAX_METERS_PER_PIXEL,
metersPerPixel));
}
/* Coarse base-height cache over the currently visible rect,
* rebuilt on demand (view change or explicit refresh). Row-major
* m_gridRes^2 entries; cell (ix, iy) maps back to canvas pixels
* as rect [ix*canvasW/res, (ix+1)*canvasW/res) x [iy*canvasH/res,
* (iy+1)*canvasH/res). */
void rebuildHeightCache(TerrainSystem *ts, double canvasW,
double canvasH, int gridRes)
{
m_gridRes = std::max(8, std::min(256, gridRes));
m_heights.clear();
m_minHeight = 0.0f;
m_maxHeight = 0.0f;
if (!ts || !ts->getTerrainGroup() || canvasW <= 0.0 ||
canvasH <= 0.0)
return;
const double spanX = canvasW * metersPerPixel;
const double spanZ = canvasH * metersPerPixel;
const double minX = centerX - spanX * 0.5;
const double minZ = centerZ - spanZ * 0.5;
const int res = m_gridRes;
m_heights.resize((size_t)res * res);
float lo = 0.0f, hi = 0.0f;
for (int iy = 0; iy < res; ++iy) {
const double wz =
minZ + ((double)iy + 0.5) * spanZ / res;
const long pz =
(long)std::floor(ts->visualToPhysicalZ(wz));
for (int ix = 0; ix < res; ++ix) {
const double wx = minX + ((double)ix + 0.5) *
spanX / res;
const long px = (long)std::floor(
ts->visualToPhysicalX(wx));
const float h = ts->sampleBaseHeightAt(px, pz);
m_heights[(size_t)iy * res + ix] = h;
if (ix == 0 && iy == 0) {
lo = h;
hi = h;
} else {
lo = std::min(lo, h);
hi = std::max(hi, h);
}
}
}
m_minHeight = lo;
m_maxHeight = hi;
}
bool hasCache() const
{
return !m_heights.empty();
}
int getGridRes() const
{
return m_gridRes;
}
float getCachedHeight(int ix, int iy) const
{
return m_heights[(size_t)iy * m_gridRes + ix];
}
float getCacheMinHeight() const
{
return m_minHeight;
}
float getCacheMaxHeight() const
{
return m_maxHeight;
}
private:
int m_gridRes = 64;
std::vector<float> m_heights;
float m_minHeight = 0.0f;
float m_maxHeight = 0.0f;
};
#endif // EDITSCENE_WORLDMAPDATA_HPP
@@ -0,0 +1,223 @@
#ifndef EDITSCENE_NAVIGATIONPANEL_HPP
#define EDITSCENE_NAVIGATIONPANEL_HPP
#pragma once
#include "WorldBookmark.hpp"
#include "../camera/EditorCamera.hpp"
#include "../systems/RenderOriginSystem.hpp"
#include "../systems/TerrainSystem.hpp"
#include <imgui.h>
#include <string>
#include <vector>
/**
* Navigation panel (M3): teleport the editor camera anywhere in the
* streamed world by world coordinates or terrain page index, and
* manage named world bookmarks.
*
* All teleport math is done in double WORLD space and converted
* through RenderOriginSystem, so jumps to the far corner of a
* 40,000,000 x 40,000,000 world stay exact.
*
* The teleport helpers are static and ImGui-free so headless tests
* and the TerrainEditor "Navigation" section can share the same
* backend. When no EditorCamera is supplied they move the
* "EditorCameraTarget" scene node directly.
*/
class NavigationPanel {
public:
void setEditorCamera(EditorCamera *cam)
{
m_editorCamera = cam;
}
const std::vector<WorldBookmark> &getBookmarks() const
{
return m_bookmarks;
}
void setBookmarks(const std::vector<WorldBookmark> &bookmarks)
{
m_bookmarks = bookmarks;
}
void addBookmark(const std::string &name, double x, double y,
double z)
{
WorldBookmark b;
b.name = name.empty() ? "Bookmark" : name;
b.x = x;
b.y = y;
b.z = z;
m_bookmarks.push_back(b);
}
/* Move the editor camera (or, headless / no camera, the
* "EditorCameraTarget" node) so its orbit target sits at world
* (wx, wy, wz), then snap it above the terrain. */
static void teleportCamera(double wx, double wy, double wz,
EditorCamera *cam)
{
TerrainSystem *ts = TerrainSystem::getInstance();
RenderOriginSystem *ro = RenderOriginSystem::getInstance();
if (!ts)
return;
Ogre::Vector3 rp;
if (ro)
rp = ro->worldToRender(wx, wy, wz);
else
rp = ts->worldToRender(wx, wy, wz);
if (cam) {
cam->setPosition(rp);
} else if (ts->getSceneManager()) {
Ogre::SceneManager *sm = ts->getSceneManager();
Ogre::SceneNode *node = nullptr;
if (sm->hasSceneNode("EditorCameraTarget"))
node = sm->getSceneNode("EditorCameraTarget");
else if (sm->hasSceneNode("EditorCameraTargetNode"))
node = sm->getSceneNode(
"EditorCameraTargetNode");
if (node)
node->setPosition(rp);
}
ts->snapCameraAboveTerrain();
}
/* Teleport to the centre of physical terrain page (px, pz),
* clamped to [0, pages-1]. Y is snapped above the terrain. */
static void teleportToPage(long px, long pz, EditorCamera *cam)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts || !ts->getTerrainGroup())
return;
long pages = ts->getStreamingPageCount();
if (pages < 1)
pages = 1;
if (px < 0)
px = 0;
if (pz < 0)
pz = 0;
if (px > pages - 1)
px = pages - 1;
if (pz > pages - 1)
pz = pages - 1;
const double ws = ts->getTerrainGroup()->getTerrainWorldSize();
const double wx =
ts->physicalToVisualX(((double)px + 0.5) * ws);
const double wz =
ts->physicalToVisualZ(((double)pz + 0.5) * ws);
teleportCamera(wx, 0.0, wz, cam);
}
void render(bool *open)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ImGui::Begin("Navigation", open)) {
ImGui::End();
return;
}
if (ImGui::CollapsingHeader("Teleport",
ImGuiTreeNodeFlags_DefaultOpen)) {
renderTeleportSection(ts);
}
if (ImGui::CollapsingHeader("Bookmarks",
ImGuiTreeNodeFlags_DefaultOpen)) {
renderBookmarkSection();
}
ImGui::End();
}
private:
void readCameraPosition()
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return;
Ogre::Vector3 p(0, 0, 0);
if (m_editorCamera) {
p = m_editorCamera->getTarget();
} else if (ts->getSceneManager()) {
Ogre::SceneManager *sm = ts->getSceneManager();
if (sm->hasSceneNode("EditorCameraTarget"))
p = sm->getSceneNode("EditorCameraTarget")
->getPosition();
}
m_teleX = ts->renderToWorldX(p.x);
m_teleY = ts->renderToWorldY(p.y);
m_teleZ = ts->renderToWorldZ(p.z);
}
void renderTeleportSection(TerrainSystem *ts)
{
ImGui::InputDouble("X", &m_teleX, 100.0, 10000.0, "%.1f");
ImGui::InputDouble("Y", &m_teleY, 10.0, 1000.0, "%.1f");
ImGui::InputDouble("Z", &m_teleZ, 100.0, 10000.0, "%.1f");
if (ImGui::Button("Teleport"))
teleportCamera(m_teleX, m_teleY, m_teleZ,
m_editorCamera);
ImGui::SameLine();
if (ImGui::Button("Current Position"))
readCameraPosition();
if (ts && ts->getStreamingActive()) {
ImGui::Separator();
long cpx, cpz;
ts->streamingCameraPage(&cpx, &cpz);
ImGui::Text("Camera page: %ld, %ld (of %ld x %ld)",
cpx, cpz, ts->getStreamingPageCount(),
ts->getStreamingPageCount());
ImGui::InputInt("Page X", &m_pageX);
ImGui::InputInt("Page Z", &m_pageZ);
if (ImGui::Button("Teleport To Page"))
teleportToPage(m_pageX, m_pageZ,
m_editorCamera);
}
}
void renderBookmarkSection()
{
ImGui::InputText("Name", m_bookmarkName,
sizeof(m_bookmarkName));
ImGui::SameLine();
if (ImGui::Button("Add##bookmark")) {
readCameraPosition();
addBookmark(m_bookmarkName, m_teleX, m_teleY, m_teleZ);
m_bookmarkName[0] = '\0';
}
for (size_t i = 0; i < m_bookmarks.size(); ++i) {
const WorldBookmark &b = m_bookmarks[i];
ImGui::PushID((int)i);
if (ImGui::Button("Go"))
teleportCamera(b.x, b.y, b.z, m_editorCamera);
ImGui::SameLine();
if (ImGui::Button("Del")) {
m_bookmarks.erase(m_bookmarks.begin() + i);
ImGui::PopID();
break;
}
ImGui::SameLine();
ImGui::Text("%s (%.0f, %.0f, %.0f)", b.name.c_str(),
b.x, b.y, b.z);
ImGui::PopID();
}
}
EditorCamera *m_editorCamera = nullptr;
double m_teleX = 0.0;
double m_teleY = 100.0;
double m_teleZ = 0.0;
int m_pageX = 0;
int m_pageZ = 0;
char m_bookmarkName[128] = "";
std::vector<WorldBookmark> m_bookmarks;
};
#endif // EDITSCENE_NAVIGATIONPANEL_HPP
@@ -10,6 +10,7 @@
#include "../systems/RoadSystem.hpp"
#include "../systems/TerrainPrefabSpawnerSystem.hpp"
#include "../systems/PrefabSystem.hpp"
#include "NavigationPanel.hpp"
#include <OgreResourceGroupManager.h>
#include <OgreTextureManager.h>
@@ -298,6 +299,35 @@ public:
ImGui::Separator();
/* --- Navigation (M3): teleport by world coords or page,
* sharing the NavigationPanel backend. The full panel
* (with bookmarks) lives under Tools -> Navigation. --- */
if (ts && ImGui::CollapsingHeader("Navigation")) {
ImGui::InputDouble("Nav X", &m_navX, 100.0, 10000.0,
"%.1f");
ImGui::InputDouble("Nav Z", &m_navZ, 100.0, 10000.0,
"%.1f");
if (ImGui::Button("Teleport Here (Y snapped)"))
NavigationPanel::teleportCamera(m_navX, 0.0,
m_navZ,
nullptr);
if (ts->getStreamingActive()) {
long cpx, cpz;
ts->streamingCameraPage(&cpx, &cpz);
ImGui::Text("Camera page: %ld, %ld (of %ld)",
cpx, cpz,
ts->getStreamingPageCount());
ImGui::InputInt("Page X", &m_navPageX);
ImGui::InputInt("Page Z", &m_navPageZ);
if (ImGui::Button("Teleport To Page"))
NavigationPanel::teleportToPage(
m_navPageX, m_navPageZ,
nullptr);
}
}
ImGui::Separator();
/* --- Camera & file operations --- */
if (ts) {
if (ImGui::Button("Snap Camera Above Terrain"))
@@ -497,6 +527,12 @@ public:
}
private:
/* Navigation section state (M3). */
double m_navX = 0.0;
double m_navZ = 0.0;
int m_navPageX = 0;
int m_navPageZ = 0;
/* Scan the prefabs directory once (or on Refresh) for the placement
* prefab picker. */
static void scanPrefabFiles(std::vector<std::string> &out)
@@ -822,6 +858,22 @@ private:
"Makes the terrain surface follow the road underside "
"through fixup height-override chunks.");
/* Inverse operation: move road nodes vertically so the
* road edges stay above the terrain plus the slab
* thickness and a small margin. */
if (ImGui::Button("Comply Roads to Terrain")) {
RoadSystem *rs = ts->getRoadSystem();
if (rs)
rs->complyRoadsToTerrain(
ts,
tc.roadGraph.config.roadThickness +
0.05f);
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip(
"Shifts road nodes up/down so road edges stay above "
"the terrain surface plus the road thickness.");
/* Clear all fixup chunks (M5.9.5). */
if (ImGui::Button("Clear All Fixups")) {
ts->clearAllFixups();
@@ -0,0 +1,22 @@
#ifndef EDITSCENE_WORLDBOOKMARK_HPP
#define EDITSCENE_WORLDBOOKMARK_HPP
#pragma once
#include <string>
/**
* World bookmark: a named absolute WORLD-space camera position.
*
* Persisted in the scene JSON ("bookmarks" array) so editors can
* jump back to points of interest across sessions. Kept in its own
* header (no ImGui) so SceneSerializer can store them without
* pulling in UI dependencies.
*/
struct WorldBookmark {
std::string name;
double x = 0.0;
double y = 0.0;
double z = 0.0;
};
#endif // EDITSCENE_WORLDBOOKMARK_HPP
+418
View File
@@ -0,0 +1,418 @@
#ifndef EDITSCENE_WORLDMAPPANEL_HPP
#define EDITSCENE_WORLDMAPPANEL_HPP
#pragma once
#include "NavigationPanel.hpp"
#include "../systems/WorldMapData.hpp"
#include "../systems/RoadSystem.hpp"
#include "../components/Terrain.hpp"
#include "../components/TerrainPrefabSpawner.hpp"
#include "../components/Transform.hpp"
#include <imgui.h>
#include <algorithm>
#include <vector>
/**
* World map panel (M3.4): a 2D top-down map of the streamed world.
*
* Data-driven (no render-to-texture): coarse base-height shading is
* sampled from TerrainSystem into WorldMapData's cache, and overlays
* (loaded pages, roads, prefab spawners, bookmarks, camera marker)
* are drawn as ImGui primitives. Interactions: left-drag pans,
* mouse wheel zooms about the cursor, double-click teleports the
* editor camera through the NavigationPanel backend.
*
* All math is double WORLD space via WorldMapData, so the map stays
* exact at the far corners of a 40,000,000 x 40,000,000 world.
*/
class WorldMapPanel {
public:
void setEditorCamera(EditorCamera *cam)
{
m_editorCamera = cam;
}
void setWorld(flecs::world *world)
{
m_world = world;
}
/* Points at NavigationPanel's bookmark list (stays valid; the
* list is a stable member of EditorUISystem's panel). */
void setBookmarks(const std::vector<WorldBookmark> *bookmarks)
{
m_bookmarks = bookmarks;
}
WorldMapData &getData()
{
return m_data;
}
void render(bool *open)
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ImGui::Begin("World Map", open)) {
ImGui::End();
return;
}
renderToolbar(ts);
float canvasW = std::max(200.0f, ImGui::GetContentRegionAvail().x);
float canvasH = std::max(200.0f, std::min(canvasW, 1024.0f));
canvasW = canvasH;
ImVec2 origin = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("##worldmapcanvas",
ImVec2(canvasW, canvasH));
const bool hovered = ImGui::IsItemHovered();
ImDrawList *dl = ImGui::GetWindowDrawList();
if (m_heightsDirty && m_showHeights) {
m_data.rebuildHeightCache(ts, canvasW, canvasH,
m_gridRes);
m_heightsDirty = false;
}
dl->AddRectFilled(origin,
ImVec2(origin.x + canvasW, origin.y + canvasH),
IM_COL32(12, 12, 16, 255));
if (m_showHeights)
drawHeights(dl, origin, canvasW, canvasH);
if (m_showPages)
drawLoadedPages(dl, ts, origin, canvasW, canvasH);
if (m_showRoads)
drawRoads(dl, ts, origin, canvasW, canvasH);
if (m_showSpawners)
drawSpawners(dl, ts, origin, canvasW, canvasH);
if (m_showBookmarks)
drawBookmarks(dl, origin, canvasW, canvasH);
drawCameraMarker(dl, ts, origin, canvasW, canvasH);
handleInput(origin, canvasW, canvasH, hovered);
ImGui::End();
}
private:
void renderToolbar(TerrainSystem *ts)
{
if (ImGui::Button("Center On Camera"))
centerOnCamera(ts);
ImGui::SameLine();
if (ImGui::Button("Fit World")) {
m_data.fitWorld(ts, 512.0, 512.0);
m_heightsDirty = true;
}
ImGui::SameLine();
if (ImGui::Button("Refresh Heights"))
m_heightsDirty = true;
ImGui::Checkbox("Heights", &m_showHeights);
ImGui::SameLine();
ImGui::Checkbox("Pages", &m_showPages);
ImGui::SameLine();
ImGui::Checkbox("Roads", &m_showRoads);
ImGui::SameLine();
ImGui::Checkbox("Spawners", &m_showSpawners);
ImGui::SameLine();
ImGui::Checkbox("Bookmarks", &m_showBookmarks);
if (ImGui::SliderInt("Height Detail", &m_gridRes, 16, 128))
m_heightsDirty = true;
ImGui::Text("Zoom: %.1f u/px Centre: %.0f, %.0f",
m_data.metersPerPixel, m_data.centerX,
m_data.centerZ);
ImGui::TextDisabled(
"Drag: pan Wheel: zoom Double-click: teleport");
}
void handleInput(const ImVec2 &origin, float canvasW, float canvasH,
bool hovered)
{
ImGuiIO &io = ImGui::GetIO();
if (ImGui::IsItemActive() &&
ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
m_data.pan(io.MouseDelta.x, io.MouseDelta.y);
m_heightsDirty = true;
}
if (hovered && io.MouseWheel != 0.0f) {
m_data.zoomAt(io.MousePos.x - origin.x,
io.MousePos.y - origin.y, canvasW,
canvasH, io.MouseWheel);
m_heightsDirty = true;
}
if (hovered &&
ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
double wx, wz;
m_data.mapToWorld(io.MousePos.x - origin.x,
io.MousePos.y - origin.y, canvasW,
canvasH, wx, wz);
NavigationPanel::teleportCamera(wx, 0.0, wz,
m_editorCamera);
}
}
bool getCameraWorld(double &wx, double &wy, double &wz) const
{
TerrainSystem *ts = TerrainSystem::getInstance();
if (!ts)
return false;
Ogre::Vector3 p(0, 0, 0);
if (m_editorCamera) {
p = m_editorCamera->getTarget();
} else if (ts->getSceneManager() &&
ts->getSceneManager()->hasSceneNode(
"EditorCameraTarget")) {
p = ts->getSceneManager()
->getSceneNode("EditorCameraTarget")
->getPosition();
}
wx = ts->renderToWorldX(p.x);
wy = ts->renderToWorldY(p.y);
wz = ts->renderToWorldZ(p.z);
return true;
}
void centerOnCamera(TerrainSystem *ts)
{
(void)ts;
double wx, wy, wz;
if (!getCameraWorld(wx, wy, wz))
return;
m_data.centerX = wx;
m_data.centerZ = wz;
m_heightsDirty = true;
}
static ImU32 heightColor(float t)
{
/* deep water -> water -> grass -> rock -> snow */
struct Stop {
float t;
int r, g, b;
};
static const Stop stops[] = {
{ 0.0f, 20, 40, 90 }, { 0.25f, 50, 90, 140 },
{ 0.4f, 80, 140, 70 }, { 0.7f, 120, 110, 90 },
{ 1.0f, 240, 240, 245 },
};
t = std::max(0.0f, std::min(1.0f, t));
for (size_t i = 1; i < sizeof(stops) / sizeof(stops[0]); ++i) {
if (t > stops[i].t)
continue;
const Stop &a = stops[i - 1];
const Stop &b = stops[i];
const float f = (t - a.t) / (b.t - a.t);
const int r = (int)(a.r + f * (b.r - a.r));
const int g = (int)(a.g + f * (b.g - a.g));
const int bl = (int)(a.b + f * (b.b - a.b));
return IM_COL32(r, g, bl, 255);
}
return IM_COL32(240, 240, 245, 255);
}
void drawHeights(ImDrawList *dl, const ImVec2 &origin, float canvasW,
float canvasH) const
{
if (!m_data.hasCache())
return;
const int res = m_data.getGridRes();
const float lo = m_data.getCacheMinHeight();
const float hi = m_data.getCacheMaxHeight();
const float span = (hi > lo) ? (hi - lo) : 1.0f;
const float cellW = canvasW / res;
const float cellH = canvasH / res;
for (int iy = 0; iy < res; ++iy) {
for (int ix = 0; ix < res; ++ix) {
const float t =
(m_data.getCachedHeight(ix, iy) - lo) /
span;
dl->AddRectFilled(
ImVec2(origin.x + ix * cellW,
origin.y + iy * cellH),
ImVec2(origin.x + (ix + 1) * cellW + 1,
origin.y + (iy + 1) * cellH + 1),
heightColor(t));
}
}
}
void drawLoadedPages(ImDrawList *dl, TerrainSystem *ts,
const ImVec2 &origin, float canvasW,
float canvasH) const
{
if (!ts || !ts->getStreamingActive() || !ts->getTerrainGroup())
return;
const double ws = ts->getTerrainGroup()->getTerrainWorldSize();
const long pages = ts->getStreamingPageCount();
double minWx, minWz, maxWx, maxWz;
m_data.mapToWorld(0.0, 0.0, canvasW, canvasH, minWx, minWz);
m_data.mapToWorld(canvasW, canvasH, canvasW, canvasH, maxWx,
maxWz);
auto clampPage = [pages](long p) {
return std::max(0L, std::min(pages - 1, p));
};
const long px0 = clampPage((long)std::floor(
ts->visualToPhysicalX(minWx) / ws));
const long px1 = clampPage((long)std::floor(
ts->visualToPhysicalX(maxWx) / ws));
const long pz0 = clampPage((long)std::floor(
ts->visualToPhysicalZ(minWz) / ws));
const long pz1 = clampPage((long)std::floor(
ts->visualToPhysicalZ(maxWz) / ws));
/* Too many pages on screen: the outlines would be noise. */
if (px1 - px0 > 256 || pz1 - pz0 > 256)
return;
for (long pz = pz0; pz <= pz1; ++pz) {
for (long px = px0; px <= px1; ++px) {
if (!ts->isPageLoaded(px, pz))
continue;
const double wxa =
ts->physicalToVisualX((double)px * ws);
const double wxb = ts->physicalToVisualX(
(double)(px + 1) * ws);
const double wza =
ts->physicalToVisualZ((double)pz * ws);
const double wzb = ts->physicalToVisualZ(
(double)(pz + 1) * ws);
float ax, ay, bx, by;
m_data.worldToMap(std::min(wxa, wxb),
std::min(wza, wzb), canvasW,
canvasH, ax, ay);
m_data.worldToMap(std::max(wxa, wxb),
std::max(wza, wzb), canvasW,
canvasH, bx, by);
dl->AddRect(ImVec2(origin.x + ax, origin.y + ay),
ImVec2(origin.x + bx, origin.y + by),
IM_COL32(60, 220, 60, 160));
}
}
}
void drawRoads(ImDrawList *dl, TerrainSystem *ts, const ImVec2 &origin,
float canvasW, float canvasH) const
{
if (!ts)
return;
RoadSystem *rs = ts->getRoadSystem();
if (!rs)
return;
flecs::entity te = rs->getTerrainEntity();
if (!te.is_alive() || !te.has<TerrainComponent>())
return;
const TerrainComponent &tc = te.get<TerrainComponent>();
const RoadGraph &g = tc.roadGraph;
/* Road node positions are RENDER space (terrain raycast
* hits); convert to world for the map. */
for (const RoadEdge &e : g.edges) {
const RoadNode *na = g.findNodeById(e.nodeA);
const RoadNode *nb = g.findNodeById(e.nodeB);
if (!na || !nb)
continue;
float ax, ay, bx, by;
m_data.worldToMap(ts->renderToWorldX(na->position.x),
ts->renderToWorldZ(na->position.z),
canvasW, canvasH, ax, ay);
m_data.worldToMap(ts->renderToWorldX(nb->position.x),
ts->renderToWorldZ(nb->position.z),
canvasW, canvasH, bx, by);
dl->AddLine(ImVec2(origin.x + ax, origin.y + ay),
ImVec2(origin.x + bx, origin.y + by),
IM_COL32(220, 220, 230, 255), 2.0f);
}
for (const RoadNode &n : g.nodes) {
float mx, my;
m_data.worldToMap(ts->renderToWorldX(n.position.x),
ts->renderToWorldZ(n.position.z),
canvasW, canvasH, mx, my);
dl->AddCircleFilled(ImVec2(origin.x + mx, origin.y + my),
3.0f, IM_COL32(255, 120, 120, 255));
}
}
void drawSpawners(ImDrawList *dl, TerrainSystem *ts,
const ImVec2 &origin, float canvasW,
float canvasH) const
{
if (!m_world || !ts)
return;
m_world->query_builder<TerrainPrefabSpawnerComponent,
TransformComponent>()
.build()
.each([&](TerrainPrefabSpawnerComponent &sp,
TransformComponent &t) {
(void)sp;
const double wx =
t.hasWorldPosition ?
t.worldX :
ts->renderToWorldX(t.position.x);
const double wz =
t.hasWorldPosition ?
t.worldZ :
ts->renderToWorldZ(t.position.z);
float mx, my;
m_data.worldToMap(wx, wz, canvasW, canvasH, mx,
my);
dl->AddCircleFilled(
ImVec2(origin.x + mx, origin.y + my),
4.0f, IM_COL32(255, 170, 40, 255));
});
}
void drawBookmarks(ImDrawList *dl, const ImVec2 &origin, float canvasW,
float canvasH) const
{
if (!m_bookmarks)
return;
for (const WorldBookmark &b : *m_bookmarks) {
float mx, my;
m_data.worldToMap(b.x, b.z, canvasW, canvasH, mx, my);
const ImVec2 c(origin.x + mx, origin.y + my);
dl->AddCircleFilled(c, 4.0f,
IM_COL32(255, 230, 60, 255));
dl->AddText(ImVec2(c.x + 6.0f, c.y - 6.0f),
IM_COL32(255, 230, 60, 255),
b.name.c_str());
}
}
void drawCameraMarker(ImDrawList *dl, TerrainSystem *ts,
const ImVec2 &origin, float canvasW,
float canvasH) const
{
(void)ts;
double wx, wy, wz;
if (!getCameraWorld(wx, wy, wz))
return;
float mx, my;
m_data.worldToMap(wx, wz, canvasW, canvasH, mx, my);
const ImVec2 c(origin.x + mx, origin.y + my);
dl->AddCircle(c, 7.0f, IM_COL32(255, 60, 60, 255), 0, 2.0f);
dl->AddLine(ImVec2(c.x - 10.0f, c.y), ImVec2(c.x + 10.0f, c.y),
IM_COL32(255, 60, 60, 255), 1.5f);
dl->AddLine(ImVec2(c.x, c.y - 10.0f), ImVec2(c.x, c.y + 10.0f),
IM_COL32(255, 60, 60, 255), 1.5f);
}
EditorCamera *m_editorCamera = nullptr;
flecs::world *m_world = nullptr;
const std::vector<WorldBookmark> *m_bookmarks = nullptr;
WorldMapData m_data;
bool m_heightsDirty = true;
bool m_showHeights = true;
bool m_showPages = true;
bool m_showRoads = true;
bool m_showSpawners = true;
bool m_showBookmarks = true;
int m_gridRes = 64;
};
#endif // EDITSCENE_WORLDMAPPANEL_HPP