Detail noise implemented
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user