diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 79b0da4..7a93961 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -1845,7 +1845,7 @@ and written. --- -#### M4.5 Batched composite-map updates +#### M4.5 Batched composite-map updates ✅ DONE — verified **Goal**: avoid calling `Ogre::Terrain::updateCompositeMap()` once per affected page on every brush stroke; batch updates to one composite-map rebuild per @@ -1885,11 +1885,27 @@ inside the page loop for every stroke. `m_compositeDirtyPages` is only touched from the main thread, so no lock is needed. +**Notes from implementation**: +- `TerrainSystem` gained a `std::set m_compositeDirtyPages` member and + a public `getCompositeDirtyPageCount()` accessor used by the test suite. +- `applySplatBrush()` now calls `blendMap->update()` to upload the blend texture + immediately, then inserts the packed page key into `m_compositeDirtyPages` + instead of calling `terrain->updateCompositeMap()`. +- `TerrainSystem::update()` drains the set once per frame after collider + processing, calling `updateCompositeMap()` exactly once for each unique dirty + page and then clearing the set. +- The set is also cleared in `deactivate()` so no stale state survives terrain + teardown. +- A new automated test, `TerrainTestRunner::testCompositeMapBatching`, verifies + that a single-page stroke produces one dirty entry, a duplicate stroke on the + same page does not increase the count, and a cross-page stroke produces two + entries before the next update drains them. + **Definition of done**: -- [ ] Painting a large brush that touches four pages calls +- [x] Painting a large brush that touches four pages calls `updateCompositeMap()` exactly four times in the next frame, not four times per mouse-drag event. -- [ ] Distance rendering of splat-painted terrain is still correct after the +- [x] Distance rendering of splat-painted terrain is still correct after the batched update. --- @@ -2002,8 +2018,8 @@ Steps: - [x] Detail noise parameters are editable in the UI, deterministic, and serialized. - [x] 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. +- [x] Aux maps can be added, removed, painted, and saved/loaded. +- [x] 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. diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index 19f89a1..8d54ad4 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -1353,6 +1353,7 @@ void TerrainSystem::deactivate() m_terrainEntityId = 0; m_dirtyPages.clear(); m_reloadQueue.clear(); + m_compositeDirtyPages.clear(); m_sculptMode = false; m_paintMode = false; m_detailNoise = TerrainComponent::DetailNoise(); @@ -1515,6 +1516,19 @@ void TerrainSystem::update(float /*deltaTime*/) processColliderRemoves(); processColliderCreates(); + /* Drain batched composite-map updates (M4.5). One update per dirty + * page per frame, regardless of how many brush strokes touched it. */ + if (mTerrainGroup && !m_compositeDirtyPages.empty()) { + for (uint64_t key : m_compositeDirtyPages) { + long px, py; + mTerrainGroup->unpackIndex(key, &px, &py); + Ogre::Terrain *t = mTerrainGroup->getTerrain(px, py); + if (t) + t->updateCompositeMap(); + } + m_compositeDirtyPages.clear(); + } + if (m_auxVisDirty) buildAuxMapVisualization(); @@ -1979,10 +1993,11 @@ void TerrainSystem::applySplatBrush(const Ogre::Vector3 &worldPos) (Ogre::uint32)cxClamped, (Ogre::uint32)cyClamped); blendMap->update(); - /* Update the composite map so the painted region is also - * visible at distance. The blend-map texture itself has - * already been uploaded by update(). */ - terrain->updateCompositeMap(); + /* Batch composite-map updates so a single brush stroke + * touching multiple pages only rebuilds each page's + * composite map once per frame (M4.5). */ + m_compositeDirtyPages.insert( + mTerrainGroup->packIndex(px, py)); /* Debug: read back the GPU blend value at the centre pixel * to verify the upload actually reached the GPU. */ diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index f5f650a..f098eff 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -206,6 +206,12 @@ public: } void applySplatBrush(const Ogre::Vector3 &worldPos); + /* --- Composite-map batching (M4.5) --- */ + size_t getCompositeDirtyPageCount() const + { + return m_compositeDirtyPages.size(); + } + /* --- Aux-map painting (M4.4) --- */ bool getAuxPaintMode() const { @@ -410,6 +416,10 @@ private: std::set m_dirtyPages; std::vector m_reloadQueue; + + /* Composite-map dirty pages (M4.5). Updated by applySplatBrush and + * drained once per frame in update(). */ + std::set m_compositeDirtyPages; bool hasHeightmap() const { return m_heightmapLoaded; diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 541d561..227e0b0 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -890,6 +890,76 @@ bool TerrainTestRunner::testAuxMapOperations(EditorApp &app, TerrainSystem *ts) return true; } +bool TerrainTestRunner::testCompositeMapBatching(EditorApp &app, + TerrainSystem *ts) +{ + (void)app; + + if (!ts->isActive()) + return false; + + /* Ensure paint settings target a valid blendable layer. */ + ts->setPaintLayerIndex(1); + ts->setPaintRadius(200.0f); + ts->setPaintStrength(1.0f); + + /* Drain any leftover composite dirty state from earlier tests. */ + pumpFrames(app, ts, 2); + if (ts->getCompositeDirtyPageCount() != 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - composite dirty pages not empty at test start"); + return false; + } + + /* Stroke inside page (0,0) only. */ + ts->applySplatBrush(Ogre::Vector3(0.0f, 0.0f, 0.0f)); + if (ts->getCompositeDirtyPageCount() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - expected 1 composite dirty page after single-page stroke, got " + + Ogre::StringConverter::toString( + (long)ts->getCompositeDirtyPageCount())); + return false; + } + + /* A second stroke on the same page must not increase the count. */ + ts->applySplatBrush(Ogre::Vector3(0.0f, 0.0f, 0.0f)); + if (ts->getCompositeDirtyPageCount() != 1) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - duplicate stroke increased composite dirty page count"); + return false; + } + + /* Let update() drain the batch. */ + pumpFrames(app, ts, 1); + if (ts->getCompositeDirtyPageCount() != 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - composite dirty pages not drained after update"); + return false; + } + + /* Stroke near the +X page boundary so the brush overlaps page (0,0) + * and page (1,0). */ + ts->applySplatBrush(Ogre::Vector3(900.0f, 0.0f, 0.0f)); + if (ts->getCompositeDirtyPageCount() != 2) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - expected 2 composite dirty pages after cross-page stroke, got " + + Ogre::StringConverter::toString( + (long)ts->getCompositeDirtyPageCount())); + return false; + } + + pumpFrames(app, ts, 1); + if (ts->getCompositeDirtyPageCount() != 0) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - composite dirty pages not drained after cross-page update"); + return false; + } + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: composite map batching test passed"); + return true; +} + bool TerrainTestRunner::testVerifyGeometry(EditorApp &app, TerrainSystem *ts) { if (!ts->isActive()) @@ -1043,6 +1113,7 @@ int TerrainTestRunner::run(EditorApp &app, int iterations) { "detailNoise", testDetailNoise }, { "brushShapes", testBrushShapes }, { "auxMapOps", testAuxMapOperations }, + { "compositeBatching", testCompositeMapBatching }, { "verify", testVerifyGeometry }, { "delete", testDeleteTerrain }, diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index 6e7481b..f9cc9e3 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -66,6 +66,7 @@ private: static bool testDetailNoise(EditorApp &app, TerrainSystem *ts); static bool testBrushShapes(EditorApp &app, TerrainSystem *ts); static bool testAuxMapOperations(EditorApp &app, TerrainSystem *ts); + static bool testCompositeMapBatching(EditorApp &app, TerrainSystem *ts); static bool testVerifyGeometry(EditorApp &app, TerrainSystem *ts); static bool testDeleteTerrain(EditorApp &app, TerrainSystem *ts); static bool testMemoryStability(EditorApp &app);