Batched composite-map updates

This commit is contained in:
2026-07-12 14:25:38 +03:00
parent ffbc85d4e2
commit 32471f0db6
5 changed files with 122 additions and 9 deletions
+21 -5
View File
@@ -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<uint64_t> 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.
@@ -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. */
@@ -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<uint64_t> m_dirtyPages;
std::vector<uint64_t> m_reloadQueue;
/* Composite-map dirty pages (M4.5). Updated by applySplatBrush and
* drained once per frame in update(). */
std::set<uint64_t> m_compositeDirtyPages;
bool hasHeightmap() const
{
return m_heightmapLoaded;
@@ -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 },
@@ -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);