diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 385489f..6e8b647 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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 ) diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index b9aef61..7760c9e 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -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 diff --git a/src/features/editScene/components/Terrain.hpp b/src/features/editScene/components/Terrain.hpp index ee98993..3dea548 100644 --- a/src/features/editScene/components/Terrain.hpp +++ b/src/features/editScene/components/Terrain.hpp @@ -89,6 +89,19 @@ struct TerrainComponent { std::vector roadNodes; std::vector 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; diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index b3f2e59..8f3b25a 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -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 = diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index f1fdc18..1bac9ca 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -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()) + m_detailNoise = e.get().detailNoise; + } + rebuildDirtyPages(); processDeferredReloads(); diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index c7f31c4..01f70d1 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -26,6 +26,8 @@ #include #include "TerrainCommands.hpp" +#include "../components/Terrain.hpp" +#include 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 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; diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index fc8271d..ba58e83 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -16,6 +16,7 @@ #include #include #include +#include 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 oldEntities; + app.getWorld() + ->query() + .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//, matching + * TerrainSystem::getHeightmapPath(). */ + uint64_t terrainId = 0; + std::string flatPath; + { + auto &tc = e.get_mut(); + /* 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 zeros(res * res, 0.0f); + file.write(reinterpret_cast(&res), sizeof(res)); + file.write(reinterpret_cast(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()) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - deserialized entity missing TerrainComponent"); + destroyTerrainEntity(app, e); + std::filesystem::remove(flatPath); + return false; + } + const auto &tc2 = e2.get(); + 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(); + 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 }, diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index 37623ae..c63fbb9 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -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); diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 90832a6..02c8c35 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -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();