Planning Terrain

This commit is contained in:
2026-06-26 21:10:29 +03:00
parent 9b6881d873
commit 5a7d97045f
+660 -101
View File
@@ -53,28 +53,47 @@ Add a robust, editor-friendly `Ogre::Terrain`-based terrain feature to the
| Component | `src/features/editScene/components/Terrain.hpp` | Parameters-only component |
| Module | `src/features/editScene/components/TerrainModule.cpp` | ECS component registration |
| Editor | `src/features/editScene/ui/TerrainEditor.hpp` | ImGui property/editor panel |
| Editor | `src/features/editScene/ui/TerrainEditor.cpp` | ImGui property/editor panel impl |
| System | `src/features/editScene/systems/TerrainSystem.hpp` | System declaration |
| System | `src/features/editScene/systems/TerrainSystem.cpp` | System implementation |
| Physics | `src/features/editScene/physics/ZigzagHeightfieldShape.hpp` | Custom Jolt shape header |
| Physics | `src/features/editScene/physics/ZigzagHeightfieldShape.cpp` | Custom Jolt shape implementation |
| App wiring | `src/features/editScene/EditorApp.hpp/.cpp` | Construct/update/destroy system |
| Serialization | `src/features/editScene/systems/SceneSerializer.hpp/.cpp` | Save/load terrain data |
| App wiring | `src/features/editScene/EditorApp.hpp` | Forward declaration + unique_ptr member |
| App wiring | `src/features/editScene/EditorApp.cpp` | setupECS, setup, frameRenderingQueued, destructor |
| Serialization | `src/features/editScene/systems/SceneSerializer.hpp` | Declare serializeTerrain/deserializeTerrain |
| Serialization | `src/features/editScene/systems/SceneSerializer.cpp` | Save/load terrain data + include |
| Build | `src/features/editScene/CMakeLists.txt` | Add files + `OgrePaging`/`OgreTerrain` links |
## 1. TerrainComponent (parameters only)
**Design**: This is a **singleton component** — exactly one entity in the scene
should carry it. Ogre only supports one `TerrainGroup` per `SceneManager`, so
multi-instance terrain makes no sense. The component holds only serializable
parameters and a transient `dirty` flag. All runtime Ogre/Jolt objects live in
`TerrainSystem`.
```cpp
struct TerrainComponent {
bool enabled = true;
// Page/tile vertex resolution. Must be 2^n+1 and <= 65 (Ogre limit).
// Page/tile vertex resolution. Must be 2^n+1 (e.g. 33, 65, 129, 257).
// Ogre supports up to 4097, but practical limits depend on GPU memory.
int terrainSize = 65;
// Stable unique ID for this terrain — generated once at component creation,
// serialized, and never changed. Used as the directory key for all binary
// terrain data (heightmap, fixups, aux maps). Default 0 means "not yet
// assigned" — the editor generates one on first save or creation.
uint64_t terrainId = 0;
// Base binary heightmap resolution. Default 256x256.
int heightmapSize = 256;
// World-space size of one Ogre terrain page.
float worldSize = 40000000.0f;
// World-space size of one Ogre terrain page in units.
// For a single-page test scene, 2000-4000 is practical (terrainSize=65
// gives ~31-62 units per vertex). The 40M legacy value assumes enormous
// world coordinates; scale to your scene's coordinate system.
float worldSize = 2000.0f;
// Maximum geometric error in pixels before a LOD tile splits.
float maxPixelError = 1.0f;
@@ -90,6 +109,8 @@ struct TerrainComponent {
std::string heightmapFile = "heightmap.bin";
// Layer texture settings (diffuse+normal pairs).
// These are loaded via Ogre resource groups and fed into
// TerrainMaterialGeneratorA (RTShader terrain materials).
struct Layer {
std::string diffuseTexture;
std::string normalTexture;
@@ -112,15 +133,8 @@ struct TerrainComponent {
std::vector<RoadNode> roadNodes;
std::vector<RoadEdge> roadEdges;
// Prefab spawn points on the terrain.
struct PrefabSpawn {
std::string prefabName;
Ogre::Vector3 position;
Ogre::Quaternion rotation;
float spawnDistance = 100.0f;
float despawnDistance = 200.0f;
};
std::vector<PrefabSpawn> prefabSpawns;
// Prefab spawn points on the terrain managed by TerrainPrefabSpawnerComponent
// (not embedded here — see section 6).
// Runtime-only: managed by TerrainSystem. Not serialized.
bool dirty = true;
@@ -136,6 +150,11 @@ Rules:
restart in editor mode.
- Runtime objects (`Ogre::Terrain*`, Jolt bodies, scene nodes) must live in
`TerrainSystem`, not in the component.
- The component must be registered in `EditorApp::setupECS()` with
`m_world.component<TerrainComponent>()` so the module macro and
serializer can use it. The `REGISTER_COMPONENT_GROUP` macro in
`TerrainModule.cpp` only handles the editor UI registration.
- This is a **singleton component**: exactly one entity per scene.
## 2. TerrainSystem (singleton owner)
@@ -184,8 +203,9 @@ std::unordered_map<uint64_t, TerrainCollider> mColliders;
- Wait until `mTerrainGroup->isDerivedDataUpdateInProgress()` is `false`.
- Remove all Jolt bodies first.
- Unload and destroy pages.
- Destroy `TerrainPaging`/`PageManager`/`TerrainGroup`/`TerrainGlobalOptions`
in that order.
- Destroy in this order: `TerrainPaging``PageManager``TerrainGroup`
`TerrainGlobalOptions`. (`TerrainPaging` owns a reference to `PageManager`,
so it must go first.)
4. **Destruction** (`EditorApp::~EditorApp()`)
- Destroy `TerrainSystem` **before** `PhysicsSystem` and the
@@ -240,7 +260,7 @@ Input:
- Integer XZ world coordinates (not normalized, not page-local).
Output:
- World-space Y height.
- World-space float Y height.
Sources (in order of priority):
1. **Road fixup heightmap** — sparse 256x256 chunks loaded on demand. If a chunk
@@ -269,6 +289,170 @@ terrainGroup->defineTerrain(x, y, heightMap);
Note: Ogre's internal `getHeightData(x, y)` uses `index = y * size + x`, so the
row index in the buffer corresponds to world Z.
### 2.5 RTShader and material configuration
Before creating the `TerrainGroup`, configure the terrain material generator:
```cpp
mTerrainGlobals->setDefaultMaterialGenerator(
Ogre::TerrainMaterialGeneratorPtr(
new Ogre::TerrainMaterialGeneratorA()));
```
This must happen while `Ogre::RTShader::ShaderGenerator` is active (it is, via
`EditorApp::setup()`).
### 2.5a Layer declaration (texture sampler layout)
`TerrainMaterialGeneratorA` defines a **layer declaration** — the set of texture
samplers each layer expects. The default `SM2Profile` declaration provides:
| Sampler | Semantic | Purpose |
|---------|----------|---------|
| `diffusespecular` | diffuse+specular | Albedo (RGB) + specular (A) |
| `normalheight` | normal+parallax | Normal map (RGB) + displacement (A) |
Each `Layer` in `TerrainComponent::layers` produces an
`Ogre::Terrain::LayerInstance` whose `textureNames` list must match the sampler
count and order of the declaration:
```cpp
Ogre::Terrain::LayerInstance layerInstance;
layerInstance.worldSize = componentLayer.worldSize;
// Order must match the LayerDeclaration samplers:
layerInstance.textureNames.push_back(componentLayer.diffuseTexture);
layerInstance.textureNames.push_back(componentLayer.normalTexture);
importData.layerList.push_back(layerInstance);
```
The `ImportData::layerDeclaration` is obtained from the material generator
profile:
```cpp
importData.layerDeclaration = generatorProfile->getLayerDeclaration();
```
### 2.5b Blend maps (splatting)
Ogre terrain **auto-creates** a packed blend map texture for all pages.
How blend maps work:
- **Layer 0** is the base layer — always 100% opaque everywhere. It needs no
blend map.
- **Layers 14** each get one RGBA channel (R, G, B, A) in the packed blend
texture. Each texel is 0..255 (mapped to 0..1) controlling the visibility
of that layer blended on top of the layers below.
- With the default `SM2Profile`, **up to 5 layers** are supported per terrain
page (layer 0 + 4 blend channels).
- After `terrain->prepare(importData)`, blend maps exist but are initialized to
zero everywhere (only layer 0 visible). Use
`TerrainLayerBlendMap::setBlendValue()` to paint splat weights.
```cpp
Ogre::TerrainLayerBlendMap *blendMap = terrain->getLayerBlendMap(layerIdx);
blendMap->setBlendValue(tx, ty, 1.0f); // full opacity for this layer here
blendMap->dirty();
blendMap->update();
```
**Composite map**: Ogre auto-generates a low-res "composite map" from the blend
maps for distant rendering (`compositeMapDistance` controls when it switches).
### 2.5c Default blend map initialization
When a terrain page is first created, a sensible default paint is to show layer
0 everywhere. Later milestones (M3) add brush tools to paint splat weights in
the editor. For M1, just leave blend maps at their zero defaults or apply a
simple slope-based heuristic:
```
// Pseudocode: paint layer 1 (grass) on flat areas, layer 2 (rock) on steep
float slope = computeSlope(x, z);
if (slope < 30°) blendMap1->setBlendValue(x, z, 1.0f);
else blendMap2->setBlendValue(x, z, 1.0f);
```
### 2.5d Editor splat paint tools (future milestone)
Splat painting uses the same brush infrastructure as heightmap editing:
- Brush type selector (which layer to paint, or "erase" to reduce weight).
- Brush radius and opacity curve.
- `TerrainLayerBlendMap::setBlendValue()` for each affected texel.
- `dirtyRect()` to limit the GPU upload to the modified region.
- The blend map image size is configurable via
`TerrainGlobalOptions::setLayerBlendMapSize()` (default 1024x1024).
### 2.6 Resource locations for terrain data
Terrain textures and heightmaps need `Ogre::ResourceGroupManager` locations:
- `heightmapFile` path: `heightmaps/<terrainId>_<heightmapFile>` where
`<terrainId>` is `TerrainComponent::terrainId`. Not scene-relative — the
terrain ID is the stable key, independent of scene file name or save path.
- Fixup chunks: stored in `heightmaps/<terrainId>/terrain_fixup/` subdirectory,
registered as a resource location when the scene is loaded.
- Layer textures: expected in the `General` resource group (same as all other
textures), so no additional resource location is needed.
### 2.7 Interaction with water and skybox systems
- **Water reflection/refraction**: The terrain's render queue group and
visibility mask must include the water RTT cameras. If the water system hides
entities during RTT passes via `RenderTargetListener`, the terrain must be
visible to those passes or water won't reflect the terrain.
- **Skybox**: No special handling needed — sky renders behind terrain.
- **Sun shadows**: If terrain uses shadow casting, ensure the sun light's shadow
far distance covers the terrain pages.
### 2.8 EditorApp wiring
Follow the existing pattern from `EditorWaterPlaneSystem` and
`EditorPhysicsSystem`:
**In `EditorApp.hpp`**:
```cpp
// Forward declaration
class TerrainSystem;
// In private section:
std::unique_ptr<TerrainSystem> m_terrainSystem;
```
**In `EditorApp::setupECS()`** — register the component:
```cpp
m_world.component<TerrainComponent>();
```
**In `EditorApp::setup()`** — construct after physics but before water/sun/skybox
(terrain must exist before those systems try to interact with it):
```cpp
m_terrainSystem = std::make_unique<TerrainSystem>(
m_world, m_sceneMgr, m_physicsSystem.get());
```
**In `EditorApp::frameRenderingQueued()`** — call update **before**
static-world generation and **after** visual mesh setup. Insert right after
`m_proceduralMeshSystem->update()`:
```cpp
if (m_terrainSystem) {
m_terrainSystem->update(evt.timeSinceLastFrame);
}
```
**In `EditorApp::~EditorApp()`** — destroy **before** `m_physicsSystem` and
before Ogre `SceneManager` cleanup. Insert `m_terrainSystem.reset()` before
`m_physicsSystem.reset()`. Also ensure `m_waterPlaneSystem`, `m_skyboxSystem`,
and `m_sunSystem` are reset before terrain (the current destructor omits these
resets — add them):
```cpp
m_waterPlaneSystem.reset();
m_skyboxSystem.reset();
m_sunSystem.reset();
m_terrainSystem.reset();
m_buoyancySystem.reset();
m_physicsSystem.reset();
```
## 3. ZigzagHeightfieldShape (custom Jolt collision shape)
`JPH::HeightFieldShape` is plain row-major and always splits quads along the
@@ -297,6 +481,8 @@ Equivalent to Bullet's `m_useZigzagSubdivision && !(j & 1)`.
Because implementing a full custom `JPH::Shape` is large, the first milestone
uses a practical, verifiable approach:
**MeshShape approach (Milestone 2)**:
1. Build a `JPH::MeshShape` per terrain page whose triangles use the exact same
zigzag triangulation as `Ogre::Terrain`.
2. The mesh vertices are `(x, height[y*size+x], z)` in row-major order.
@@ -306,6 +492,15 @@ uses a practical, verifiable approach:
4. Place the `MeshShape` at the page world position using a
`JPH::RotatedTranslatedShape` or by offsetting vertex coordinates.
**Performance note**: `JPH::MeshShape` stores the full triangle soup and uses
a bounding-volume tree internally, while `JPH::HeightFieldShape` exploits the
regular grid structure for O(log n) collision queries with minimal memory.
For a terrain page of 65x65 vertices (8192 triangles), the `MeshShape` overhead
is acceptable (a few KB extra memory, similar collision speed for typical
contacts). For pages above 129x129, the custom heightfield shape should be
prioritized. **Mitigation**: Test with `terrainSize=65` first; profile with
Jolt's stats overlay before scaling up.
If runtime collision performance becomes a problem, a second milestone can
replace the `MeshShape` with a true custom `ZigzagHeightfieldShape` derived from
`JPH::Shape`, reusing Jolt's triangle-vs-convex helpers
@@ -356,18 +551,102 @@ Use `EShapeSubType::User1` for the shape subtype.
- Format: raw binary, `heightmapSize * heightmapSize * sizeof(float)`.
- Row-major, same layout as Ogre: `index = y * heightmapSize + x`.
- Stored relative to the scene or project (e.g., `scenes/<scene>/heightmap.bin`).
- Default size: 256x256.
- **Storage path**: Directory keyed by the stable `TerrainComponent::terrainId`:
`heightmaps/<terrainId>_<heightmapFile>` where `<terrainId>` is the
decimal value of `TerrainComponent::terrainId`. This ID is generated once
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.
- Editable in the terrain editor with elevation and curve brushes.
- A fresh empty heightmap initializes all samples to `0.0f`.
### 4.2 Fixup chunks
- Sparse 256x256 chunks keyed by integer world XZ chunk coordinates.
- Loaded on demand when a page needs them or when a road node is modified.
- Stored as separate binary files (e.g., `terrain_fixup/x0_z0.bin`) or in the
scene JSON for small edits.
- Applied additively to the base heightmap to make terrain comply to roads and
spawned geometry.
- **Purpose**: Override base heightmap values in localized regions so terrain
complies to roads, buildings, and other placed geometry.
- **Format**: raw binary, 256x256 floats (same as base heightmap but always
256x256).
- **Naming**: `x<chunkX>_z<chunkZ>.bin` where chunk coordinates are derived from
world coordinates:
```
int chunkX = floor(worldX / (worldSize / 256));
int chunkZ = floor(worldZ / (worldSize / 256));
```
So chunk `(0,0)` covers world `(0,0)` to `(worldSize/256, worldSize/256)` in
X and Z.
- **Storage directory**: `heightmaps/<terrainId>/terrain_fixup/` — registered as
an Ogre resource location when the scene is loaded so the definer can load them
on demand. The `<terrainId>` prefix matches the base heightmap directory scheme
from 4.1.
- **Semantics**: Each fixup sample is an **absolute** height value (not an
offset). If `getHeightAtWorld()` finds a fixup chunk covering the queried
world coordinate, it bilinearly samples the fixup chunk and **replaces** the
base heightmap value at that point. A sentinel value (e.g., `NaN` or
`-FLT_MAX`) indicates "no fixup at this sample" — in that case fall through
to the base heightmap.
- **Blending at chunk boundaries**: Use bilinear interpolation within each chunk
(natural for floating-point heightmaps). When a queried point falls in a
chunk that hasn't been created yet, there's no fixup — fall through to base.
- **Sparsity**: Chunks are created lazily — only when a road brush writes to
them or when explicitly created by editor tools. Absent chunks mean "no fixup
data for this area."
- **Cleanup**: Provide a "Clear all fixups" button in the terrain editor that
deletes all files under `heightmaps/<terrainId>/terrain_fixup/` and marks all
terrain pages dirty for re-sampling. Additionally, individual fixup chunks
can be deleted by right-clicking a world position in the editor — the chunk
file covering that position is removed and the affected pages rebuild.
### 4.3 Non-heightmap maps (foliage density, material masks, etc.)
Terrain systems often need auxiliary 2D maps beyond the heightfield. These are
stored the same way as the base heightmap but with independent resolution and
without fixup chunk support.
**Data structure** — add to `TerrainComponent`:
```cpp
struct AuxMap {
std::string name; // e.g. "foliage_density", "rock_mask"
std::string fileName; // raw binary file, e.g. "foliage_density.bin"
int resolution = 256; // independent from heightmapSize
float defaultValue = 0.0f; // initial fill value for new maps
};
std::vector<AuxMap> auxMaps;
```
**Storage**: `heightmaps/<terrainId>/<auxMap.fileName>` — same directory as the
base heightmap.
**Editing**: Use the same brush system as heightmap editing (raise/lower/smooth)
but interpreted as adding/subtracting density values. Values are clamped to
[0, 1] for density maps.
**Visualization**: For density maps, the foliage placement itself serves as
indirect visualization (more foliage where density is high). For debugging,
render a heatmap overlay on the terrain surface using a simple colored quad or
compositor pass.
**Foliage integration**: After terrain implementation, `Ogre::PagedGeometry`
will sample these density maps during placement. The `AuxMap` data must be
accessible from the foliage system (via `TerrainSystem` API or shared file
paths). This is tracked as a follow-up task; for now, the storage and editing
infrastructure is in scope.
### 4.4 Packaging
- **Goal**: Bundle all terrain data files into a single `.package` archive
(using the existing Package archive format from `package/OgrePackageArchive`)
to avoid filesystem clutter after heavy terrain editing.
- **Trigger**: A "Package Terrain" button in the terrain editor or a CLI tool.
- **Contents**: All files under `heightmaps/<terrainId>/` — base heightmap,
fixup chunks, and aux maps.
- **Package path**: `heightmaps/<terrainId>.package`
- **Loading**: When the package file exists, register it as an Ogre resource
location before the loose file directory so the packaged version takes
precedence. Loose files under `heightmaps/<terrainId>/` are treated as
overrides (editor writes to loose files, packaging snapshots them).
- **Unpack**: An "Unpack Terrain" button extracts the package back to loose
files for editing.
## 5. Procedural Roads
@@ -382,47 +661,260 @@ Use `EShapeSubType::User1` for the shape subtype.
- Join two selected nodes with an edge.
- Set per-node vertical offset and per-edge road level for each direction.
### 5.2 Geometry generation
**Per-edge data** — `RoadEdge` is extended beyond the simple A/B link to carry
local overrides for road configuration:
```cpp
struct RoadEdge {
int nodeA = -1;
int nodeB = -1;
float roadLevelA = 0.0f; // road surface height at nodeA end
float roadLevelB = 0.0f; // road surface height at nodeB end
// Per-edge lane count override (0 = use global lanesPerDirection).
int lanesPerDirectionOverride = 0;
// Per-direction lane counts for asymmetric roads.
// Positive = A→B, negative = B→A. If 0, uses lanesPerDirectionOverride
// or global lanesPerDirection.
int lanesAtoB = 0;
int lanesBtoA = 0;
// Prefab spawn points along this edge.
struct RoadSidePrefab {
std::string prefabPath;
float edgeT = 0.5f; // 0..1 position along edge
float sideOffset = 5.0f; // lateral offset from road center
bool leftSide = true; // left vs right relative to A→B direction
};
std::vector<RoadSidePrefab> sidePrefabs;
};
```
This allows individual edges to be wider (more lanes) than the default and to
spawn lamp posts, guardrails, or other roadside objects at configurable offsets.
### 5.2 Road configuration parameters
These live in `TerrainComponent` alongside the node graph:
```cpp
// Road surface mesh template name (file in General resource group).
// Default: a unit-length flat plane mesh at Y=0, extending +X.
std::string roadMeshTemplate = "road_segment.mesh";
// Width of a single lane in world units.
float laneWidth = 3.0f;
// Number of lanes in each road direction (1 = single-lane each way). default value, should be possible to set per edge per direction.
int lanesPerDirection = 1;
// Vertical thickness of the road surface (for terrain compliance depth).
float roadThickness = 0.3f;
// Maximum edge length beyond which roads use lower LOD.
float roadLodDistance = 200.0f;
// Distance at which road meshes fade out (Ogre visibility distance).
float roadVisibilityDistance = 1000.0f;
// Material name for road surfaces.
std::string roadMaterialName = "RoadMaterial";
```
### 5.3 Road mesh template
The `roadMeshTemplate` is an Ogre mesh file with these conventions:
- The mesh occupies **exactly 1 unit** along the positive X axis (from X=0 to
X=1), Z=0 to Z=1 or Z=-1 to Z=0, centered on Y=0 (width spans 0 to
laneWidth for each lane).
- The mesh is repeated along the edge direction for the actual road length.
- Vertices with `X < 0.05` (near the start cap) are shifted slightly negative
(`-0.05`) to create overlap and prevent seams at wedge boundaries.
- UVs span (0,0) to (1,1) along the X/Z extents and are scaled to the actual
road length at mesh generation time.
- If no template mesh is provided, fall back to a generated box primitive with
dimensions `roadSegmentLength x roadThickness x laneWidth`. It should be offset right and towards front for easier calculation and have Z size of 1.0
### 5.4 Geometry generation
For each loaded terrain page, collect all road nodes whose world position falls
within the page plus a margin equal to `roadVisibilityDistance`.
**Step 1 — Node sorting**
For each node:
0. Collect all nodes which are located on terrain chunks being loaded. Make sure it happens in background work.
1. Collect all neighbors connected by edges.
2. Sort neighbors by angle around the vertical (Y) axis.
3. If only one neighbor, generate an end-of-road mesh from the node position to
the midpoint between node and neighbor.
4. If multiple neighbors, iterate sorted neighbors. For each consecutive pair
`(neighborI, neighborI+1)` create a **wedge** bounded by:
- Line from `neighborI` midpoint to node.
- Line from node to `neighborI+1` midpoint.
5. Generate wedge geometry into a `Procedural::TriangleBuffer`. The generation should use road mesh and sample its geometry among resulting line inside triangle buffer. This mesh should start at zero and go in positive direction among X and Z axis. Its geometry should be used to produce mesh with length of the resulting line and be modified by its angles. The process should be repeated for each lane for roads with multiple lanes in the same direction. If mesh was not provided, simple cube could be used for start. Because of floating point errors, adjacent wedges might end up a bit split, for that each mesh vertex with model X coordinate < 0.05 should be shifted in negative model X axis by 0.05. Same might be done for model Z axis.
NB: Length of each edge is required to be integer number and length of road mesh should be exactly 1 unit (or a tiny bit larger for overlap)
6. Join close wedges into larger meshes to reduce draw calls.
7. Generate Jolt Mesh collider from joined triangle buffer.
8. Build an Ogre mesh from the triangle buffer, then run it through the LOD
system and set a visibility distance.
3. If only one neighbor, the node is an endpoint → generate an **end cap**.
### 5.3 Terrain compliance
**Step 2 — Wedge generation**
- For every road vertex/sample, compute the desired terrain height
(`roadY - roadThickness`).
- Write these heights into the sparse fixup chunk covering that world position.
- Mark affected terrain pages `dirty` so `CustomTerrainDefiner` re-samples them.
- Optionally add a smooth falloff around the road so the blend into the base
heightmap is gradual.
For nodes with multiple neighbors, iterate sorted neighbors and for each
consecutive pair `(neighborI, neighborI+1)` create a **wedge** bounded by:
- Line from node to `neighborI` midpoint.
- Line from node to `neighborI+1` midpoint.
For each wedge:
1. Compute the two boundary direction vectors from the node.
2. Generate wedge geometry into a `Procedural::TriangleBuffer`:
- The wedge spans the angular region between the two directions.
- The road mesh template is repeated along each radial direction and the
triangles between adjacent samples form the wedge surface.
- Each vertex's model-space X coordinate maps to distance from the node;
Z maps to lateral offset.
3. For each lane `L` from `0` to `lanesPerDirection - 1`:
- Offset the Z coordinate by `L * laneWidth` for the outward lane and
`-L * laneWidth` for the inward lane.
- Generate separate wedge geometry for each lane (so lanes can be
individually LOD'd and culled).
**Step 3 — End cap generation**
For endpoints (nodes with exactly one neighbor):
1. Generate a half-circle or rectangular cap mesh from the node extending away
from the neighbor direction.
2. Same lane multiplication as wedge generation.
**Step 4 — Seam suppression**
Floating-point errors at wedge boundaries can create visible gaps. Mitigation:
- Vertices with model-space `X < 0.05` (near zero) are shifted by `-0.05` in
the negative X direction (toward the node center).
- Vertices at the outer radial edge (model-space `X` near the edge midpoint
distance) are shifted slightly outward along the edge direction.
**Step 5 — Mesh assembly**
For each wedge and end cap:
1. Create a `TriangleBufferComponent` entity as a child of the terrain entity.
2. The buffer is built by `ProceduralMeshSystem` during its update pass.
3. `ProceduralMeshSystem::convertToMesh()` creates the Ogre mesh from the buffer.
4. Apply the road material:
- Set `materialEntity` reference on the `TriangleBufferComponent`.
- `ProceduralMaterialSystem` resolves the material at mesh creation time.
**Step 6 — LOD and visibility**
- Each road mesh entity gets a `LodComponent` with `LodStrategy::Distance`.
- Meshes beyond `roadLodDistance` from the camera use a lower-poly LOD level
(generated by `MeshLodGenerator` if available, or a simple reduction).
- Meshes beyond `roadVisibilityDistance` are culled entirely via
`Ogre::Entity::setRenderingDistance()`.
**Step 7 — Physics colliders**
- For each road mesh, generate a `JPH::MeshShape` matching the visible geometry.
- The collider is attached as a static body in the NON_MOVING layer.
- Colliders are created/removed in sync with mesh visibility (same distance
thresholds).
- Create colliders from joined geometry.
**Step 8 — Wedge joining (optimization)**
Adjacent wedges that share an edge direction and have the same material can be
merged into a single `TriangleBuffer` to reduce draw calls:
1. After generating all wedges for a page, group wedges by material and seed
node.
2. Merge triangle buffers by appending triangles and adjusting indices.
3. Merge only if the total triangle count stays below a threshold (e.g. 65535
vertices for 16-bit index buffers).
### 5.5 Terrain compliance (road flattening)
For every road vertex/sample:
1. Compute the desired terrain height at that world position:
`targetY = roadY - roadThickness`.
2. Write `targetY` into the fixup chunk that covers that world position.
3. Mark affected terrain pages dirty so `CustomTerrainDefiner` re-samples them
on the next `update()` call.
4. Apply a smooth falloff perpendicular to the road direction:
- Full compliance within the road width.
- Linear fade over `laneWidth * 2` units outside the road edge.
- Beyond the fade zone, no fixup (use base heightmap).
### 5.6 Edge length constraint
Each road edge must have an **integer** length in the road mesh template
coordinate system. The road mesh template is exactly 1 unit long, so edges
whose world-space length is not a whole number of road-segment units will
have a fractional segment at the end. Round edge lengths to the nearest
integer number of road-mesh repetitions. For very short edges (length < 1
unit), scale the road mesh down or emit a warning/immediate LOD reduction.
When operating on edges, like splitting and joining, it should be checked and applied. If enforcing of constraint (by moving new nodes) is impossible, the operation is cancelled with warning message. Other option would be scaling elemental mesh on one of midpoint edges together with scaling UVs so road surface will still look fine. If that would be robust enough I'd go with it, but only if fixing by moving the node is impossible. That should reduce probability of problems.
### 5.7 Dependencies on existing systems
- **ProceduralMeshSystem**: Converts `TriangleBufferComponent` → Ogre mesh.
- **ProceduralMaterialSystem**: Resolves `ProceduralMaterialComponent` → Ogre
material for road surfaces.
- **LodSystem**: Manages `LodComponent` for distance-based LOD switching.
- **EditorPhysicsSystem**: Provides `JoltPhysicsWrapper` for creating static
mesh colliders.
- **StaticGeometrySystem**: Optional — road meshes could be batched into
static geometry for large road networks, but this complicates per-segment
LOD and visibility culling.
## 6. Prefab Spawning on Terrain
- Reuse the same distance-based spawn/despawn pattern as
`CharacterSpawnerSystem`.
- New component `TerrainPrefabSpawn` (or keep inside `TerrainComponent`) stores:
- Prefab name/path. A path should be selectable in the UI, taken from prefabs directory.
- World position (optionally snapped to terrain surface).
- `spawnDistance` / `despawnDistance`.
- `TerrainSystem` tracks significent camera changes and if enough position change happend evaluates camera distance and creates/destroys
prefab instances via the existing `PrefabSystem`. If terrain chunk is loaded/unloaded, prefabs should too.
- A terrain editing tool mode can temporarily raise/lower terrain around a
spawned prefab to make it sit flush (writes to fixup chunks).
### 6.1 Design
Terrain-attached prefabs use the existing `PrefabSystem` for instantiation and
a new ECS component for distance-based spawn/despawn, similar to
`CharacterSpawnerComponent`.
### 6.2 TerrainPrefabSpawnerComponent
A new component (not embedded in `TerrainComponent`, to keep the component
single-responsibility):
```cpp
struct TerrainPrefabSpawnerComponent {
// Prefab JSON file path (selectable from prefabs directory in UI).
std::string prefabPath;
// World position (snapped to terrain surface at placement time).
Ogre::Vector3 position;
Ogre::Quaternion rotation;
// Distance-based spawn/despawn (squared for fast comparison).
float spawnDistanceSq = 100.0f * 100.0f;
float despawnDistanceSq = 200.0f * 200.0f;
// Runtime: the spawned instance entity (null when not spawned).
flecs::entity_t spawnedEntity = 0;
};
```
### 6.3 TerrainPrefabSpawnerSystem
A lightweight system that:
1. Queries all `TerrainPrefabSpawnerComponent` entities each frame.
2. Compares camera distance against `spawnDistanceSq` / `despawnDistanceSq`.
3. Creates/destroys prefab instances via `PrefabSystem::createInstance()`.
4. Tracks camera position changes; only re-evaluates when the camera moves
significantly (e.g., more than 10 units since last check).
This is registered and updated alongside other systems in `EditorApp`, not
inside `TerrainSystem`.
### 6.4 Terrain snap at placement
When the user places a prefab spawn point in the editor:
1. Raycast against the terrain to find the surface Y at `(position.x, position.z)`.
2. Set `position.y` to the terrain height.
3. The spawner entity's `TransformComponent.position` matches this snapped
position so it's visible in the editor.
### 6.5 Terrain compliance tool
A terrain editing tool mode can temporarily raise/lower terrain around a
spawned prefab to make it sit flush:
1. Sample the prefab's bounding box footprint on the terrain.
2. Write fixup chunk entries that flatten the terrain under the footprint.
3. Optionally add a slope/blend around the footprint edges.
4. Mark affected terrain pages dirty.
## 7. Editor Integration
@@ -465,16 +957,23 @@ Minimum controls:
- `maxPixelError`, `compositeMapDistance`, `minBatchSize`, `maxBatchSize`.
- Heightmap file path and **Load / Save** buttons.
- Brush mode:
- Brush type (raise, lower, smooth, flatten).
- Brush type: raise, lower, smooth, flatten, **layer paint** (selects which
terrain layer to apply, with opacity).
- Brush radius and curve (stored as simple 1D curve).
- Apply to base heightmap and affected fixup chunks.
- For layer paint: writes to `TerrainLayerBlendMap` via `setBlendValue()` and
`dirtyRect()`, then `update()`.
- Road mode:
- Road config: `roadMeshTemplate`, `laneWidth`, `lanesPerDirection`,
`roadThickness`, `roadLodDistance`, `roadVisibilityDistance`,
`roadMaterialName`.
- Add/remove/split/join/select nodes.
- Inspector for node position, offset, edge levels.
- "Comply terrain to roads" button.
- Prefab spawn mode:
- Add/remove spawn points.
- Prefab spawn mode (uses `TerrainPrefabSpawnerComponent`):
- Add/remove spawn points (snapped to terrain surface).
- Pick prefab from existing prefab list.
- Configure spawn/despawn distances.
## 8. Serialization
@@ -500,9 +999,10 @@ JSON schema:
{
"terrain": {
"enabled": true,
"terrainId": 17345678901234567890,
"terrainSize": 65,
"heightmapSize": 256,
"worldSize": 40000000.0,
"worldSize": 2000.0,
"maxPixelError": 1.0,
"compositeMapDistance": 300.0,
"minBatchSize": 17,
@@ -515,28 +1015,53 @@ JSON schema:
"worldSize": 100.0
}
],
"auxMaps": [
{
"name": "foliage_density",
"fileName": "foliage_density.bin",
"resolution": 256,
"defaultValue": 0.0
}
],
"roadConfig": {
"roadMeshTemplate": "road_segment.mesh",
"laneWidth": 3.0,
"lanesPerDirection": 1,
"roadThickness": 0.3,
"roadLodDistance": 200.0,
"roadVisibilityDistance": 1000.0,
"roadMaterialName": "RoadMaterial"
},
"roadNodes": [
{ "id": 0, "position": [0, 10, 0], "verticalOffset": 0.5 }
],
"roadEdges": [
{ "nodeA": 0, "nodeB": 1, "roadLevelA": 0.0, "roadLevelB": 0.0 }
],
"prefabSpawns": [
{
"prefabName": "town_01",
"position": [100, 0, 200],
"rotation": [0, 0, 0, 1],
"spawnDistance": 100.0,
"despawnDistance": 200.0
"nodeA": 0, "nodeB": 1,
"roadLevelA": 0.0, "roadLevelB": 0.0,
"lanesPerDirectionOverride": 0,
"lanesAtoB": 0, "lanesBtoA": 0,
"sidePrefabs": []
}
]
}
}
```
Heightmap binary files are saved next to the scene JSON or in a configured
resource directory. Fixup chunk files are saved under a `terrain_fixup/`
subdirectory.
**Note**: Prefab spawners are serialized separately via
`TerrainPrefabSpawnerComponent`, not embedded in the terrain JSON.
**Binary file storage**: All terrain binary data lives under
`heightmaps/<terrainId>/` where `<terrainId>` is the decimal value of
`TerrainComponent::terrainId`:
- Base heightmap: `heightmaps/<terrainId>/<heightmapFile>`
- Fixup chunks: `heightmaps/<terrainId>/terrain_fixup/x<chunkX>_z<chunkZ>.bin`
- Aux maps: `heightmaps/<terrainId>/<auxMap.fileName>`
- Package: `heightmaps/<terrainId>.package`
The terrainId is generated once at component creation, serialized, and survives save/reload unchanged.
time, then serialized. This avoids the chicken-and-egg problem of time-based
IDs and keeps all data for one terrain in one directory.
## 9. Build Changes
@@ -570,48 +1095,66 @@ target_link_libraries(editSceneEditor PUBLIC
- `TerrainSystem` with `Ogre::TerrainGroup`, `PageManager`, `TerrainPaging`,
`PagedWorld`, `TerrainPagedWorldSection`.
- `CustomTerrainDefiner` sampling a flat or procedural 256x256 base heightmap.
- CMake and `EditorApp` wiring.
- Scene serialization for the component.
- RTShader material generator setup (TerrainMaterialGeneratorA).
- CMake (`OgrePaging`/`OgreTerrain` links) and `EditorApp` wiring.
- Component registration in `setupECS()` + editor module.
Definition of done: a paged terrain renders in the editor; terrain pages load
and unload as the camera moves.
### Milestone 2 — Physics integration
### Milestone 2 — Serialization + physics integration
- Scene serialization for `TerrainComponent` (save/load round-trip).
- Implement `ZigzagHeightfieldShape` as a `JPH::MeshShape` per page with zigzag
triangulation matching Ogre.
- Queue collider creation after page load and derived-data completion.
- Maintain `mColliders` map; safe remove/re-add on scene clear or component
removal.
- Add raycast helper to `TerrainSystem` for gameplay/editor tools.
- Binary heightmap file save/load alongside scene JSON.
Definition of done: rigid bodies and characters collide flush with the visible
terrain; removing/re-adding the terrain entity is leak-free.
Definition of done: terrain saves/loads from JSON; rigid bodies and characters
collide flush with the visible terrain; removing/re-adding the terrain entity
is leak-free.
### Milestone 3 — Heightmap editing
- Load/save binary heightmap.
- Brush tools (raise/lower/smooth/flatten) with simple curve-based brush
profile.
- Sparse 256x256 fixup chunk cache and file I/O.
- Mark affected pages dirty and rebuild them.
### Milestone 3 — Heightmap editing + splat painting
- Brush tools (raise/lower/smooth/flatten) with curve-based brush profile.
- Splat paint brush (select layer, set opacity, paint blend values via
`TerrainLayerBlendMap`).
- Sparse 256x256 fixup chunk cache and file I/O (lazy creation, sentinel-based
override semantics).
- Mark affected pages dirty and rebuild them via CustomTerrainDefiner
re-sampling.
- Heightmap editor UI (brush radius, strength, type selector).
- `AuxMap` infrastructure (create, load, save, edit with brushes).
- Fixup chunk cleanup (clear all, delete individual chunks by world position).
Definition of done: user can sculpt the terrain in the editor and save/load the
result.
result; visual and collision update after sculpting.
### Milestone 4 — Procedural roads
- Node/edge editing UI.
- Wedge geometry generation with `Procedural::TriangleBuffer`.
- LOD + visibility distance.
- "Comply terrain to roads" writing fixup chunks.
- Node/edge editing UI (add, remove, split, join, select, move).
- Road configuration parameters UI (lane width, template, material).
- Wedge geometry generation using the road mesh template and
`Procedural::TriangleBuffer`.
- End cap generation for endpoints.
- Seam suppression via vertex shifting.
- LOD + visibility distance per road mesh segment.
- Road physics colliders (JPH::MeshShape per segment).
- "Comply terrain to roads" writing fixup chunks with smooth falloff.
Definition of done: user can draw a road network; terrain flattens under roads
with no gaps.
Definition of done: user can draw a road network; road geometry appears with
correct lanes; terrain flattens under roads with no visible gaps.
### Milestone 5 — Prefab spawns and compliance
- Distance-based prefab spawn/despawn on terrain.
- Tool mode to make terrain comply to spawned geometry.
### Milestone 5 — Prefab spawns and terrain compliance
- `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`.
- `TerrainPrefabSpawnerSystem` with distance-based spawn/despawn via
`PrefabSystem`.
- Terrain-snap at placement (raycast against terrain).
- Terrain compliance tool: flatten terrain under prefab footprint using fixup
chunks.
Definition of done: towns/rocks spawn at configured locations and sit flush on
terrain.
Definition of done: towns/rocks spawn at configured locations on terrain and sit
flush; save/load round-trips correctly.
## 11. Risks and Mitigations
@@ -620,20 +1163,36 @@ terrain.
| Background job crash on terrain removal | Always check `isDerivedDataUpdateInProgress()`; defer removal to next frame. |
| Memory leak on repeated add/remove | `TerrainSystem` owns all runtime objects; verify destruction order in tests. |
| Physics/visual mismatch | Use zigzag triangulation; compare debug draw of Jolt body against Ogre terrain wireframe. |
| 40M world size precision loss | Project already uses `JPH_DOUBLE_PRECISION`; keep world coordinates as `double`/`long` where possible. |
| Large world size precision loss | Project already uses `JPH_DOUBLE_PRECISION`; keep world coordinates as `double`/`long` where possible. Scale `worldSize` to realistic scene dimensions. |
| Large heightmap build stalls | Use Ogre's background prepare; create Jolt colliders only after page is fully loaded. |
| Road mesh too heavy | Join wedges, run LOD, limit visibility distance, use instancing for repeated pieces. |
| Road wedge seam gaps | Shift near-seam vertices by 0.05; overlap adjacent wedge edges. |
| Fixup chunk read/write races | `CustomTerrainDefiner::define()` runs on Ogre's WorkQueue; fixup writes must happen on the main thread before marking pages dirty. |
| Terrain not visible in water reflections | Ensure terrain render queue/visibility mask matches water RTT camera setup. |
| TerrainPaging/PageManager destruction order | Destroy `TerrainPaging` before `PageManager` (Paging holds reference to PageManager). |
| EditorApp destructor leaves Ogre objects alive | Explicitly `reset()` water/skybox/sun/terrain systems before physics and SceneManager shutdown. |
## 12. Testing Checklist
- [ ] Add terrain entity → paged terrain renders.
- [ ] Remove terrain entity → no crash, no leaks, no dangling Jolt bodies.
- [ ] Add, remove, add again repeatedly → stable memory.
- [ ] Add, remove, add again repeatedly → stable memory (valgrind/ASan).
- [ ] Camera fly-through → pages load/unload correctly.
- [ ] Place a rigid body on a slope → it rests flush with terrain.
- [ ] Sculpt heightmap → visual and collision update.
- [ ] Create road network → road geometry appears and terrain complies.
- [ ] Save scene, reload → terrain, roads, and prefab spawns restored.
- [ ] Place a rigid body on a slope → it rests flush with terrain (no sinking/floating).
- [ ] Sculpt heightmap → visual and collision update (brush raise/lower/smooth/flatten).
- [ ] Paint splat layer → terrain surface changes appearance at painted locations.
- [ ] Paint splat, save, reload → blend map changes persist.
- [ ] Create/edit/delete AuxMap → values stored and loaded correctly.
- [ ] Clear all fixups → terrain returns to base heightmap only.
- [ ] Save scene, exit, reload → terrain restored with correct heightmap and layers.
- [ ] Create road network → road geometry appears with correct lane count and terrain compliance.
- [ ] Per-edge lane override → edge with `lanesAtoB=2` renders two lanes A→B.
- [ ] Save/reload scene with roads → nodes, edges, and config restored.
- [ ] Place prefab spawner → prefab appears at correct distance and sits on terrain.
- [ ] Move camera far away and back → prefab despawns and respawns correctly.
- [ ] Terrain reflected in water → terrain visible in reflection RTT pass.
- [ ] Height function round-trip: write known heights → sample them back → values match.
- [ ] Fixup chunk persistence: write fixup → save → reload → fixup still applied.
- [ ] Build with ASan/UBSan if available → no reports.
## 13. Reference Code