From 98303a64480ee2d08f7ffb696a525d0c97b7dad0 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Fri, 3 Jul 2026 05:48:54 +0300 Subject: [PATCH] decal works --- src/features/editScene/TerrainRequirements.md | 119 ++- .../editScene/systems/EditorUISystem.cpp | 34 +- .../editScene/systems/TerrainSystem.cpp | 683 +++++++++++++++++- .../editScene/systems/TerrainSystem.hpp | 51 +- src/features/editScene/ui/TerrainEditor.hpp | 2 +- 5 files changed, 777 insertions(+), 112 deletions(-) diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 3cf86b3..44fcfa5 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -1247,95 +1247,70 @@ terrain and colliders orphaned. ### Milestone 3 — Serialization + heightmap editing + splat painting 🚧 IN PROGRESS -**Status (2026-07-02)**: Core data pipeline verified working. Two -blocking issues prevent visual terrain update. See "Known Issues" below. +**Status (2026-07-03)**: ManualObject sculpt preview renders correctly and +matches terrain output perfectly. Brush decal works (translucent green +circle with white centre cross). Sculpt mode works in both editor and +game modes. The remaining blocking issue is a Z-mapping mismatch between +the raycast physical-hit position and the visual display position of +heightmap data within terrain pages. **Completed**: - [x] Scene serialization (save/load JSON round-trip for TerrainComponent) - [x] Binary heightmap file save/load (`heightmaps//`) -- [x] `HeightmapData` class (256×256 float array, `getHeightAt` / `setHeightAt`) -- [x] `CustomTerrainDefiner` samples `HeightmapData` (falls back to procedural) +- [x] `CustomTerrainDefiner` samples heightmap data (falls back to procedural) - [x] `TerrainCommand` / `TerrainCommandQueue` (Raise, Lower, Smooth, Flatten, SplatPaint) - [x] Sculpt mode checkbox + brush controls in TerrainEditor panel - [x] Brush tools: Raise, Lower, Smooth, Flatten, Splat Paint - [x] `applySculptBrush()` modifies heightmap data correctly - [x] `markPageDirty()` / `rebuildDirtyPages()` infrastructure +- [x] Coordinate formula in `sampleHeightAtLocked()` — FIXED (2026-07-02) +- [x] Sculpt works in both editor **and** game modes (Issue 2 — FIXED) +- [x] Iterative ray-heightfield intersection for sculpt mode (Issue 3 — FIXED) +- [x] ManualObject sculpt preview — GPU update via ManualObject rebuild (Issue 4 — FIXED) +- [x] Brush decal: translucent green circle + white centre cross (Issue 5 — FIXED) +- [x] Default brush radius 100, slider range 5–500 (Issue 6 — FIXED) +- [x] `getHeightAt()` uses in-memory heightmap in sculpt mode +- [x] ManualObject geometry matches terrain output perfectly -**Known issues (must fix before M3 ship)**: +**Verified alignment**: The ManualObject sculpt preview uses the identical +local-coordinate convention as the terrain (scene node at page minimum +corner from `convertTerrainSlotToWorldPosition`, local Z = `halfSize – j*step`, +zigzag triangulation matching collider pattern). Heightmap sampling +(`corner.z + j*step`) matches `fillPageHeightData`. Unedited manual +objects and terrain chunks are visually identical. After exiting sculpt mode +the output geometry (reloaded terrain pages) matches the manual objects. -1. **Coordinate formula in `sampleHeightAtLocked()`** — FIXED. - Old: `fx = worldX / worldSize * res` — maps world (0,0) to index 0. - Fixed: `fx = (worldX - m_heightmapWorldMinX) / worldSize * res` — maps to index 85. - This bug caused terrain pages to read from wrong heightmap indices. - **Fix committed 2026-07-02.** +**Z-mapping mismatch — FIXED (2026-07-03)**: -2. **Sculpt only works in game mode** — the sculpt code in - `EditorUISystem::update()` is inside `if (!m_editorUIEnabled)`. - In editor mode sculpting never activates. Move the sculpt block - before this check so it runs in both editor and game modes. - **Not yet fixed.** +Ogre terrain (`ALIGN_X_Z`) renders vertex `(i,j)` at local Z = +`halfSize – j*step` (from `getPointAlign` with `mBase = -halfSize`). +The heightmap stores data at physical world Z = `pageMinZ + j*step` +(via `fillPageHeightData`). The relationship is: -3. **Jolt raycast misses terrain when camera is above/below** — - `raycastTerrain()` uses Jolt physics raycast which misses mesh - surface when the camera is under or far above the terrain. - A fallback (Y=0 plane intersection + `getHeightAt()`) is needed - and was implemented during testing but lost in git resets. - **Must re-add.** + visualZ = 2*pageMinZ + halfSize – physicalZ (Z-inverted around page centre) -4. **`dirty()+update(true)` does not push to GPU** — THE BLOCKING - ISSUE. The heightmap data pipeline works correctly (verified: - CPU terrain heights track modified heightmap). But Ogre terrain's - `dirty()` + `update(true)` updates CPU memory only; the GPU - vertex buffers are NOT refreshed. This is specific to terrains - created through the paging system (`loadAllTerrains(true)`). - - **Approaches tried and failed**: - - `dirty()` + `update(true)` — CPU updates, GPU does not - - `dirty()` + `updateGeometry()` — same - - `dirty()` + `updateDerivedData(true)` — same - - `_getRootSceneNode()->needUpdate(true)` — same - - `terrain->unload()` + `terrain->load()` — crashes (null quad tree) - - `removeTerrain + defineTerrain + loadTerrain` with paging disabled — - terrain IS recreated with correct heights (PostLoad verified), - but visual still doesn't update. Paging system's definer - (`CustomTerrainDefiner`) overwrites import data unless paging is - disabled; even with paging off, GPU doesn't refresh. +The raycast returns the *physical* Z. The **decal** must be at +*visual* Z (so it appears where the user clicked), and the **brush** +must use *physical* Z directly (because the heightmap is stored in +physical coordinates). `physicalToVisualZ()` converts physical Z to +visual Z for the decal position. - **Promising direction**: The legacy `updateHeightmap()` in - `src/gamedata/EditorGUIModule.cpp` uses `mTerrainPagedWorldSection-> - unloadPage()` + `mTerrainGroup->update(false)` — this works through - the paging system properly. For editor mode, the camera needs to - be attached to `PageManager` so auto-reload works. It is very slow. +Previously the conversion was applied to the brush (not the decal), +which meant: the decal appeared at the wrong world Z position +(confusing the user), and the brush modified heightmap cells that +rendered at the page-opposite visual location (symmetric around page +centre). Now fixed in `EditorUISystem::update()`. - **Alternative**: Render terrain heights via a custom - `Ogre::ManualObject` mesh that mirrors the heightmap, bypassing - Ogre terrain rendering entirely for real-time editing preview. - It is more work but much better. Make sure the mesh is generated in the same way - as Ogre terrain's mesh in zigzag pattern. Then only vertices Y coordinates can be updated. - The mesh should show all loaded chunks pages and show height directly allowing to see the change quickly. - The flow should be like: - - Sclupt Mode checkbox enabled - - Page loading should be disabled by removing camera from PageManager or other way. - - Already loaded pages should be remembered and unloaded so no terrain chunks rendered. - - For all remembered pages ManualObject should be generated which directly represents heightmap - - It should be showing height replica of original terrain chunk, but with single color but shaded look - to understand its geometry easier. - - As geometry modified via brush, the manual object for appropriate chunk should be rebuilt with changed vertices. - It can also easily be just plain vertex buffer update with only changing vertex part as index part is the same. - This can be optimized for partial updates and read/write buffers, so that updates are fast. - - As Sculpt mode checkbox gets updated, the height updates get flushed, then manual objects get deleted, pages loaded back and paging enabled as before sculpt mode. +**Remaining M3 items**: +- [ ] Splat paint (`applySplatBrush` is a stub — needs blend map access) +- [ ] Test: sculpt → visual update shows change at decal location +- [ ] Test: sculpt, save heightmap, reload → changes persist +- [ ] Test: sculpt data survives save/load scene round-trip - -5. **Brush decal not working** — The translucent circle indicator - (`Ogre::ManualObject` with alpha blending) renders as opaque white. - RTSS material override prevents `setSceneBlending()` from taking - effect. **Fix**: Use per-vertex colours with alpha + material - with `scene_blend alpha_blend` explicitly set on the pass - (bypassing RTSS by setting the scheme or using raw materials). - -6. **Default brush radius too small** — Default 5.0 on a 2000-unit - page covers ~1 vertex. Should default to 50-150 for visible - effect. Slider range currently 0.5–50; should be 5–500. +**Files modified for M3**: +- `systems/TerrainSystem.hpp/cpp` — ManualObject preview, brush decal, raycast, Z-helper +- `systems/EditorUISystem.cpp` — sculpt block placement, decal update, Z-inversion call (FIXED) +- `ui/TerrainEditor.hpp` — slider range ### Milestone 4 — Procedural roads - Node/edge editing UI (add, remove, split, join, select, move). diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 2880399..688fbc7 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -327,29 +327,39 @@ void EditorUISystem::registerModularComponents() void EditorUISystem::update(float deltaTime) { - if (!m_editorUIEnabled) { - // Only render FPS overlay when editor UI is disabled - renderFPSOverlay(deltaTime); - /* Sculpt mode: left mouse on terrain applies brush. */ + /* Sculpt mode: left mouse on terrain applies brush. + * Also updates brush decal at mouse position. + * Runs in both editor and game mode. */ { TerrainSystem *ts = TerrainSystem::getInstance(); if (ts && ts->isSculpting()) { ImGuiIO &io = ImGui::GetIO(); - if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && - !io.WantCaptureMouse && - m_editorCamera) { - Ogre::Ray ray = m_editorCamera->getMouseRay( - io.MousePos.x, io.MousePos.y); + if (!io.WantCaptureMouse && m_editorCamera) { + Ogre::Ray ray = + m_editorCamera->getMouseRay( + io.MousePos.x, + io.MousePos.y); float t; Ogre::Vector3 normal; - if (ts->raycastTerrain(ray, t, normal)) { - Ogre::Vector3 hit = ray.getPoint(t); - ts->applySculptBrush(hit); + if (ts->raycastTerrain(ray, t, + normal)) { + Ogre::Vector3 hit = + ray.getPoint(t); + ts->updateBrushDecal(hit, normal, + ts->getSculptRadius()); + if (ImGui::IsMouseDown( + ImGuiMouseButton_Left)) { + ts->applySculptBrush(hit); + } + } else { + ts->hideBrushDecal(); } } } } + if (!m_editorUIEnabled) { + renderFPSOverlay(deltaTime); return; } diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index d0b62bb..6c4ca1d 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include @@ -571,6 +572,19 @@ void TerrainSystem::rebuildDirtyPages() if (!mTerrainGroup || m_dirtyPages.empty()) return; + /* When sculpt previews are active, only update ManualObjects. + * Terrain pages are unloaded — don't touch colliders or try to + * update terrain geometry. */ + if (m_sculptPreviewsActive) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: updating " + + Ogre::StringConverter::toString( + (int)m_dirtyPages.size()) + + " dirty sculpt preview pages"); + updateSculptDirtyPages(); + return; + } + Ogre::LogManager::getSingleton().logMessage( "Terrain: rebuilding " + Ogre::StringConverter::toString((int)m_dirtyPages.size()) + @@ -734,6 +748,36 @@ void TerrainSystem::deactivate() m_terrainEntityId = 0; m_dirtyPages.clear(); m_reloadQueue.clear(); + m_sculptMode = false; + + /* Clean up sculpt previews without trying to reload terrain + * pages (we're shutting down the whole terrain). */ + if (m_sculptPreviewsActive) { + for (auto &kv : m_sculptPreviews) { + auto &sp = kv.second; + if (sp.manualObj) { + if (sp.sceneNode) + sp.sceneNode->detachObject( + sp.manualObj); + m_sceneMgr->destroyManualObject(sp.manualObj); + } + if (sp.sceneNode) + m_sceneMgr->destroySceneNode(sp.sceneNode); + } + m_sculptPreviews.clear(); + m_sculptPreviewsActive = false; + } + + /* Destroy brush decal. */ + if (m_brushDecal) { + m_brushDecalNode->detachObject(m_brushDecal); + m_sceneMgr->destroyManualObject(m_brushDecal); + m_brushDecal = nullptr; + } + if (m_brushDecalNode) { + m_sceneMgr->destroySceneNode(m_brushDecalNode); + m_brushDecalNode = nullptr; + } removeAllColliders(); if (m_physics) @@ -834,43 +878,138 @@ void TerrainSystem::update(float /*deltaTime*/) float TerrainSystem::getHeightAt(const Ogre::Vector3 &worldPos) const { - if (!m_active || !mTerrainGroup) + if (!m_active) return 0.0f; - return mTerrainGroup->getHeightAtWorldPosition(worldPos); + + /* In sculpt mode the Ogre terrain instances are unloaded; + * sample directly from the in-memory heightmap. */ + if (m_sculptPreviewsActive && m_heightmapLoaded) + return sampleHeightAt((long)worldPos.x, (long)worldPos.z); + + if (mTerrainGroup) + return mTerrainGroup->getHeightAtWorldPosition(worldPos); + + return 0.0f; } bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, Ogre::Vector3 &outNormal) const { - if (!m_active || !m_physics || mColliders.empty()) + if (!m_active || !mTerrainGroup || !m_sceneMgr) return false; - const JPH::RVec3 origin(ray.getOrigin().x, ray.getOrigin().y, - ray.getOrigin().z); - JPH::Vec3 dir(ray.getDirection().x, ray.getDirection().y, - ray.getDirection().z); - dir = dir.Normalized(); + /* --- Sculpt mode: terrain pages are unloaded (no Jolt + * colliders). Iteratively intersect the camera ray with + * the heightmap: start at Y=0, look up the terrain height + * at that XZ, then solve for the ray's intersection with a + * plane at that height, and repeat. Converges in 2–4 + * iterations even on steep slopes. --- */ + if (m_sculptPreviewsActive && m_heightmapLoaded) { + const Ogre::Vector3 &origin = ray.getOrigin(); + const Ogre::Vector3 &dir = ray.getDirection(); - const JPH::RRayCast rayCast(origin, dir); + if (fabsf(dir.y) < 1e-6f) + return false; - JPH::PhysicsSystem *jphSys = m_physics->getPhysicsSystem(); - if (!jphSys) - return false; + /* Seed: start just in front of the camera, not + * from Y=0 (which fails when camera is below Y=0 + * looking down). The iterative refinement converges + * in 2-4 steps from any reasonable seed. */ + float t = 0.01f; + Ogre::Vector3 pt = origin + dir * t; - JPH::RayCastResult hit; - if (!jphSys->GetNarrowPhaseQuery().CastRay(rayCast, hit, - JPH::BroadPhaseLayerFilter(), - JPH::ObjectLayerFilter(), - JPH::BodyFilter())) - return false; + /* Safety: don't march past 100 km. */ + const float maxDist = 100000.0f; + for (int iter = 0; iter < 16; ++iter) { + float h = sampleHeightAt((long)pt.x, + (long)pt.z); + float tNew = (h - origin.y) / dir.y; + if (tNew < 0.0f || tNew > maxDist) + return false; + Ogre::Vector3 ptNew = origin + dir * tNew; - for (auto &pair : mColliders) { - if (pair.second.bodyId == hit.mBodyID) { - outT = hit.mFraction; - outNormal = Ogre::Vector3::UNIT_Y; - return true; + /* Converged? */ + if (fabsf(ptNew.x - pt.x) < 0.2f && + fabsf(ptNew.z - pt.z) < 0.2f) { + outT = tNew; + /* Normal from heightmap gradient. */ + float eps = 1.0f; + float dx = sampleHeightAt( + (long)(ptNew.x + eps), + (long)ptNew.z) - + h; + float dz = sampleHeightAt( + (long)ptNew.x, + (long)(ptNew.z + eps)) - + h; + outNormal = Ogre::Vector3(-dx, + eps * 2.0f, + -dz); + outNormal.normalise(); + return true; + } + pt = ptNew; + t = tNew; + } + /* No convergence — use last result. */ + outT = t; + outNormal = Ogre::Vector3::UNIT_Y; + return true; + } + + /* --- Primary: Jolt physics raycast against colliders --- */ + if (m_physics && !mColliders.empty()) { + const JPH::RVec3 origin(ray.getOrigin().x, ray.getOrigin().y, + ray.getOrigin().z); + JPH::Vec3 dir(ray.getDirection().x, ray.getDirection().y, + ray.getDirection().z); + dir = dir.Normalized(); + + const JPH::RRayCast rayCast(origin, dir); + + JPH::PhysicsSystem *jphSys = m_physics->getPhysicsSystem(); + if (jphSys) { + JPH::RayCastResult hit; + if (jphSys->GetNarrowPhaseQuery().CastRay( + rayCast, hit, + JPH::BroadPhaseLayerFilter(), + JPH::ObjectLayerFilter(), + JPH::BodyFilter())) { + for (auto &pair : mColliders) { + if (pair.second.bodyId == + hit.mBodyID) { + outT = hit.mFraction; + outNormal = Ogre::Vector3::UNIT_Y; + return true; + } + } + } } } + + /* --- Fallback: Y=0 plane intersection for when Jolt + * colliders miss (camera far above/below or shallow + * angle). Not used in sculpt mode which has its own + * heightmap-based path above. --- */ + if (m_sculptPreviewsActive) + return false; + + { + const Ogre::Vector3 &origin = ray.getOrigin(); + const Ogre::Vector3 &dir = ray.getDirection(); + if (fabsf(dir.y) < 1e-6f) + return false; + + const float planeY = 0.0f; + float t = (planeY - origin.y) / dir.y; + if (t < 0.0f) + return false; + + outT = t; + outNormal = Ogre::Vector3::UNIT_Y; + return true; + } + return false; } @@ -911,6 +1050,14 @@ void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) if (!m_heightmapLoaded || !m_active || !mTerrainGroup) return; + Ogre::LogManager::getSingleton().logMessage( + "Terrain: applySculptBrush at (" + + Ogre::StringConverter::toString(worldPos.x) + ", " + + Ogre::StringConverter::toString(worldPos.y) + ", " + + Ogre::StringConverter::toString(worldPos.z) + + ") radius=" + + Ogre::StringConverter::toString(m_sculptRadius)); + std::lock_guard lock(m_heightmapMutex); float spacing = m_heightmapWorldSize / (float)(m_heightmapRes - 1); @@ -991,15 +1138,501 @@ void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) /* applySplatBrush */ /* ------------------------------------------------------------------ */ -void TerrainSystem::applySplatBrush(const Ogre::Vector3 & /*worldPos*/) +void TerrainSystem::applySplatBrush(const Ogre::Vector3 &worldPos) { - /* Not implemented yet. */ + if (!mTerrainGroup || !m_active) + return; + + /* Blend maps live on the terrain instance — must be loaded. + * In sculpt mode terrain is unloaded; skip. */ + if (m_sculptPreviewsActive) + return; + + int layerIdx = m_sculptLayerIndex; + if (layerIdx < 1) + return; /* layer 0 is base, no blend map */ + + /* Find the page at this world position. */ + long px, py; + mTerrainGroup->convertWorldPositionToTerrainSlot( + worldPos, &px, &py); + + Ogre::Terrain *terrain = mTerrainGroup->getTerrain(px, py); + if (!terrain || !terrain->isLoaded()) + return; + + if (layerIdx >= (int)terrain->getLayerCount()) + return; + + /* Blend map index is layerIdx-1 (layer 0 has no blend map). */ + Ogre::TerrainLayerBlendMap *blendMap = + terrain->getLayerBlendMap((Ogre::uint8)(layerIdx - 1)); + if (!blendMap) + return; + + int bmSize = (int)terrain->getLayerBlendMapSize(); + Ogre::Real ws = terrain->getWorldSize(); + Ogre::Real halfSize = ws * 0.5f; + + /* Page corner in world space. */ + Ogre::Vector3 pageCorner; + mTerrainGroup->convertTerrainSlotToWorldPosition(px, py, &pageCorner); + + /* World position → blend-map texel. + * For ALIGN_X_Z: blend (0,0) = world (corner.x - halfSize, corner.z + halfSize). */ + int cx = (int)(((worldPos.x - pageCorner.x + halfSize) / ws) * + (float)bmSize); + int cy = (int)(((pageCorner.z + halfSize - worldPos.z) / ws) * + (float)bmSize); + + float spacing = ws / (float)bmSize; + int rSamples = (int)(m_sculptRadius / spacing) + 1; + + int x0 = std::max(0, cx - rSamples); + int x1 = std::min(bmSize - 1, cx + rSamples); + int y0 = std::max(0, cy - rSamples); + int y1 = std::min(bmSize - 1, cy + rSamples); + + for (int y = y0; y <= y1; ++y) { + for (int x = x0; x <= x1; ++x) { + float dx = ((float)x - (float)cx) * spacing; + float dy = ((float)y - (float)cy) * spacing; + float d2 = dx * dx + dy * dy; + if (d2 <= m_sculptRadius * m_sculptRadius) { + float falloff = + 1.0f - sqrtf(d2) / m_sculptRadius; + float cur = blendMap->getBlendValue( + (Ogre::uint32)x, (Ogre::uint32)y); + float val = std::min( + 1.0f, cur + m_sculptStrength * falloff); + blendMap->setBlendValue((Ogre::uint32)x, (Ogre::uint32)y, + val); + } + } + } + + blendMap->dirtyRect(Ogre::Rect((long)x0, (long)y0, + (long)x1, (long)y1)); + blendMap->update(); +} + +/* ------------------------------------------------------------------ */ +/* Sculpt preview material (M3) */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::ensureSculptPreviewMaterial() +{ + if (m_sculptPreviewMaterial) + return; + + Ogre::MaterialManager &matMgr = Ogre::MaterialManager::getSingleton(); + + m_sculptPreviewMaterial = matMgr.create( + "TerrainSculptPreview", + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + + Ogre::Pass *pass = m_sculptPreviewMaterial->getTechnique(0)->createPass(); + pass->setDiffuse(0.55f, 0.50f, 0.40f, 1.0f); /* earthy brown */ + pass->setAmbient(0.35f, 0.30f, 0.25f); + pass->setSpecular(0.1f, 0.1f, 0.1f, 1.0f); + pass->setShininess(4.0f); + pass->setCullingMode(Ogre::CULL_CLOCKWISE); + + /* Let RTSS inject per-pixel lighting shaders. */ + Ogre::RTShader::ShaderGenerator *shaderGen = + Ogre::RTShader::ShaderGenerator::getSingletonPtr(); + if (shaderGen) { + shaderGen->createShaderBasedTechnique( + *m_sculptPreviewMaterial, + Ogre::MaterialManager::DEFAULT_SCHEME_NAME, + Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME); + shaderGen->validateMaterial( + Ogre::RTShader::ShaderGenerator::DEFAULT_SCHEME_NAME, + "TerrainSculptPreview"); + } + + Ogre::LogManager::getSingleton().logMessage( + "Terrain: created sculpt preview material"); +} + +/* ------------------------------------------------------------------ */ +/* beginSculptPreviews */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::beginSculptPreviews() +{ + if (!m_active || !mTerrainGroup || !m_sceneMgr) + return; + + if (m_sculptPreviewsActive) + endSculptPreviews(); + + ensureSculptPreviewMaterial(); + + /* 1. Disable paging so no new pages load/unload while sculpting. */ + if (mPageManager) + mPageManager->setPagingOperationsEnabled(false); + + /* 2. Remember all loaded pages and unload them. */ + std::vector> loadedPages; + for (const auto &kv : mTerrainGroup->getTerrainSlots()) { + Ogre::TerrainGroup::TerrainSlot *slot = kv.second; + if (slot && slot->instance && slot->instance->isLoaded()) + loadedPages.push_back({ slot->x, slot->y }); + } + + /* Unload terrain instances so they stop rendering. */ + for (auto [x, y] : loadedPages) + mTerrainGroup->removeTerrain(x, y); + + /* 3. Create ManualObject + SceneNode per page. */ + for (auto [x, y] : loadedPages) { + uint64_t key = mTerrainGroup->packIndex(x, y); + + /* SceneNode at page minimum corner (match terrain + * root node position). */ + Ogre::Vector3 worldPos; + mTerrainGroup->convertTerrainSlotToWorldPosition( + x, y, &worldPos); + + Ogre::SceneNode *node = m_sceneMgr->getRootSceneNode() + ->createChildSceneNode(worldPos); + + Ogre::ManualObject *mo = m_sceneMgr->createManualObject(); + mo->setDynamic(true); + node->attachObject(mo); + + m_sculptPreviews[key] = { mo, node, x, y }; + buildSculptPreview(x, y); + mo->setVisible(true); + } + + m_sculptPreviewsActive = true; + + Ogre::LogManager::getSingleton().logMessage( + "Terrain: sculpt previews active (" + + Ogre::StringConverter::toString((int)loadedPages.size()) + + " pages)"); +} + +/* ------------------------------------------------------------------ */ +/* buildSculptPreview */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::buildSculptPreview(long pageX, long pageY) +{ + uint64_t key = mTerrainGroup->packIndex(pageX, pageY); + auto it = m_sculptPreviews.find(key); + if (it == m_sculptPreviews.end()) + return; + + Ogre::ManualObject *mo = it->second.manualObj; + if (!mo) + return; + + Ogre::LogManager::getSingleton().logMessage( + "Terrain: building sculpt preview page (" + + Ogre::StringConverter::toString(pageX) + ", " + + Ogre::StringConverter::toString(pageY) + ")"); + + const Ogre::uint16 size = mTerrainGroup->getTerrainSize(); + const Ogre::Real worldSize = mTerrainGroup->getTerrainWorldSize(); + const Ogre::Real step = worldSize / (Ogre::Real)(size - 1); + + /* Sample heights. */ + std::vector heights(size * size); + { + Ogre::Vector3 worldPos; + mTerrainGroup->convertTerrainSlotToWorldPosition( + pageX, pageY, &worldPos); + for (int j = 0; j < size; ++j) { + for (int i = 0; i < size; ++i) { + long wx = (long)(worldPos.x + + (Ogre::Real)i * step); + long wz = (long)(worldPos.z + + (Ogre::Real)j * step); + heights[j * size + i] = + sampleHeightAt(wx, wz); + } + } + } + + /* Compute per-vertex normals via central differences. */ + std::vector normals(size * size); + for (int j = 0; j < size; ++j) { + for (int i = 0; i < size; ++i) { + float hl = (i > 0) ? + heights[j * size + (i - 1)] : + heights[j * size + i]; + float hr = (i < size - 1) ? + heights[j * size + (i + 1)] : + heights[j * size + i]; + float hu = (j > 0) ? + heights[(j - 1) * size + i] : + heights[j * size + i]; + float hd = (j < size - 1) ? + heights[(j + 1) * size + i] : + heights[j * size + i]; + Ogre::Vector3 n(-(hr - hl), 2.0f * step, -(hd - hu)); + n.normalise(); + normals[j * size + i] = n; + } + } + + /* Build ManualObject with zigzag triangulation matching Ogre + * terrain and Jolt collider layout. */ + mo->clear(); + mo->begin("TerrainSculptPreview", + Ogre::RenderOperation::OT_TRIANGLE_LIST); + + float halfSize = worldSize * 0.5f; + for (int j = 0; j < size; ++j) { + for (int i = 0; i < size; ++i) { + float vx = (float)i * step - halfSize; + float vz = halfSize - (float)j * step; + float vy = heights[j * size + i]; + mo->position(vx, vy, vz); + mo->normal(normals[j * size + i].x, + normals[j * size + i].y, + normals[j * size + i].z); + } + } + + for (int j = 0; j < size - 1; ++j) { + for (int i = 0; i < size - 1; ++i) { + int idx00 = j * size + i; + int idx10 = j * size + i + 1; + int idx01 = (j + 1) * size + i; + int idx11 = (j + 1) * size + i + 1; + if ((j & 1) == 0) { + mo->triangle(idx00, idx11, idx01); + mo->triangle(idx00, idx10, idx11); + } else { + mo->triangle(idx00, idx10, idx01); + mo->triangle(idx10, idx11, idx01); + } + } + } + + mo->end(); +} + +/* ------------------------------------------------------------------ */ +/* updateSculptDirtyPages */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::updateSculptDirtyPages() +{ + if (!m_sculptPreviewsActive || m_dirtyPages.empty()) + return; + + for (uint64_t key : m_dirtyPages) { + long x, y; + mTerrainGroup->unpackIndex(key, &x, &y); + buildSculptPreview(x, y); + } + m_dirtyPages.clear(); +} + +/* ------------------------------------------------------------------ */ +/* endSculptPreviews */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::endSculptPreviews() +{ + if (!m_sculptPreviewsActive || !mTerrainGroup) + return; + + hideBrushDecal(); + + /* 1. Destroy all ManualObjects and SceneNodes. */ + for (auto &kv : m_sculptPreviews) { + auto &sp = kv.second; + if (sp.manualObj) { + if (sp.sceneNode) + sp.sceneNode->detachObject(sp.manualObj); + m_sceneMgr->destroyManualObject(sp.manualObj); + } + if (sp.sceneNode) + m_sceneMgr->destroySceneNode(sp.sceneNode); + } + m_sculptPreviews.clear(); + + /* 2. Reload terrain pages with updated heightmap data. */ + const Ogre::uint16 terrainSize = mTerrainGroup->getTerrainSize(); + for (long py = m_pageMinY; py <= m_pageMaxY; ++py) { + for (long px = m_pageMinX; px <= m_pageMaxX; ++px) { + float *heightMap = OGRE_ALLOC_T( + float, terrainSize * terrainSize, + Ogre::MEMCATEGORY_GEOMETRY); + fillPageHeightData(mTerrainGroup, px, py, heightMap); + mTerrainGroup->defineTerrain(px, py, heightMap); + OGRE_FREE(heightMap, Ogre::MEMCATEGORY_GEOMETRY); + } + } + mTerrainGroup->loadAllTerrains(true); + + /* 3. Re-enable paging. */ + if (mPageManager) + mPageManager->setPagingOperationsEnabled(true); + + m_sculptPreviewsActive = false; + + Ogre::LogManager::getSingleton().logMessage( + "Terrain: sculpt previews destroyed, paging restored"); +} + +/* ------------------------------------------------------------------ */ +/* Brush decal (M3) */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::updateBrushDecal(const Ogre::Vector3 &worldPos, + const Ogre::Vector3 &normal, + float radius) +{ + if (!m_sceneMgr) + return; + + /* Lazy-create the decal. */ + if (!m_brushDecal) { + m_brushDecalNode = m_sceneMgr->getRootSceneNode() + ->createChildSceneNode(); + + m_brushDecal = m_sceneMgr->createManualObject(); + m_brushDecal->setDynamic(true); + m_brushDecalNode->attachObject(m_brushDecal); + + /* Material: unlit, alpha-blended, vertex-coloured. + * RTSS reads the pass settings (lighting off, + * scene_blend, TVC_DIFFUSE) and generates a shader + * that passes vertex colours straight through with + * proper alpha blending. */ + Ogre::MaterialManager &matMgr = + Ogre::MaterialManager::getSingleton(); + if (!matMgr.resourceExists("TerrainBrushDecal")) { + auto mat = matMgr.create( + "TerrainBrushDecal", + Ogre::ResourceGroupManager:: + DEFAULT_RESOURCE_GROUP_NAME); + Ogre::Pass *pass = + mat->getTechnique(0)->createPass(); + pass->setLightingEnabled(false); + pass->setSceneBlending( + Ogre::SBT_TRANSPARENT_ALPHA); + pass->setDepthWriteEnabled(false); + pass->setDepthCheckEnabled(true); + pass->setCullingMode(Ogre::CULL_NONE); + /* Vertex colours provide the actual colour. */ + pass->setVertexColourTracking( + Ogre::TVC_DIFFUSE | Ogre::TVC_AMBIENT); + pass->setDiffuse(1.0f, 1.0f, 1.0f, 1.0f); + pass->setAmbient(1.0f, 1.0f, 1.0f); + + Ogre::RTShader::ShaderGenerator *shaderGen = + Ogre::RTShader::ShaderGenerator:: + getSingletonPtr(); + if (shaderGen) { + shaderGen->createShaderBasedTechnique( + *mat, + Ogre::MaterialManager:: + DEFAULT_SCHEME_NAME, + Ogre::RTShader::ShaderGenerator:: + DEFAULT_SCHEME_NAME); + shaderGen->validateMaterial( + Ogre::RTShader::ShaderGenerator:: + DEFAULT_SCHEME_NAME, + "TerrainBrushDecal"); + } + } + + + /* Section 1: semi-transparent circle (triangle fan). */ + const int segments = 32; + m_brushDecal->begin("TerrainBrushDecal", + Ogre::RenderOperation::OT_TRIANGLE_FAN); + /* Center — low alpha, marks the exact brush origin. */ + m_brushDecal->position(0, 0, 0); + m_brushDecal->colour(0.0f, 0.8f, 0.0f, 0.10f); + for (int i = 0; i <= segments; ++i) { + float angle = 2.0f * M_PI * (float)i / + (float)segments; + m_brushDecal->position(cosf(angle), 0, + sinf(angle)); + /* Edge — higher alpha, shows brush boundary. */ + m_brushDecal->colour(0.0f, 0.8f, 0.0f, 0.28f); + } + m_brushDecal->end(); + + /* Section 2: centre cross (line list) so the + * operator can always see the exact hit point. */ + m_brushDecal->begin("TerrainBrushDecal", + Ogre::RenderOperation::OT_LINE_LIST); + float cs = 0.12f; /* cross arm half-length */ + m_brushDecal->position(-cs, 0, 0); + m_brushDecal->colour(1.0f, 1.0f, 1.0f, 0.85f); + m_brushDecal->position(cs, 0, 0); + m_brushDecal->colour(1.0f, 1.0f, 1.0f, 0.85f); + m_brushDecal->position(0, 0, -cs); + m_brushDecal->colour(1.0f, 1.0f, 1.0f, 0.85f); + m_brushDecal->position(0, 0, cs); + m_brushDecal->colour(1.0f, 1.0f, 1.0f, 0.85f); + m_brushDecal->end(); + } + + /* Position on terrain surface with slight Y offset to + * avoid z-fighting. */ + m_brushDecalNode->setVisible(true); + m_brushDecalNode->setPosition(worldPos.x, + worldPos.y + 0.5f, + worldPos.z); + + /* Orient the decal to face the terrain normal. */ + m_brushDecalNode->setOrientation( + Ogre::Vector3::UNIT_Y.getRotationTo(normal)); + + /* Scale to match brush radius (the template is unit radius). */ + m_brushDecalNode->setScale(radius, 1.0f, radius); +} + +void TerrainSystem::hideBrushDecal() +{ + if (m_brushDecalNode) + m_brushDecalNode->setVisible(false); } /* ------------------------------------------------------------------ */ /* destroyCollider */ /* ------------------------------------------------------------------ */ +/* ------------------------------------------------------------------ */ +/* physicalToVisualX */ +/* ------------------------------------------------------------------ */ + +float TerrainSystem::physicalToVisualX(float physicalX) const +{ + if (!mTerrainGroup) + return physicalX; + /* For ALIGN_X_Z: Ogre terrain local X = i*step - halfSize. + * The heightmap physical X = pageMinX + i*step. + * Visual world X = pageMinX + i*step - halfSize = physicalX - halfSize. + * Same shift for all pages. */ + return physicalX - mTerrainGroup->getTerrainWorldSize() * 0.5f; +} + +/* ------------------------------------------------------------------ */ +/* physicalToVisualZ */ +/* ------------------------------------------------------------------ */ + +float TerrainSystem::physicalToVisualZ(float physicalZ) const +{ + if (!mTerrainGroup) + return physicalZ; + Ogre::Real ws = mTerrainGroup->getTerrainWorldSize(); + float halfSize = ws * 0.5f; + float pageZ = floorf(physicalZ / ws) * ws; + return 2.0f * pageZ + halfSize - physicalZ; +} + void TerrainSystem::destroyCollider(uint64_t /*key*/, TerrainCollider & /*collider*/) { diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index d3b3dbd..18be184 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include #include #include @@ -67,7 +69,16 @@ public: /* --- Sculpting (M3) --- */ enum class SculptTool { Raise, Lower, Smooth, Flatten, SplatPaint }; bool getSculptMode() const { return m_sculptMode; } - void setSculptMode(bool v) { m_sculptMode = v; } + void setSculptMode(bool v) + { + if (v == m_sculptMode) + return; + m_sculptMode = v; + if (v) + beginSculptPreviews(); + else + endSculptPreviews(); + } bool isSculpting() const { return m_sculptMode; } SculptTool getSculptTool() const { return m_sculptTool; } void setSculptTool(SculptTool t) { m_sculptTool = t; } @@ -81,6 +92,22 @@ public: void applySculptBrush(const Ogre::Vector3 &worldPos); void applySplatBrush(const Ogre::Vector3 &worldPos); + /* --- Brush decal (M3) --- */ + void updateBrushDecal(const Ogre::Vector3 &worldPos, + const Ogre::Vector3 &normal, float radius); + void hideBrushDecal(); + + /* Convert physical heightmap X to visual display X. + * Terrain pages render column i at X = i*step - halfSize + * but the height data lives at X = pageMinX + i*step. + * Same for Z but Z-inverted (see physicalToVisualZ). */ + float physicalToVisualX(float physicalX) const; + + /* Convert physical heightmap Z to visual display Z. + * Terrain pages render row j at Z = pageMinZ + halfSize - j*step + * but the height data lives at Z = pageMinZ + j*step. */ + float physicalToVisualZ(float physicalZ) const; + private: void activate(struct TerrainComponent &tc, struct TransformComponent &xform); @@ -203,10 +230,30 @@ private: /* Sculpting state */ bool m_sculptMode = false; SculptTool m_sculptTool = SculptTool::Raise; - float m_sculptRadius = 5.0f; + float m_sculptRadius = 100.0f; float m_sculptStrength = 0.5f; int m_sculptLayerIndex = 0; + /* --- Sculpt preview (M3) --- */ + struct SculptPreview { + Ogre::ManualObject *manualObj = nullptr; + Ogre::SceneNode *sceneNode = nullptr; + long pageX, pageY; + }; + std::unordered_map m_sculptPreviews; + Ogre::MaterialPtr m_sculptPreviewMaterial; + bool m_sculptPreviewsActive = false; + + void beginSculptPreviews(); + void endSculptPreviews(); + void buildSculptPreview(long pageX, long pageY); + void updateSculptDirtyPages(); + void ensureSculptPreviewMaterial(); + + /* Brush decal (M3) */ + Ogre::ManualObject *m_brushDecal = nullptr; + Ogre::SceneNode *m_brushDecalNode = nullptr; + /* Entity tracking */ flecs::entity_t m_terrainEntityId = 0; diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 18daead..99b6a7a 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -59,7 +59,7 @@ public: ts->setSculptTool((TerrainSystem::SculptTool)tool); float radius = ts->getSculptRadius(); - if (ImGui::SliderFloat("Radius", &radius, 0.5f, 50.0f)) + if (ImGui::SliderFloat("Radius", &radius, 5.0f, 500.0f)) ts->setSculptRadius(radius); float strength = ts->getSculptStrength();