Terrain improvement
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user