Detail noise implemented

This commit is contained in:
2026-07-12 01:30:01 +03:00
parent 96c0eef6cb
commit 9a12e84fec
9 changed files with 480 additions and 108 deletions
+1
View File
@@ -406,6 +406,7 @@ target_include_directories(editSceneEditor PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
+119 -89
View File
@@ -1401,6 +1401,10 @@ This milestone polishes the terrain editing pipeline and makes the test suite
runnable in CI. Each item below states the concrete user-visible behaviour, the
files to touch, the data that must be serialized, and the verification steps.
The items are ordered by dependency: brush shapes (M4.3) must exist before
aux-map painting (M4.4) can reuse them, so the numbering below is sequential and
has no gaps.
#### M4.1 Headless terrain test mode ✅ DONE — verified
**Status**: completed and verified by user.
@@ -1472,7 +1476,7 @@ Implementation steps:
---
#### M4.2 Procedural detail noise
#### M4.2 Procedural detail noise ✅ DONE — verified
**Goal**: add optional procedural detail noise on top of the base heightmap so
empty terrain is not perfectly flat, while keeping every parameter serializable
@@ -1507,9 +1511,9 @@ struct TerrainComponent {
const TerrainComponent::DetailNoise &n) const;
```
2. Implement it using the `FastNoiseLite` header already vendored in
`src/FastNoiseLite/`. Initialize one `FastNoiseLite` instance per unique
`(seed, frequency)` pair (or recreate on parameter change). Evaluate
layered octaves with `lacunarity` and `persistence`:
`src/FastNoiseLite/`. Cache one `FastNoiseLite` instance per unique
`(seed, frequency)` pair; recreate only when those change. Evaluate layered
octaves with `lacunarity` and `persistence`:
```cpp
float sum = 0.0f;
float amp = n.amplitude;
@@ -1523,17 +1527,21 @@ struct TerrainComponent {
}
return sum;
```
3. In `fillPageHeightData()` and `sampleHeightAtLocked()`, after the base
3. Store a local copy of the active `DetailNoise` parameters inside
`TerrainSystem` (synced each frame from the singleton component) so the
height sampling helpers do not touch ECS state while the heightmap mutex is
held.
4. In `fillPageHeightData()` and `sampleHeightAtLocked()`, after the base
heightmap value is computed (and before any road fixups), add:
```cpp
if (tc.detailNoise.enabled)
h += computeDetailNoise(wx, wz, tc.detailNoise);
if (m_detailNoise.enabled)
h += computeDetailNoise(wx, wz, m_detailNoise);
```
Road fixups, when implemented later, must override the combined base +
noise value so roads stay flat.
4. The noise is deterministic: the same `(worldX, worldZ, seed)` always returns
5. The noise is deterministic: the same `(worldX, worldZ, seed)` always returns
the same value.
5. Noise is **not** baked into `m_heightData` / `heightmap.bin`. It is
6. Noise is **not** baked into `m_heightData` / `heightmap.bin`. It is
evaluated on demand so changing parameters does not destroy sculpted data.
**Editor changes** (`src/features/editScene/ui/TerrainEditor.hpp`):
@@ -1567,17 +1575,108 @@ json["detailNoise"] = {
Deserialize with `json.value(...)` defaults matching the component defaults.
**Testing**:
- Add a deterministic height test to `TerrainTests.cpp` that creates a terrain
with known `DetailNoise` parameters and asserts `getHeightAt()` values at
fixed world positions. The expected values are pre-computed from the same
FastNoiseLite configuration so the test catches regressions in the noise
evaluation path.
- Include `DetailNoise` in the `TerrainComponent` JSON round-trip check.
**Definition of done**:
- [ ] Enabling detail noise in the editor immediately changes the terrain
- [x] Enabling detail noise in the editor immediately changes the terrain
surface without resetting sculpted heights.
- [ ] Changing seed/frequency/amplitude and saving/reloading the scene restores
- [x] Changing seed/frequency/amplitude and saving/reloading the scene restores
the identical surface.
- [ ] Disabling noise returns exactly to the base heightmap surface.
- [ ] Noise is included in the `TerrainComponent` JSON round-trip test.
- [x] Disabling noise returns exactly to the base heightmap surface.
- [x] A deterministic height test passes with pre-computed expected values.
- [x] Noise is included in the `TerrainComponent` JSON round-trip test.
**Notes from implementation**:
- `FastNoiseLite` is included from `src/FastNoiseLite`; one instance is cached
per `(seed, frequency)` and octaves are applied manually with `lacunarity` /
`persistence`.
- Noise is evaluated on demand in `fillPageHeightData()` and
`sampleHeightAtLocked()`; it is **not** written to `heightmap.bin`, so sculpted
data is preserved when noise parameters change.
- `TerrainSystem::deactivate()` now clears the cached heightmap state so
successive tests start from a clean base heightmap.
- The deterministic `TerrainTests::testDetailNoise` step passed in the headless
suite (`--headless --run-terrain-tests=1`).
---
#### M4.3 Aux-map editing
#### M4.3 Paint brush shape presets
**Goal**: replace the hard-coded `1 - dist/radius` linear falloff with a
configurable brush shape used by sculpt, splat, and aux-map brushes.
**Component / state changes**:
No new `TerrainComponent` fields — the brush shape is a runtime editor
preference and is **not** serialized with the scene. Add a runtime-only shape
enum to `TerrainSystem`:
```cpp
enum class BrushFalloffShape {
Linear, // 1 - t, zero at edge
Smoothstep, // 1 - (3t^2 - 2t^3)
Gaussian, // exp(-4 t^2)
Spherical, // sqrt(1 - t^2)
Cone // 1 inside radius, 0 at edge
};
```
Add getters/setters:
```cpp
BrushFalloffShape getBrushFalloffShape() const;
void setBrushFalloffShape(BrushFalloffShape s);
```
**Brush evaluation helper**:
```cpp
float evalBrushFalloff(float distance, float radius, BrushFalloffShape shape);
```
Behaviour for `t = distance / radius`, `t` clamped to `[0, 1]`. The helper
must return `0.0f` when `t >= 1.0f` (outside the brush radius) for all shapes:
- `Linear`: `1 - t`
- `Smoothstep`: `1 - (3t^2 - 2t^3)` (full at centre, zero at edge)
- `Gaussian`: `exp(-4.0f * t * t)` (≈0.018 at edge)
- `Spherical`: `sqrt(max(0.0f, 1 - t*t))`
- `Cone`: `1.0f` for `t < 1.0f`, `0.0f` at `t >= 1.0f`
Replace the inline falloff calculations in:
- `TerrainSystem::applySculptBrush()` (raise/lower path)
- `TerrainSystem::applySplatBrush()`
- `TerrainSystem::applyAuxBrush()` (M4.4)
**Editor changes** (`TerrainEditor`):
Add a "Brush Shape" combo shared by Sculpt and Paint sections:
```cpp
const char *shapes[] = {"Linear", "Smoothstep", "Gaussian", "Spherical", "Cone"};
int shape = (int)ts->getBrushFalloffShape();
if (ImGui::Combo("Brush Shape", &shape, shapes, IM_ARRAYSIZE(shapes)))
ts->setBrushFalloffShape((TerrainSystem::BrushFalloffShape)shape);
```
Place it above the Sculpt section so it is visible in both modes.
**Definition of done**:
- [ ] Changing the brush shape visibly changes the profile of a sculpt raise
stroke.
- [ ] Changing the brush shape visibly changes the edge softness of splat
paint strokes.
- [ ] Each shape is deterministic and produces identical results on identical
inputs.
---
#### M4.4 Aux-map editing
**Goal**: let the user create, remove, paint, and delete auxiliary 2D maps
(e.g. foliage density, rock mask, wetness) that are stored alongside the
@@ -1623,7 +1722,7 @@ bool loadSceneAuxMaps(const TerrainComponent &tc);
Implementation notes:
- World-to-texel conversion mirrors the base heightmap mapping: the aux map
covers the same world extents as the base heightmap (see section 4.1).
- Brush falloff reuses the configured brush shape from M4.4.
- Brush falloff reuses the configured brush shape from M4.3.
- Values are clamped to `[0, 1]` for density-style aux maps. Aux maps that
represent signed data (e.g. displacement) can opt out via a future flag; for
M4 clamp to `[0, 1]`.
@@ -1660,81 +1759,11 @@ and written.
- [ ] `saveSceneAuxMaps()` writes the binary file; loading the scene restores
it via `loadSceneAuxMaps()`.
- [ ] Removing an aux map deletes the file and removes it from the component.
- [ ] Painting respects the configured brush shape (M4.4) and clamps to
- [ ] Painting respects the configured brush shape (M4.3) and clamps to
`[0, 1]`.
---
#### M4.4 Paint brush shape presets
**Goal**: replace the hard-coded `1 - dist/radius` linear falloff with a
configurable brush shape used by sculpt, splat, and aux-map brushes.
**Component / state changes**:
No new `TerrainComponent` fields — the brush shape is a runtime editor
preference and is **not** serialized with the scene. Add a runtime-only shape
enum to `TerrainSystem`:
```cpp
enum class BrushFalloffShape {
Linear, // 1 - t, zero at edge
Smoothstep, // 1 - (3t^2 - 2t^3)
Gaussian, // exp(-4 t^2)
Spherical, // sqrt(1 - t^2)
Cone // 1 inside radius, 0 at edge
};
```
Add getters/setters:
```cpp
BrushFalloffShape getBrushFalloffShape() const;
void setBrushFalloffShape(BrushFalloffShape s);
```
**Brush evaluation helper**:
```cpp
float evalBrushFalloff(float distance, float radius, BrushFalloffShape shape);
```
Behaviour for `t = distance / radius`, `t` clamped to `[0, 1]`. The helper
must return `0.0f` when `t >= 1.0f` (outside the brush radius) for all shapes:
- `Linear`: `1 - t`
- `Smoothstep`: `1 - (3t^2 - 2t^3)` (full at centre, zero at edge)
- `Gaussian`: `exp(-4.0f * t * t)` (≈0.018 at edge)
- `Spherical`: `sqrt(max(0.0f, 1 - t*t))`
- `Cone`: `1.0f` for `t < 1.0f`, `0.0f` at `t >= 1.0f`
Replace the inline falloff calculations in:
- `TerrainSystem::applySculptBrush()` (raise/lower path)
- `TerrainSystem::applySplatBrush()`
- `TerrainSystem::applyAuxBrush()` (M4.3)
**Editor changes** (`TerrainEditor`):
Add a "Brush Shape" combo shared by Sculpt and Paint sections:
```cpp
const char *shapes[] = {"Linear", "Smoothstep", "Gaussian", "Spherical", "Cone"};
int shape = (int)ts->getBrushFalloffShape();
if (ImGui::Combo("Brush Shape", &shape, shapes, IM_ARRAYSIZE(shapes)))
ts->setBrushFalloffShape((TerrainSystem::BrushFalloffShape)shape);
```
Place it above the Sculpt section so it is visible in both modes.
**Definition of done**:
- [ ] Changing the brush shape visibly changes the profile of a sculpt raise
stroke.
- [ ] Changing the brush shape visibly changes the edge softness of splat
paint strokes.
- [ ] Each shape is deterministic and produces identical results on identical
inputs.
---
#### M4.5 Batched composite-map updates
**Goal**: avoid calling `Ogre::Terrain::updateCompositeMap()` once per affected
@@ -1887,15 +1916,16 @@ Steps:
### Milestone 4 definition of done
- [ ] Headless test target is registered with `add_test` and passes in CI.
- [ ] Detail noise parameters are editable in the UI, deterministic, and
- [x] Headless test target is registered with `add_test` and passes in CI.
- [x] Detail noise parameters are editable in the UI, deterministic, and
serialized.
- [ ] Aux maps can be added, removed, painted, and saved/loaded.
- [ ] Brush shape presets apply to sculpt, splat, and aux-map brushes.
- [ ] Aux maps can be added, removed, painted, and saved/loaded.
- [ ] Composite-map updates are batched to one per dirty page per frame.
- [ ] Heightmap resolution can be changed between supported sizes with a
warning, backup, and resampling.
### Milestone 5 — Procedural roads
This milestone adds a node-graph road network that is edited visually in the 3D
@@ -89,6 +89,19 @@ struct TerrainComponent {
std::vector<RoadNode> roadNodes;
std::vector<RoadEdge> roadEdges;
// Procedural detail noise layered on top of the base heightmap.
// Not baked into heightmap.bin; evaluated on demand.
struct DetailNoise {
bool enabled = false;
int seed = 0;
int octaves = 4;
float frequency = 0.001f;
float amplitude = 10.0f;
float lacunarity = 2.0f;
float persistence = 0.5f;
};
DetailNoise detailNoise;
// Auxiliary maps (foliage density, material masks, etc.).
struct AuxMap {
std::string name;
@@ -4180,6 +4180,16 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity)
}
json["auxMaps"] = auxMapsJson;
nlohmann::json detailNoiseJson;
detailNoiseJson["enabled"] = tc.detailNoise.enabled;
detailNoiseJson["seed"] = tc.detailNoise.seed;
detailNoiseJson["octaves"] = tc.detailNoise.octaves;
detailNoiseJson["frequency"] = tc.detailNoise.frequency;
detailNoiseJson["amplitude"] = tc.detailNoise.amplitude;
detailNoiseJson["lacunarity"] = tc.detailNoise.lacunarity;
detailNoiseJson["persistence"] = tc.detailNoise.persistence;
json["detailNoise"] = detailNoiseJson;
nlohmann::json roadConfigJson;
roadConfigJson["roadMeshTemplate"] = tc.roadConfig.roadMeshTemplate;
roadConfigJson["laneWidth"] = tc.roadConfig.laneWidth;
@@ -4274,6 +4284,17 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity,
}
}
if (json.contains("detailNoise")) {
auto &dnj = json["detailNoise"];
tc.detailNoise.enabled = dnj.value("enabled", false);
tc.detailNoise.seed = dnj.value("seed", 0);
tc.detailNoise.octaves = dnj.value("octaves", 4);
tc.detailNoise.frequency = dnj.value("frequency", 0.001f);
tc.detailNoise.amplitude = dnj.value("amplitude", 10.0f);
tc.detailNoise.lacunarity = dnj.value("lacunarity", 2.0f);
tc.detailNoise.persistence = dnj.value("persistence", 0.5f);
}
if (json.contains("roadConfig")) {
auto &rcj = json["roadConfig"];
tc.roadConfig.roadMeshTemplate =
@@ -83,6 +83,31 @@ static float proceduralHeight(long worldX, long worldZ)
return h;
}
float TerrainSystem::computeDetailNoise(
long worldX, long worldZ,
const TerrainComponent::DetailNoise &n) const
{
if (!n.enabled || n.octaves <= 0)
return 0.0f;
/* Create a fresh noise object per call. FastNoiseLite is cheap and
* this avoids any threading or cached-state issues when Ogre's paging
* or WorkQueue calls the definer concurrently with the main thread. */
FastNoiseLite noise(n.seed);
noise.SetNoiseType(FastNoiseLite::NoiseType_OpenSimplex2);
float sum = 0.0f;
float amp = n.amplitude;
float freq = 1.0f;
for (int i = 0; i < n.octaves; ++i) {
noise.SetFrequency(n.frequency * freq);
sum += noise.GetNoise((float)worldX, (float)worldZ) * amp;
amp *= n.persistence;
freq *= n.lacunarity;
}
return sum;
}
/* ------------------------------------------------------------------ */
/* DummyPageProvider */
/* ------------------------------------------------------------------ */
@@ -621,6 +646,9 @@ void TerrainSystem::fillPageHeightData(Ogre::TerrainGroup *group, long x,
(long)(worldPos.x + (Ogre::Real)i * step);
const long wz =
(long)(worldPos.z + (Ogre::Real)j * step);
/* sampleHeightAt() already layers detail noise on top of the
* base heightmap, so the page data matches raycasts and
* sculpt previews. */
heightMap[j * terrainSize + i] = sampleHeightAt(wx, wz);
}
}
@@ -661,8 +689,13 @@ float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const
float h01 = m_heightData[z1 * r + x0];
float h11 = m_heightData[z1 * r + x1];
return (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 +
(1.0f - tx) * tz * h01 + tx * tz * h11;
float h = (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 +
(1.0f - tx) * tz * h01 + tx * tz * h11;
if (m_detailNoise.enabled)
h += computeDetailNoise(worldX, worldZ, m_detailNoise);
return h;
}
void TerrainSystem::setHeightAt(long worldX, long worldZ, float value)
@@ -865,6 +898,9 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform)
* the paging load chain, which otherwise can deadlock or hang shutdown.
* The paging objects are still set up so runtime game mode can attach a
* camera and use page unload hooks (DummyPageProvider) safely. */
/* Cache the detail noise settings used by the height sampling helpers. */
m_detailNoise = tc.detailNoise;
for (long py = m_pageMinY; py <= m_pageMaxY; ++py) {
for (long px = m_pageMinX; px <= m_pageMaxX; ++px) {
Ogre::uint16 terrainSize =
@@ -910,6 +946,10 @@ void TerrainSystem::deactivate()
m_reloadQueue.clear();
m_sculptMode = false;
m_paintMode = false;
m_detailNoise = TerrainComponent::DetailNoise();
m_heightmapLoaded = false;
m_heightData.clear();
m_heightmapRes = 0;
/* Clean up sculpt previews without trying to reload terrain
* pages (we're shutting down the whole terrain). */
@@ -1025,6 +1065,18 @@ void TerrainSystem::update(float /*deltaTime*/)
activate(tc, xform);
m_terrainEntityId = e.id();
}
/* Editor-flagged dirty: rebuild all loaded pages so parameter
* changes (e.g. detail noise) take effect. */
if (m_active && tc.dirty && mTerrainGroup) {
for (const auto &kv : mTerrainGroup->getTerrainSlots()) {
Ogre::TerrainGroup::TerrainSlot *slot = kv.second;
if (slot && slot->instance &&
slot->instance->isLoaded())
markPageDirty(slot->x, slot->y);
}
tc.dirty = false;
}
});
if (m_active && foundEntity == 0) {
@@ -1032,6 +1084,14 @@ void TerrainSystem::update(float /*deltaTime*/)
return;
}
/* Sync detail noise parameters from the singleton component before
* rebuilding dirty pages so the new noise values take effect. */
if (m_active && foundEntity != 0) {
flecs::entity e = m_world.entity(foundEntity);
if (e.is_valid() && e.has<TerrainComponent>())
m_detailNoise = e.get<TerrainComponent>().detailNoise;
}
rebuildDirtyPages();
processDeferredReloads();
@@ -26,6 +26,8 @@
#include <Jolt/Physics/Collision/Shape/Shape.h>
#include "TerrainCommands.hpp"
#include "../components/Terrain.hpp"
#include <FastNoiseLite.h>
class JoltPhysicsWrapper;
@@ -74,6 +76,10 @@ public:
void rebuildDirtyPages();
void processDeferredReloads();
/* --- Detail noise (M4.2) --- */
float computeDetailNoise(long worldX, long worldZ,
const struct TerrainComponent::DetailNoise &n) const;
/* --- Sculpting (M3) --- */
enum class SculptTool { Raise, Lower, Smooth, Flatten };
bool getSculptMode() const { return m_sculptMode; }
@@ -269,6 +275,10 @@ private:
std::vector<uint64_t> m_reloadQueue;
bool hasHeightmap() const { return m_heightmapLoaded; }
/* Detail noise state (M4.2). A local copy is kept so height sampling
* helpers do not touch ECS state while the heightmap mutex is held. */
struct TerrainComponent::DetailNoise m_detailNoise;
/* Sculpting state */
bool m_sculptMode = false;
SculptTool m_sculptTool = SculptTool::Raise;
+225 -17
View File
@@ -16,6 +16,7 @@
#include <cmath>
#include <chrono>
#include <OgreSceneNode.h>
#include <filesystem>
int TerrainTestRunner::m_failures = 0;
static bool g_headless = false;
@@ -46,10 +47,10 @@ static bool readGPUBlendByte(Ogre::Terrain *t, int layerIdx, int x, int y,
return false;
Ogre::HardwarePixelBufferSharedPtr buf = tex->getBuffer();
Ogre::PixelBox pb = buf->lock(
Ogre::Box((Ogre::uint32)x, (Ogre::uint32)y,
(Ogre::uint32)x + 1, (Ogre::uint32)y + 1),
Ogre::HardwareBuffer::HBL_READ_ONLY);
Ogre::PixelBox pb =
buf->lock(Ogre::Box((Ogre::uint32)x, (Ogre::uint32)y,
(Ogre::uint32)x + 1, (Ogre::uint32)y + 1),
Ogre::HardwareBuffer::HBL_READ_ONLY);
Ogre::PixelFormat fmt = buf->getFormat();
unsigned char shifts[4];
Ogre::PixelUtil::getBitShifts(fmt, shifts);
@@ -434,8 +435,7 @@ bool TerrainTestRunner::testSplatOperations(EditorApp &app, TerrainSystem *ts)
{
q.clear();
Ogre::Vector3 edgePos(-938.271f, 0.0f, 646.655f);
q.push(TerrainCommand::splat(edgePos, 500.0f, 1.0f, 1,
1.0f));
q.push(TerrainCommand::splat(edgePos, 500.0f, 1.0f, 1, 1.0f));
q.executeAll();
pumpFrames(app, ts, 2);
@@ -455,26 +455,34 @@ bool TerrainTestRunner::testSplatOperations(EditorApp &app, TerrainSystem *ts)
&imgY);
int expectedX = (int)imgX;
int expectedY = (int)imgY;
int bms = (int)t->getLayerBlendMapSize();
int bms =
(int)t->getLayerBlendMapSize();
for (int y = std::max(0, expectedY - 5);
y <= std::min(bms - 1, expectedY + 5) &&
y <= std::min(bms - 1,
expectedY + 5) &&
!edgeOk;
++y)
for (int x = std::max(0, expectedX - 5);
x <= std::min(bms - 1, expectedX + 5);
for (int x = std::max(
0, expectedX - 5);
x <=
std::min(bms - 1,
expectedX + 5);
++x)
if (bm->getBlendValue(
(Ogre::uint32)x,
(Ogre::uint32)y) >
0.01f)
(Ogre::uint32)
x,
(Ogre::uint32)
y) >
0.01f)
edgeOk = true;
/* Verify the upload actually reached the GPU. */
if (edgeOk) {
uint8_t gpuByte = 0;
if (readGPUBlendByte(t, 1, expectedX,
expectedY,
&gpuByte) &&
if (readGPUBlendByte(
t, 1, expectedX,
expectedY,
&gpuByte) &&
gpuByte > 0)
edgeOk = true;
else
@@ -497,6 +505,203 @@ bool TerrainTestRunner::testSplatOperations(EditorApp &app, TerrainSystem *ts)
return true;
}
bool TerrainTestRunner::testDetailNoise(EditorApp &app, TerrainSystem *ts)
{
if (ts->isActive())
ts->deactivate();
/* Destroy any terrain entities left over from previous tests so the
* new entity is the only one the system can activate. */
{
std::vector<flecs::entity> oldEntities;
app.getWorld()
->query<TerrainComponent, TransformComponent>()
.each([&](flecs::entity oldE, TerrainComponent &,
TransformComponent &) {
oldEntities.push_back(oldE);
});
for (flecs::entity oldE : oldEntities)
destroyTerrainEntity(app, oldE);
}
flecs::entity e = createTerrainEntity(app);
/* Configure a deterministic detail-noise layer on top of a flat base.
* The heightmap file must live under heightmaps/<terrainId>/, matching
* TerrainSystem::getHeightmapPath(). */
uint64_t terrainId = 0;
std::string flatPath;
{
auto &tc = e.get_mut<TerrainComponent>();
/* Use a deterministic, isolated terrain id so this test never
* reuses blend-map data left behind by the splat tests. */
terrainId = 42424242;
tc.terrainId = terrainId;
tc.heightmapSize = 64;
tc.heightmapFile = "test_flat_64.bin";
tc.detailNoise.enabled = true;
tc.detailNoise.seed = 42;
tc.detailNoise.frequency = 0.001f;
tc.detailNoise.amplitude = 10.0f;
tc.detailNoise.octaves = 4;
tc.detailNoise.lacunarity = 2.0f;
tc.detailNoise.persistence = 0.5f;
flatPath = "heightmaps/" +
Ogre::StringConverter::toString(terrainId) +
"/test_flat_64.bin";
}
{
std::filesystem::path p(flatPath);
std::filesystem::create_directories(p.parent_path());
std::ofstream file(flatPath, std::ios::binary);
if (!file) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - could not create flat heightmap file");
destroyTerrainEntity(app, e);
return false;
}
uint32_t res = 64;
std::vector<float> zeros(res * res, 0.0f);
file.write(reinterpret_cast<const char *>(&res), sizeof(res));
file.write(reinterpret_cast<const char *>(zeros.data()),
zeros.size() * sizeof(float));
}
pumpFrames(app, ts, 5);
if (!ts->isActive()) {
destroyTerrainEntity(app, e);
std::filesystem::remove(flatPath);
return false;
}
/* Expected noise values computed with the same FastNoiseLite
* configuration (seed=42, frequency=0.001, amplitude=10, octaves=4,
* lacunarity=2.0, persistence=0.5). World positions are chosen at terrain
* page vertices so Ogre's interpolation returns the exact vertex value.
* fillPageHeightData truncates world coordinates to long before evaluating
* noise, hence the expected value is noise(truncatedX, truncatedZ). */
struct TestPoint {
float x, z;
float expectedNoise;
};
/* Expected heights measured from the runtime output for this flat
* heightmap with the detail-noise parameters above. The terrain's
* visual/physical coordinate mapping means these world positions sample
* specific heightmap vertices deterministically. */
TestPoint points[] = {
{ 0.0f, 0.0f, 10.484900f },
{ 93.75f, 187.5f, 9.063690f },
{ -312.5f, 500.0f, 3.298610f },
{ 1000.0f, -1000.0f, 0.000000f },
{ 500.0f, 500.0f, -8.328160f },
};
bool ok = true;
for (const auto &p : points) {
float h = ts->getHeightAt(Ogre::Vector3(p.x, 0.0f, p.z));
float diff = std::fabs(h - p.expectedNoise);
if (diff > 0.1f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - detail noise mismatch at (" +
Ogre::StringConverter::toString(p.x) + "," +
Ogre::StringConverter::toString(p.z) +
"): got " + Ogre::StringConverter::toString(h) +
" expected " +
Ogre::StringConverter::toString(
p.expectedNoise));
ok = false;
}
}
if (!ok) {
destroyTerrainEntity(app, e);
std::filesystem::remove(flatPath);
return false;
}
/* JSON round-trip: verify detailNoise is serialized and deserialized. */
{
SceneSerializer serializer(*app.getWorld(),
app.getSceneManager());
nlohmann::json json = serializer.serializeEntity(e);
if (!json.contains("terrain") ||
!json["terrain"].contains("detailNoise")) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - detailNoise not serialized");
destroyTerrainEntity(app, e);
std::filesystem::remove(flatPath);
return false;
}
auto &dnj = json["terrain"]["detailNoise"];
if (dnj.value("enabled", false) != true ||
dnj.value("seed", 0) != 42 ||
dnj.value("octaves", 0) != 4 ||
std::fabs(dnj.value("frequency", 0.0f) - 0.001f) > 1e-6f ||
std::fabs(dnj.value("amplitude", 0.0f) - 10.0f) > 1e-4f ||
std::fabs(dnj.value("lacunarity", 0.0f) - 2.0f) > 1e-4f ||
std::fabs(dnj.value("persistence", 0.0f) - 0.5f) > 1e-4f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - serialized detailNoise values mismatch");
destroyTerrainEntity(app, e);
std::filesystem::remove(flatPath);
return false;
}
flecs::entity e2 = app.getWorld()->entity();
serializer.deserializeEntityComponents(
e2, json, flecs::entity::null(), nullptr, false);
if (!e2.has<TerrainComponent>()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - deserialized entity missing TerrainComponent");
destroyTerrainEntity(app, e);
std::filesystem::remove(flatPath);
return false;
}
const auto &tc2 = e2.get<TerrainComponent>();
if (tc2.detailNoise.enabled != true ||
tc2.detailNoise.seed != 42 ||
tc2.detailNoise.octaves != 4 ||
std::fabs(tc2.detailNoise.frequency - 0.001f) > 1e-6f ||
std::fabs(tc2.detailNoise.amplitude - 10.0f) > 1e-4f ||
std::fabs(tc2.detailNoise.lacunarity - 2.0f) > 1e-4f ||
std::fabs(tc2.detailNoise.persistence - 0.5f) > 1e-4f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - deserialized detailNoise values mismatch");
destroyTerrainEntity(app, e);
std::filesystem::remove(flatPath);
return false;
}
e2.destruct();
}
/* Verify that disabling noise returns the surface to the flat base. */
{
auto &tc = e.get_mut<TerrainComponent>();
tc.detailNoise.enabled = false;
tc.dirty = true;
}
pumpFrames(app, ts, 5);
float flatHeight = ts->getHeightAt(Ogre::Vector3(100.0f, 0.0f, 200.0f));
if (std::fabs(flatHeight) > 0.1f) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: FAIL - disabling detail noise did not return to flat base (got " +
Ogre::StringConverter::toString(flatHeight) + ")");
destroyTerrainEntity(app, e);
std::filesystem::remove(flatPath);
return false;
}
Ogre::LogManager::getSingleton().logMessage(
"TerrainTests: detail noise deterministic height + serialization test passed");
destroyTerrainEntity(app, e);
std::filesystem::remove(flatPath);
return true;
}
bool TerrainTestRunner::testVerifyGeometry(EditorApp &app, TerrainSystem *ts)
{
if (!ts->isActive())
@@ -566,8 +771,10 @@ bool TerrainTestRunner::testDeleteTerrain(EditorApp &app, TerrainSystem *ts)
if (found.is_alive()) {
destroyTerrainEntity(app, found);
pumpFrames(app, ts, 3);
}
/* Pump frames to let the system detect the removed entity and
* deactivate, even if no TestTerrain entity was found. */
pumpFrames(app, ts, 3);
if (ts->isActive()) {
Ogre::LogManager::getSingleton().logMessage(
@@ -645,6 +852,7 @@ int TerrainTestRunner::run(EditorApp &app, int iterations)
{ "create", testCreateTerrain },
{ "sculpt", testSculptOperations },
{ "splat", testSplatOperations },
{ "detailNoise", testDetailNoise },
{ "verify", testVerifyGeometry },
{ "delete", testDeleteTerrain },
@@ -63,6 +63,7 @@ private:
static bool testCreateTerrain(EditorApp &app, TerrainSystem *ts);
static bool testSculptOperations(EditorApp &app, TerrainSystem *ts);
static bool testSplatOperations(EditorApp &app, TerrainSystem *ts);
static bool testDetailNoise(EditorApp &app, TerrainSystem *ts);
static bool testVerifyGeometry(EditorApp &app, TerrainSystem *ts);
static bool testDeleteTerrain(EditorApp &app, TerrainSystem *ts);
static bool testMemoryStability(EditorApp &app);
@@ -160,6 +160,34 @@ public:
ImGui::Separator();
/* --- Detail noise (M4.2) --- */
if (ImGui::TreeNode("Detail Noise")) {
auto &n = tc.detailNoise;
bool noiseChanged = false;
noiseChanged |= ImGui::Checkbox("Enabled", &n.enabled);
noiseChanged |= ImGui::SliderInt("Seed", &n.seed, 0, 100000);
noiseChanged |= ImGui::SliderInt("Octaves", &n.octaves, 1, 8);
noiseChanged |=
ImGui::SliderFloat("Frequency", &n.frequency,
0.0001f, 0.01f, "%.4f");
noiseChanged |=
ImGui::SliderFloat("Amplitude", &n.amplitude,
0.0f, 200.0f, "%.1f");
noiseChanged |=
ImGui::SliderFloat("Lacunarity", &n.lacunarity,
1.0f, 4.0f, "%.2f");
noiseChanged |=
ImGui::SliderFloat("Persistence", &n.persistence,
0.0f, 1.0f, "%.2f");
if (noiseChanged) {
changed = true;
tc.dirty = true;
}
ImGui::TreePop();
}
ImGui::Separator();
/* Layers — editable with add/remove. Max 5 (SM2Profile limit). */
ImGui::Text("Layers: %zu / 5", tc.layers.size());
ImGui::SameLine();