From 568b3bc9179553eb2bbb50589515c385e0a681c2 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Wed, 8 Jul 2026 23:27:15 +0300 Subject: [PATCH] Paint mode is functional --- src/features/editScene/EditorApp.cpp | 52 +- src/features/editScene/TerrainRequirements.md | 727 ++++++++++-------- src/features/editScene/components/Terrain.hpp | 1 + src/features/editScene/main.cpp | 48 ++ .../editScene/systems/EditorUISystem.cpp | 89 ++- .../editScene/systems/SceneSerializer.cpp | 2 + .../editScene/systems/TerrainSystem.cpp | 601 +++++++++------ .../editScene/systems/TerrainSystem.hpp | 2 + .../editScene/systems/TerrainTests.cpp | 380 ++++++--- src/features/editScene/ui/TerrainEditor.hpp | 422 +++++++++- 10 files changed, 1616 insertions(+), 708 deletions(-) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 0a64966..bdacb61 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -1793,6 +1793,16 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt) { m_currentModifiers = evt.keysym.mod; + /* Exit terrain sculpt/paint modes with ESC before other handlers + * consume the key. */ + if (m_terrainSystem && + (m_terrainSystem->isSculpting() || m_terrainSystem->isPainting()) && + evt.keysym.sym == OgreBites::SDLK_ESCAPE) { + m_terrainSystem->setSculptMode(false); + m_terrainSystem->setPaintMode(false); + return true; + } + if (m_gameMode == GameMode::Game) { if (evt.keysym.sym == OgreBites::SDLK_ESCAPE) { if (m_gamePlayState == GamePlayState::Playing) @@ -1997,8 +2007,46 @@ void EditorApp::locateResources() "Characters", true); Ogre::ResourceGroupManager::getSingleton().createResourceGroup( "LuaScripts", false); - Ogre::ResourceGroupManager::getSingleton().addResourceLocation( - "./lua-scripts.package", "Package", "LuaScripts", true, true); + + /* Try package and raw filesystem from several relative paths so the + * editor works when launched from the build root or from the binary + * subdirectory. */ + struct LuaPathEntry { + const char *packagePath; + const char *fsPath; + }; + LuaPathEntry luaPaths[] = { + { "./lua-scripts.package", "./lua-scripts" }, + { "./src/features/editScene/lua-scripts.package", + "./src/features/editScene/lua-scripts" }, + { "../../src/features/editScene/lua-scripts.package", + "../../src/features/editScene/lua-scripts" }, + { "../../../src/features/editScene/lua-scripts.package", + "../../../src/features/editScene/lua-scripts" }, + }; + + bool luaAdded = false; + for (const auto &entry : luaPaths) { + if (std::filesystem::exists(entry.packagePath)) { + Ogre::ResourceGroupManager::getSingleton() + .addResourceLocation(entry.packagePath, "Package", + "LuaScripts", true, true); + luaAdded = true; + break; + } + if (std::filesystem::exists(entry.fsPath)) { + Ogre::ResourceGroupManager::getSingleton() + .addResourceLocation(entry.fsPath, "FileSystem", + "LuaScripts", true, true); + luaAdded = true; + break; + } + } + if (!luaAdded) { + Ogre::LogManager::getSingleton().logMessage( + "WARNING: Could not find lua-scripts package or directory from " + + std::filesystem::current_path().string()); + } /* Try multiple relative paths for characters to handle different * working directories (build root vs binary subdirectory) */ diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 922906f..dc4b039 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -1,4 +1,4 @@ -# Terrain System Implementation Plan +#Terrain System Implementation Plan ## Goal Add a robust, editor-friendly `Ogre::Terrain`-based terrain feature to the @@ -74,73 +74,76 @@ parameters and a transient `dirty` flag. All runtime Ogre/Jolt objects live in ```cpp struct TerrainComponent { - bool enabled = true; + bool enabled = true; - // Page/tile vertex resolution. Must be 2^n+1 (e.g. 33, 65, 129, 257). - // Ogre supports up to 4097, but practical limits depend on GPU memory. - int terrainSize = 65; + // Page/tile vertex resolution. Must be 2^n+1 (e.g. 33, 65, 129, 257). + // Ogre supports up to 4097, but practical limits depend on GPU memory. + int terrainSize = 65; - // Stable unique ID for this terrain — generated once at component creation, - // serialized, and never changed. Used as the directory key for all binary - // terrain data (heightmap, fixups, aux maps). Default 0 means "not yet - // assigned" — the editor generates one on first save or creation. - uint64_t terrainId = 0; + // Stable unique ID for this terrain — generated once at component creation, + // serialized, and never changed. Used as the directory key for all binary + // terrain data (heightmap, fixups, aux maps). Default 0 means "not yet + // assigned" — the editor generates one on first save or creation. + uint64_t terrainId = 0; - // Base binary heightmap resolution. Default 256x256. - int heightmapSize = 256; + // Base binary heightmap resolution. Default 256x256. + int heightmapSize = 256; - // World-space size of one Ogre terrain page in units. - // For a single-page test scene, 2000-4000 is practical (terrainSize=65 - // gives ~31-62 units per vertex). The 40M legacy value assumes enormous - // world coordinates; scale to your scene's coordinate system. - float worldSize = 2000.0f; + // World-space size of one Ogre terrain page in units. + // For a single-page test scene, 2000-4000 is practical (terrainSize=65 + // gives ~31-62 units per vertex). The 40M legacy value assumes enormous + // world coordinates; scale to your scene's coordinate system. + float worldSize = 2000.0f; - // Maximum geometric error in pixels before a LOD tile splits. - float maxPixelError = 1.0f; + // Maximum geometric error in pixels before a LOD tile splits. + float maxPixelError = 1.0f; - // Distance at which the cheap composite map is used. - float compositeMapDistance = 300.0f; + // Distance at which the cheap composite map is used. + float compositeMapDistance = 300.0f; - // Minimum and maximum batch LOD sizes. Must be 2^n+1 and <= terrainSize. - int minBatchSize = 17; - int maxBatchSize = 65; + // Minimum and maximum batch LOD sizes. Must be 2^n+1 and <= terrainSize. + int minBatchSize = 17; + int maxBatchSize = 65; - // Base heightmap binary file path (project-relative or absolute). - std::string heightmapFile = "heightmap.bin"; + // Base heightmap binary file path (project-relative or absolute). + std::string heightmapFile = "heightmap.bin"; - // Layer texture settings (diffuse+normal pairs). - // These are loaded via Ogre resource groups and fed into - // TerrainMaterialGeneratorA (RTShader terrain materials). - struct Layer { - std::string diffuseTexture; - std::string normalTexture; - float worldSize = 100.0f; - }; - std::vector layers; + // Layer texture settings (diffuse+normal pairs). + // These are loaded via Ogre resource groups and fed into + // TerrainMaterialGeneratorA (RTShader terrain materials). + struct Layer { + std::string diffuseTexture; + std::string normalTexture; + float worldSize = 100.0f; + }; + std::vector layers; - // Road network data (node/edge graph). - struct RoadNode { - Ogre::Vector3 position; // default: terrain surface + offset - float verticalOffset = 0.0f; - int id = 0; - }; - struct RoadEdge { - int nodeA = -1; - int nodeB = -1; - float roadLevelA = 0.0f; - float roadLevelB = 0.0f; - }; - std::vector roadNodes; - std::vector roadEdges; + // Road network data (node/edge graph). + struct RoadNode { + Ogre::Vector3 position; // default: terrain surface + offset + float verticalOffset = 0.0f; + int id = 0; + }; + struct RoadEdge { + int nodeA = -1; + int nodeB = -1; + float roadLevelA = 0.0f; + float roadLevelB = 0.0f; + }; + std::vector roadNodes; + std::vector roadEdges; - // Prefab spawn points on the terrain managed by TerrainPrefabSpawnerComponent - // (not embedded here — see section 6). + // Prefab spawn points on the terrain managed by TerrainPrefabSpawnerComponent + // (not embedded here — see section 6). - // Runtime-only: managed by TerrainSystem. Not serialized. - bool dirty = true; - bool rebuildInProgress = false; + // Runtime-only: managed by TerrainSystem. Not serialized. + bool dirty = true; + bool rebuildInProgress = false; - void markDirty() { dirty = true; } + void markDirty() + { + dirty = true; + } }; ``` @@ -165,21 +168,21 @@ Owned objects: ```cpp Ogre::TerrainGlobalOptions* mTerrainGlobals = nullptr; -Ogre::TerrainGroup* mTerrainGroup = nullptr; -Ogre::PageManager* mPageManager = nullptr; -Ogre::TerrainPaging* mTerrainPaging = nullptr; -Ogre::PagedWorld* mPagedWorld = nullptr; -Ogre::TerrainPagedWorldSection* mTerrainPagedWorldSection = nullptr; +Ogre::TerrainGroup *mTerrainGroup = nullptr; +Ogre::PageManager *mPageManager = nullptr; +Ogre::TerrainPaging *mTerrainPaging = nullptr; +Ogre::PagedWorld *mPagedWorld = nullptr; +Ogre::TerrainPagedWorldSection *mTerrainPagedWorldSection = nullptr; std::unique_ptr mTerrainDefiner; ``` -Per-page physics state: + Per - + page physics state : -```cpp -struct TerrainCollider { - long x, y; - JPH::BodyID bodyId; - bool pendingRemove = false; +```cpp struct TerrainCollider { + long x, y; + JPH::BodyID bodyId; + bool pendingRemove = false; }; std::unordered_map mColliders; ``` @@ -222,8 +225,9 @@ Call `TerrainSystem::update(dt)` from `EditorApp::frameRenderingQueued()` **before** static-world generation: ```cpp -if (m_terrainSystem) { - m_terrainSystem->update(evt.timeSinceLastFrame); +if (m_terrainSystem) +{ + m_terrainSystem->update(evt.timeSinceLastFrame); } ``` @@ -244,33 +248,39 @@ Inside `update()`: Ogre's `WorkQueue`. The following rules prevent crashes, leaks, and use-after-free: - **Do not drive paging from a worker thread in the editor.** In editor mode - the camera is intentionally **not** added to `PageManager`; pages are loaded - synchronously through `TerrainGroup::loadAllTerrains(true)`. This prevents - `CustomTerrainDefiner::define()` from being called on a background thread and - touching GL/ECS state unsafely. -- **Never call `defineTerrain()` on a slot that already has a live instance from - a background thread.** If paging is active at runtime, only unload/reload - pages through the paging system so the instance is freed before `define()` is - called again. -- **Keep editing and paging reload separate.** Sculpting writes the heightmap - and marks pages dirty; `rebuildDirtyPages()` updates the live terrain geometry + the camera is intentionally **not** added to `PageManager`; +pages are loaded synchronously through `TerrainGroup::loadAllTerrains( + true)`.This prevents + `CustomTerrainDefiner::define()` from being called on a + background thread and touching GL + / ECS state unsafely.- + **Never call `defineTerrain()` on a slot that already has a live + instance from a background thread + .**If paging is active at runtime, + only unload / reload pages through the paging system so the instance is + freed before `define()` is called again.- + **Keep editing and paging reload separate.* + *Sculpting writes the heightmap and marks pages dirty; `rebuildDirtyPages()` updates the live terrain geometry directly. The paging system may call `CustomTerrainDefiner` later for a true reload, but the two paths do not interleave during a frame. - `DummyPageProvider::unloadProceduralPage` must only mark colliders for removal; - it must not synchronously destroy Jolt bodies or touch ECS state. -- Colliders can be built as soon as `terrain->isLoaded()` is true; the heightmap - data is stable even while derived normal/composite maps are still being - generated. -- The `TerrainGroup` destructor can hang or crash on some GL drivers, so the - active group is intentionally leaked at shutdown. `PageManager::~PageManager` - does not destroy worlds, which makes this leak safe. +it must not synchronously destroy Jolt bodies or + touch ECS state.- + Colliders can be built as soon as `terrain->isLoaded()` is true; +the heightmap data is stable even while derived normal / + composite maps are still being generated.- + The `TerrainGroup` destructor can hang or + crash on some GL drivers, + so the active group is intentionally leaked at + shutdown. `PageManager::~PageManager` does not destroy worlds, + which makes this leak safe. -### 2.4 Height function + ## #2.4 Height function -The height function is implemented inside `CustomTerrainDefiner::define()`: + The height function is implemented + inside `CustomTerrainDefiner::define()`: -```cpp -float getHeightAtWorld(int worldX, int worldZ); +```cpp float getHeightAtWorld(int worldX, int worldZ); ``` Input: @@ -300,48 +310,51 @@ Ogre::Real step = worldSize / (Ogre::Real)(terrainSize - 1); Ogre::Vector3 worldPos; terrainGroup->convertTerrainSlotToWorldPosition(x, y, &worldPos); for (int z = 0; z < terrainSize; ++z) { - for (int x = 0; x < terrainSize; ++x) { - long wx = (long)(worldPos.x + (Ogre::Real)x * step); - long wz = (long)(worldPos.z + (Ogre::Real)z * step); - heightMap[z * terrainSize + x] = getHeightAtWorld(wx, wz); - } + for (int x = 0; x < terrainSize; ++x) { + long wx = (long)(worldPos.x + (Ogre::Real)x * step); + long wz = (long)(worldPos.z + (Ogre::Real)z * step); + heightMap[z * terrainSize + x] = getHeightAtWorld(wx, wz); + } } terrainGroup->defineTerrain(x, y, heightMap); ``` -Note: Ogre's internal `getHeightData(x, y)` uses `index = y * size + x`, so row -index `z` in the buffer corresponds to world Z. -row index in the buffer corresponds to world Z. + Note + : Ogre's internal `getHeightData(x, y)` uses `index = y * size + x`, so row index `z` in + the buffer corresponds to world + Z.row index in the buffer corresponds to world Z. -### 2.5 RTShader and material configuration + ## #2.5 RTShader and material configuration -Before creating the `TerrainGroup`, configure the terrain material generator: + Before creating the `TerrainGroup`, + configure the terrain material generator : -```cpp -mTerrainGlobals->setDefaultMaterialGenerator( - Ogre::TerrainMaterialGeneratorPtr( - new Ogre::TerrainMaterialGeneratorA())); +```cpp mTerrainGlobals->setDefaultMaterialGenerator( + Ogre::TerrainMaterialGeneratorPtr( + new Ogre::TerrainMaterialGeneratorA())); ``` -This must happen while `Ogre::RTShader::ShaderGenerator` is active (it is, via -`EditorApp::setup()`). + This must happen while `Ogre::RTShader::ShaderGenerator` is + active(it is, via +`EditorApp::setup()`) + . -### 2.5a Layer declaration (texture sampler layout) + ## #2.5a Layer declaration(texture sampler layout) -`TerrainMaterialGeneratorA` defines a **layer declaration** — the set of texture -samplers each layer expects. The default `SM2Profile` declaration provides: +`TerrainMaterialGeneratorA` defines a **layer + declaration ** — the set of texture samplers each layer expects.The + default `SM2Profile` declaration provides : -| Sampler | Semantic | Purpose | -|---------|----------|---------| -| `diffusespecular` | diffuse+specular | Albedo (RGB) + specular (A) | -| `normalheight` | normal+parallax | Normal map (RGB) + displacement (A) | + | Sampler | Semantic | Purpose | | -- -- -- -- - | -- -- -- -- -- | + -- -- -- -- - | | `diffusespecular` | diffuse + specular | + Albedo(RGB) + specular(A) | | `normalheight` | normal + parallax + | Normal map(RGB) + displacement(A) | -Each `Layer` in `TerrainComponent::layers` produces an + Each `Layer` in `TerrainComponent::layers` produces an `Ogre::Terrain::LayerInstance` whose `textureNames` list must match the sampler -count and order of the declaration: + count and order of the declaration : -```cpp -Ogre::Terrain::LayerInstance layerInstance; +```cpp Ogre::Terrain::LayerInstance layerInstance; layerInstance.worldSize = componentLayer.worldSize; // Order must match the LayerDeclaration samplers: layerInstance.textureNames.push_back(componentLayer.diffuseTexture); @@ -349,32 +362,37 @@ layerInstance.textureNames.push_back(componentLayer.normalTexture); importData.layerList.push_back(layerInstance); ``` -The `ImportData::layerDeclaration` is obtained from the material generator -profile: + The `ImportData::layerDeclaration` is obtained from the material + generator profile : -```cpp -importData.layerDeclaration = generatorProfile->getLayerDeclaration(); +```cpp importData.layerDeclaration = generatorProfile->getLayerDeclaration(); ``` -### 2.5b Blend maps (splatting) + ## #2.5b Blend + maps(splatting) Ogre terrain **auto-creates** a packed blend map texture for all pages. How blend maps work: - **Layer 0** is the base layer — always 100% opaque everywhere. It needs no blend map. -- **Layers 1–4** each get one RGBA channel (R, G, B, A) in the packed blend - texture. Each texel is 0..255 (mapped to 0..1) controlling the visibility - of that layer blended on top of the layers below. -- With the default `SM2Profile`, **up to 5 layers** are supported per terrain - page (layer 0 + 4 blend channels). -- After `terrain->prepare(importData)`, blend maps exist but are initialized to - zero everywhere (only layer 0 visible). Use +- **Layers 1–4** each get one RGBA channel (R, G, B, A) +in the packed blend + texture.Each texel is 0..255(mapped to 0..1)controlling the + visibility of that layer blended on top of the layers below.- + With the default `SM2Profile`, + **up to 5 layers **are supported per terrain + page(layer 0 + 4 blend channels) + .- + After `terrain->prepare(importData)`, + blend maps exist but are initialized to zero + everywhere(only layer 0 visible) + .Use `TerrainLayerBlendMap::setBlendValue()` to paint splat weights. -```cpp -Ogre::TerrainLayerBlendMap *blendMap = terrain->getLayerBlendMap(layerIdx); -blendMap->setBlendValue(tx, ty, 1.0f); // full opacity for this layer here +```cpp Ogre::TerrainLayerBlendMap *blendMap = + terrain->getLayerBlendMap(layerIdx); +blendMap->setBlendValue(tx, ty, 1.0f); // full opacity for this layer here blendMap->dirty(); blendMap->update(); ``` @@ -385,15 +403,18 @@ maps for distant rendering (`compositeMapDistance` controls when it switches). ### 2.5c Default blend map initialization When a terrain page is first created, a sensible default paint is to show layer -0 everywhere. Later milestones (M3) add brush tools to paint splat weights in -the editor. For M1, just leave blend maps at their zero defaults or apply a -simple slope-based heuristic: +0 everywhere. Later milestones (M3) +add brush tools to paint splat weights in the editor.For M1, + just leave blend maps at their zero defaults or + apply a simple slope - based heuristic : ``` -// Pseudocode: paint layer 1 (grass) on flat areas, layer 2 (rock) on steep -float slope = computeSlope(x, z); -if (slope < 30°) blendMap1->setBlendValue(x, z, 1.0f); -else blendMap2->setBlendValue(x, z, 1.0f); + // Pseudocode: paint layer 1 (grass) on flat areas, layer 2 (rock) on steep + float slope = computeSlope(x, z); +if (slope < 30°) + blendMap1->setBlendValue(x, z, 1.0f); +else + blendMap2->setBlendValue(x, z, 1.0f); ``` ### 2.5d Editor splat paint tools (future milestone) @@ -442,23 +463,25 @@ class TerrainSystem; std::unique_ptr m_terrainSystem; ``` -**In `EditorApp::setupECS()`** — register the component: -```cpp -m_world.component(); + **In `EditorApp::setupECS()`** — register the component : +```cpp m_world.component(); ``` -**In `EditorApp::setup()`** — construct after physics but before water/sun/skybox -(terrain must exist before those systems try to interact with it): - Ogre::Camera *cam = m_camera ? m_camera->getCamera() : nullptr; - m_terrainSystem = std::make_unique( - m_world, m_sceneMgr, cam, m_physicsSystem->getPhysicsWrapper()); + **In `EditorApp::setup()`** — construct after physics but before + water + / sun / + skybox(terrain must exist before those systems try to interact with it) + : Ogre::Camera *cam = m_camera ? m_camera->getCamera() : nullptr; +m_terrainSystem = std::make_unique( + m_world, m_sceneMgr, cam, m_physicsSystem->getPhysicsWrapper()); -**In `EditorApp::frameRenderingQueued()`** — call update **before** -static-world generation and **after** visual mesh setup. Insert right after +**In `EditorApp::frameRenderingQueued()`** — call update **before **static - + world generation and**after **visual mesh setup + .Insert right after `m_proceduralMeshSystem->update()`: -```cpp -if (m_terrainSystem) { - m_terrainSystem->update(evt.timeSinceLastFrame); +```cpp if (m_terrainSystem) +{ + m_terrainSystem->update(evt.timeSinceLastFrame); } ``` @@ -476,37 +499,35 @@ m_buoyancySystem.reset(); m_physicsSystem.reset(); ``` - -### 2.9 EditorUISystem wiring + ## #2.9 EditorUISystem wiring `EditorUISystem::renderComponentList()` hardcodes each component type with -explicit `entity.has()` checks. Add the following after the + explicit `entity.has()` checks.Add the following after the -WaterPlane block in `src/features/editScene/systems/EditorUISystem.cpp`: + WaterPlane block in `src + / features / editScene / systems / + EditorUISystem.cpp`: ```cpp -#include "../components/Terrain.hpp" // at top with other includes +#include "../components/Terrain.hpp" // at top with other includes + // In renderComponentList(), after WaterPlane: + // Render Terrain if present -// In renderComponentList(), after WaterPlane: + if (entity.has()) +{ + auto &tc = entity.get_mut(); -// Render Terrain if present - -if (entity.has()) { - - auto &tc = entity.get_mut(); - - m_componentRegistry.render(entity, tc); - - componentCount++; + m_componentRegistry.render(entity, tc); + componentCount++; } ``` @@ -556,48 +577,50 @@ regular grid structure for O(log n) collision queries with minimal memory. For a terrain page of 65x65 vertices (8192 triangles), the `MeshShape` overhead is acceptable (a few KB extra memory, similar collision speed for typical contacts). For pages above 129x129, the custom heightfield shape should be -prioritized. **Mitigation**: Test with `terrainSize=65` first; profile with -Jolt's stats overlay before scaling up. +prioritized. **Mitigation**: Test with `terrainSize=65` first; +profile with Jolt's stats overlay before scaling up. -If runtime collision performance becomes a problem, a second milestone can -replace the `MeshShape` with a true custom `ZigzagHeightfieldShape` derived from -`JPH::Shape`, reusing Jolt's triangle-vs-convex helpers -(`CollideConvexVsTriangles`, `CastConvexVsTriangles`). + If runtime collision performance becomes a problem, + a second milestone can replace the `MeshShape` with + a true custom `ZigzagHeightfieldShape` derived from +`JPH::Shape`, + reusing Jolt's triangle-vs-convex helpers (`CollideConvexVsTriangles`, `CastConvexVsTriangles`) + . -### 3.4 Custom shape skeleton (future milestone) + ## #3.4 Custom shape skeleton(future milestone) -```cpp -class ZigzagHeightfieldShapeSettings : public JPH::ShapeSettings { +```cpp class ZigzagHeightfieldShapeSettings : public JPH::ShapeSettings { public: - uint32_t sampleCount = 0; - JPH::Vec3 offset = JPH::Vec3::sZero(); - JPH::Vec3 scale = JPH::Vec3::sReplicate(1.0f); - std::vector heightSamples; + uint32_t sampleCount = 0; + JPH::Vec3 offset = JPH::Vec3::sZero(); + JPH::Vec3 scale = JPH::Vec3::sReplicate(1.0f); + std::vector heightSamples; - virtual JPH::ShapeResult Create() const override; + virtual JPH::ShapeResult Create() const override; }; class ZigzagHeightfieldShape : public JPH::Shape { public: - ZigzagHeightfieldShape(const ZigzagHeightfieldShapeSettings &settings, - JPH::ShapeResult &outResult); + ZigzagHeightfieldShape(const ZigzagHeightfieldShapeSettings &settings, + JPH::ShapeResult &outResult); - // Required JPH::Shape overrides: - // GetLocalBounds, GetSubShapeIDBitsRecursive, GetInnerRadius, - // GetMassProperties, GetMaterial, GetSurfaceNormal, CastRay, - // CollidePoint, CollideSoftBodyVertices, GetTrianglesStart/Next, - // GetStats, GetVolume, GetSubmergedVolume. + // Required JPH::Shape overrides: + // GetLocalBounds, GetSubShapeIDBitsRecursive, GetInnerRadius, + // GetMassProperties, GetMaterial, GetSurfaceNormal, CastRay, + // CollidePoint, CollideSoftBodyVertices, GetTrianglesStart/Next, + // GetStats, GetVolume, GetSubmergedVolume. - // Zigzag-aware triangle iteration used by collide/cast functions. - void VisitTriangles(const JPH::AABB &localBox, - const std::function &fn); + // Zigzag-aware triangle iteration used by collide/cast functions. + void + VisitTriangles(const JPH::AABB &localBox, + const std::function &fn); }; ``` -Collision handlers are registered with: + Collision handlers are registered with : -```cpp -CollisionDispatch::sRegisterCollideShape(typeA, typeB, MyCollideFunction); +```cpp CollisionDispatch::sRegisterCollideShape(typeA, typeB, + MyCollideFunction); CollisionDispatch::sRegisterCastShape(typeA, typeB, MyCastFunction); ``` @@ -628,7 +651,7 @@ Use `EShapeSubType::User1` for the shape subtype. world coordinates: ``` int chunkX = floor(worldX / (worldSize / 256)); - int chunkZ = floor(worldZ / (worldSize / 256)); +int chunkZ = floor(worldZ / (worldSize / 256)); ``` So chunk `(0,0)` covers world `(0,0)` to `(worldSize/256, worldSize/256)` in X and Z. @@ -664,12 +687,12 @@ without fixup chunk support. ```cpp struct AuxMap { - std::string name; // e.g. "foliage_density", "rock_mask" - std::string fileName; // raw binary file, e.g. "foliage_density.bin" - int resolution = 256; // independent from heightmapSize - float defaultValue = 0.0f; // initial fill value for new maps -}; -std::vector auxMaps; + std::string name; // e.g. "foliage_density", "rock_mask" + std::string fileName; // raw binary file, e.g. "foliage_density.bin" + int resolution = 256; // independent from heightmapSize + float defaultValue = 0.0f; // initial fill value for new maps + }; + std::vector auxMaps; ``` **Storage**: `heightmaps//` — same directory as the @@ -724,42 +747,46 @@ local overrides for road configuration: ```cpp struct RoadEdge { - int nodeA = -1; - int nodeB = -1; - float roadLevelA = 0.0f; // road surface height at nodeA end - float roadLevelB = 0.0f; // road surface height at nodeB end + int nodeA = -1; + int nodeB = -1; + float roadLevelA = 0.0f; // road surface height at nodeA end + float roadLevelB = 0.0f; // road surface height at nodeB end - // Per-edge lane count override (0 = use global lanesPerDirection). - int lanesPerDirectionOverride = 0; + // Per-edge lane count override (0 = use global lanesPerDirection). + int lanesPerDirectionOverride = 0; - // Per-direction lane counts for asymmetric roads. - // Positive = A→B, negative = B→A. If 0, uses lanesPerDirectionOverride - // or global lanesPerDirection. - int lanesAtoB = 0; - int lanesBtoA = 0; + // Per-direction lane counts for asymmetric roads. + // Positive = A→B, negative = B→A. If 0, uses lanesPerDirectionOverride + // or global lanesPerDirection. + int lanesAtoB = 0; + int lanesBtoA = 0; - // Prefab spawn points along this edge. - struct RoadSidePrefab { - std::string prefabPath; - float edgeT = 0.5f; // 0..1 position along edge - float sideOffset = 5.0f; // lateral offset from road center - bool leftSide = true; // left vs right relative to A→B direction - }; - std::vector sidePrefabs; -}; + // Prefab spawn points along this edge. + struct RoadSidePrefab { + std::string prefabPath; + float edgeT = 0.5f; // 0..1 position along edge + float sideOffset = + 5.0f; // lateral offset from road center + bool leftSide = + true; // left vs right relative to A→B direction + }; + std::vector sidePrefabs; + }; ``` -This allows individual edges to be wider (more lanes) than the default and to -spawn lamp posts, guardrails, or other roadside objects at configurable offsets. + This allows individual edges to be + wider(more lanes) than the default and to spawn lamp posts, + guardrails, + or other roadside objects at configurable offsets. -### 5.2 Road configuration parameters + ## #5.2 Road configuration parameters -These live in `TerrainComponent` alongside the node graph: + These live in `TerrainComponent` alongside the node graph : ```cpp -// Road surface mesh template name (file in General resource group). -// Default: a unit-length flat plane mesh at Y=0, extending +X. -std::string roadMeshTemplate = "road_segment.mesh"; + // Road surface mesh template name (file in General resource group). + // Default: a unit-length flat plane mesh at Y=0, extending +X. + std::string roadMeshTemplate = "road_segment.mesh"; // Width of a single lane in world units. float laneWidth = 3.0f; @@ -929,19 +956,19 @@ single-responsibility): ```cpp struct TerrainPrefabSpawnerComponent { - // Prefab JSON file path (selectable from prefabs directory in UI). - std::string prefabPath; + // Prefab JSON file path (selectable from prefabs directory in UI). + std::string prefabPath; - // World position (snapped to terrain surface at placement time). - Ogre::Vector3 position; - Ogre::Quaternion rotation; + // World position (snapped to terrain surface at placement time). + Ogre::Vector3 position; + Ogre::Quaternion rotation; - // Distance-based spawn/despawn (squared for fast comparison). - float spawnDistanceSq = 100.0f * 100.0f; - float despawnDistanceSq = 200.0f * 200.0f; + // Distance-based spawn/despawn (squared for fast comparison). + float spawnDistanceSq = 100.0f * 100.0f; + float despawnDistanceSq = 200.0f * 200.0f; - // Runtime: the spawned instance entity (null when not spawned). - flecs::entity_t spawnedEntity = 0; + // Runtime: the spawned instance entity (null when not spawned). + flecs::entity_t spawnedEntity = 0; }; ``` @@ -990,19 +1017,18 @@ Create `src/features/editScene/components/TerrainModule.cpp`: REGISTER_COMPONENT_GROUP("Terrain", "Environment", TerrainComponent, TerrainEditor) { - registry.registerComponent( - "Terrain", "Environment", - std::make_unique(), - [](flecs::entity e) { - if (!e.has()) { - e.set({}); - } - }, - [](flecs::entity e) { - if (e.has()) { - e.remove(); - } - }); + registry.registerComponent( + "Terrain", "Environment", std::make_unique(), + [](flecs::entity e) { + if (!e.has()) { + e.set({}); + } + }, + [](flecs::entity e) { + if (e.has()) { + e.remove(); + } + }); } ``` @@ -1041,7 +1067,7 @@ Add to `SceneSerializer::serializeEntity()`: ```cpp if (entity.has()) { - json["terrain"] = serializeTerrain(entity); + json["terrain"] = serializeTerrain(entity); } ``` @@ -1049,7 +1075,7 @@ Add to both deserialize paths: ```cpp if (json.contains("terrain")) { - deserializeTerrain(entity, json["terrain"]); + deserializeTerrain(entity, json["terrain"]); } ``` @@ -1057,54 +1083,56 @@ JSON schema: ```json { - "terrain": { - "enabled": true, - "terrainId": 17345678901234567890, - "terrainSize": 65, - "heightmapSize": 256, - "worldSize": 2000.0, - "maxPixelError": 1.0, - "compositeMapDistance": 300.0, - "minBatchSize": 17, - "maxBatchSize": 65, - "heightmapFile": "heightmap.bin", - "layers": [ - { - "diffuseTexture": "Ground23_col.jpg", - "normalTexture": "Ground23_normheight.dds", - "worldSize": 100.0 - } - ], - "auxMaps": [ - { - "name": "foliage_density", - "fileName": "foliage_density.bin", - "resolution": 256, - "defaultValue": 0.0 - } - ], - "roadConfig": { - "roadMeshTemplate": "road_segment.mesh", - "laneWidth": 3.0, - "lanesPerDirection": 1, - "roadThickness": 0.3, - "roadLodDistance": 200.0, - "roadVisibilityDistance": 1000.0, - "roadMaterialName": "RoadMaterial" - }, - "roadNodes": [ - { "id": 0, "position": [0, 10, 0], "verticalOffset": 0.5 } - ], - "roadEdges": [ - { - "nodeA": 0, "nodeB": 1, - "roadLevelA": 0.0, "roadLevelB": 0.0, - "lanesPerDirectionOverride": 0, - "lanesAtoB": 0, "lanesBtoA": 0, - "sidePrefabs": [] - } - ] - } + "terrain": + { + "enabled" : true, + "terrainId" : 17345678901234567890, + "terrainSize" : 65, + "heightmapSize" : 256, + "worldSize" : 2000.0, + "maxPixelError" : 1.0, + "compositeMapDistance" : 300.0, + "minBatchSize" : 17, + "maxBatchSize" : 65, + "heightmapFile" : "heightmap.bin", + "layers" + : [{ + "diffuseTexture": "Ground23_col.jpg", + "normalTexture": "Ground23_normheight.dds", + "worldSize": 100.0 + }], + "auxMaps" : [{ + "name": "foliage_density", + "fileName": "foliage_density.bin", + "resolution": 256, + "defaultValue": 0.0 + }], + "roadConfig" + : { + "roadMeshTemplate": "road_segment.mesh", + "laneWidth": 3.0, + "lanesPerDirection": 1, + "roadThickness": 0.3, + "roadLodDistance": 200.0, + "roadVisibilityDistance": 1000.0, + "roadMaterialName": "RoadMaterial" + }, + "roadNodes" : [{ + "id": 0, + "position": [0, 10, 0], + "verticalOffset": 0.5 + }], + "roadEdges" : [{ + "nodeA": 0, + "nodeB": 1, + "roadLevelA": 0.0, + "roadLevelB": 0.0, + "lanesPerDirectionOverride": 0, + "lanesAtoB": 0, + "lanesBtoA": 0, + "sidePrefabs": [] + }] + } } ``` @@ -1182,8 +1210,8 @@ drains the queue in `update()` when the terrain instance is loaded. Each page gets a zigzag `JPH::MeshShape` as a static body in `Layers::NON_MOVING`. **mColliders map**: `std::unordered_map` keyed by -`packIndex(x, y)`. `TerrainCollider` holds `{pageX, pageY, JPH::BodyID, -pendingRemove}`. +`packIndex(x, y)`. `TerrainCollider` holds `{ + pageX, pageY, JPH::BodyID, pendingRemove}`. **Collider removal queue**: `DummyPageProvider::unloadProceduralPage` marks the collider `pendingRemove`. `processColliderRemoves()` destroys bodies in @@ -1245,7 +1273,7 @@ If `m_active` but no entity carries `TerrainComponent`, `deactivate()` is called. Without this, deleting the entity or removing the component leaves terrain and colliders orphaned. -### Milestone 3 — Serialization + heightmap editing + splat painting 🚧 (2026-07-04) +### Milestone 3 — Serialization + heightmap editing + splat painting ✅ (2026-07-05) **Status**: Heightmap sculpting and splat painting are two independent, mutually exclusive modes. Enabling either mode automatically finishes the other: @@ -1272,42 +1300,85 @@ persist via binary save/load (`getBlendPointer`). - [x] `snapCameraAboveTerrain()` samples 5 points for safety - [x] `TerrainCommandQueue` wired to `applySculptBrush`/`applySplatBrush` - [x] TerrainTests.cpp compiles and links -- [x] Splat paint: EditorUISystem dispatches to applySplatBrush when tool is SplatPaint -- [x] Splat paint: setSculptTool auto-exits preview mode when switching to SplatPaint, so terrain stays loaded -- [x] Splat paint: command queue uses visual world coords (fixed was-physical bug) -- [x] Splat paint: blend map save/load via HardwarePixelBuffer::lock +- [x] Sculpt Mode and Paint Mode as separate, mutually exclusive checkboxes +- [x] Paint mode: layer index, radius, strength controls in TerrainEditor +- [x] Paint mode: EditorUISystem dispatches `isPainting()` → `applySplatBrush` +- [x] Paint mode: `setPaintMode(true)` calls `setSculptMode(false)` → `endSculptPreviews()` (terrain reloaded) +- [x] Sculpt mode: `setSculptMode(true)` silences paint, calls `beginSculptPreviews()` +- [x] Splat paint: command queue uses visual world coords +- [x] Splat paint: blend map save/load via `getBlendPointer()` - [x] Splat paint: save/load round-trip verification in testSplatOperations - [x] Blend map save wired into SceneSerializer + load wired into activate() - [x] Save/Load Blend Maps buttons in TerrainEditor UI -- [x] Splat paint: blend map changes visible outside sculpt mode (terrain loaded) +- [x] **Multi-page splat painting**: brush radius that crosses page boundaries writes to every touched page +- [x] **Erase brush**: negative paint strength subtracts blend weight (verified in tests) +- [x] **Texture selection UI**: combo + browse modal for diffuse/normal textures on each layer +- [x] **Layer names**: `TerrainComponent::Layer.name` serialized and shown in paint-layer selector +- [x] **Texture thumbnails**: layer editor attempts to load the selected texture and display a small GPU thumbnail (OpenGL `GLID`); falls back to a coloured swatch +- [x] **ESC exits sculpt/paint mode** from `EditorApp::keyPressed` +- [x] **Robust resource paths**: LuaScripts package/filesystem fallbacks work from build root or binary subdirectory -**Z-mapping solution**: (see section above for full details) +**Z-mapping solution**: - Decal at raw physical hit (visible world position) -- Brush converts via `visualToPhysicalX(hit.x)` + `visualToPhysicalZ(hit.z)` +- Sculpt brush converts via `visualToPhysicalX(hit.x)` + `visualToPhysicalZ(hit.z)` +- Splat brush uses visual world coords directly; page selection uses + `TerrainGroup::convertWorldPositionToTerrainSlot` and texel mapping uses + `TerrainLayerBlendMap::convertWorldToUVSpace` / `convertUVToImageSpace` - `visualToPhysicalZ` uses visual page indexing (works for Z < 0) **Helpers added**: `visualToPhysicalX`, `visualToPhysicalZ`, `getTerrainWorldHalfSize`, `getTerrainGroup` -**Not yet verified (needs render-loop testing)**: -- [x] Splat paint: blend map changes detectable via API (tests verify; visual check needs runtime) -- [x] Splat paint: save, reload → blend map changes persist (round-trip test in testSplatOperations) -- [x] Sculpt: save heightmap, exit, reload → heightmap changes persist (SceneSerializer) -- [x] Sculpt data survives scene save/load round-trip -- [ ] TerrainTestRunner::run() passes (hangs outside render loop — needs frame-driven test harness) +**Test infrastructure**: -**Not implemented / planned for later**: -- [x] Splat paint in sculpt mode (resolved: splat uses loaded terrain, auto-switches out of preview mode) -- [ ] Headless test mode for TerrainTestRunner -- [ ] Blend map erase brush (weight=0 with large radius) +| Component | File | Purpose | +|-----------|------|---------| +| CLI flag | `main.cpp` | `--run-terrain-tests=N` | +| Frame listener | `main.cpp` | `TerrainTestFrameListener` - runs suite on first frame | +| Test runner | `systems/TerrainTests.cpp` | 5-step cycle: create - sculpt - splat - verify - delete | +| Frame pump | `systems/TerrainTests.cpp` | `pumpFrames()` drives `ts->update()` inside render loop | -**Files modified for M3 (2026-07-04)**: -- `systems/TerrainSystem.hpp` — blend map API, setSculptMode/Tool, visual/physical helpers -- `systems/TerrainSystem.cpp` — applySplatBrush, setSculptMode, setSculptTool, saveBlendMaps, loadBlendMaps -- `systems/EditorUISystem.cpp` — tool dispatch (SplatPaint → applySplatBrush) -- `systems/TerrainTests.cpp` — splat save/load round-trip verification -- `systems/TerrainCommands.hpp` — command queue definition -- `systems/SceneSerializer.cpp` — blend map save during serialization -- `ui/TerrainEditor.hpp` — Save/Load Blend Maps buttons +**Verification command**: + +```bash +cd build-vscode/src/features/editScene +./editSceneEditor --run-terrain-tests=1 +``` + +> Run from the binary directory so relative LuaScripts and resource paths resolve correctly. + +**Bugs found & fixed during M3**: +1. `getLayerBlendMap()` expects the **layer index** (1-based for blendable layers), not the internal array index. Passing `layerIdx - 1` caused `InvalidParametersException: Invalid layer index` as soon as painting started. +2. Blend-map coordinate conversion in `applySplatBrush` originally mixed page-centre and page-corner math; fixed to use the page centre returned by `convertTerrainSlotToWorldPosition` and `bmSize - 1` for texel mapping. Subsequent fix: page iteration used `floor((pos ± radius) / worldSize)`, which placed brush strokes on the wrong terrain slot for any hit point not near a page centre. Replaced with `TerrainGroup::convertWorldPositionToTerrainSlot` for the brush centre and a slot-radius loop, and replaced the manual UV math with `TerrainLayerBlendMap::convertWorldToUVSpace` / `convertUVToImageSpace`. +3. `saveBlendMaps`/`saveHeightmap` used single-level `mkdir`, failing when the parent `heightmaps/` directory had not been created yet. Replaced with `std::filesystem::create_directories`. +4. Erase via `TerrainCommandQueue` ignored `cmd.strength` and always used positive `cmd.splatWeight`. `SplatPaint` now uses `cmd.strength` as the brush delta (negative = erase). +5. LuaScripts were only resolved from `./lua-scripts.package` / `./lua-scripts`, which fails when the executable is launched from the build root. Added multiple relative-path fallbacks. +6. `renderTexturePreview()` passed arguments to `Ogre::TextureManager::resourceExists()` in the wrong order (`group, name` instead of `name, group`), causing an `ItemIdentityException` when a new layer defaulted to `Ground23_col.jpg`. +7. Brush decal stayed visible after paint/sculpt mode was turned off because `EditorUISystem::update()` only hid it while inside the mode. Added an `else` branch to hide the decal whenever neither mode is active, and `setPaintMode(false)` now also hides the decal. +8. Paint-layer selector clamped only the local combo index, not the actual `m_paintLayerIndex`. Selecting the base layer (index 0) left `m_paintLayerIndex = 0`, so `applySplatBrush` silently returned. The selector now writes the clamped value back to `TerrainSystem`. +9. New layers used the same diffuse/normal texture as the base layer, making splat painting effectively invisible. New layers now default to `Ground37_diffspec.dds` / `Ground37_normheight.dds` so the painted area is visually distinct. +10. Interactive paint near terrain page edges or with large brushes that cross page boundaries crashed with out-of-bounds image coordinates (`Ogre::Image::getData` assertion). `applySplatBrush` now derives the actual GPU blend-map dimensions, keeps unclamped float image coordinates for accurate cross-page distance falloff, clamps the affected pixel range to each page, and passes half-open bounds to `dirtyRect`. Redundant `terrain->update(true)` was removed; `blendMap->update()` + `terrain->updateCompositeMap()` remain for immediate GPU upload and distance rendering. +11. After adding a new terrain layer the old terrain pages were not removed before reactivation; the leaked pages stayed in the scene manager and rendered on top of the new terrain, hiding painted splat changes. `TerrainSystem::deactivate()` now calls `mTerrainGroup->removeAllTerrains()` to destroy the old page instances while still leaking the group object to avoid driver teardown crashes. +12. Clicking **Add Layer** on a terrain whose `layers` list was empty replaced the implicit default base with the new texture, so the ground changed instead of gaining a splat-mapped layer. The editor now inserts the default base layer (`Ground23_col.jpg` / `Ground23_normheight.dds`) first when the list is empty, so the new texture always becomes a blendable layer on top. +13. Saved splat-paint blend maps were not restored when a scene was loaded. `TerrainSystem::activate()` set `m_active = true` *after* calling `loadSceneBlendMaps()`, but `loadBlendMaps()` early-returns when `m_active` is false. Moved `m_active = true` before the blend-map restore so saved layers load correctly. + +**Not yet implemented / follow-up**: +- [ ] Headless test mode +- [ ] Procedural detail noise / aux-map editing +- [ ] Paint brush shape presets (currently hard-coded linear falloff) +- [ ] Batch composite-map updates instead of one `updateCompositeMap()` per stroke + +**Files modified for M3 (2026-07-05)**: +- `main.cpp` — `--run-terrain-tests=N` flag, `TerrainTestFrameListener` +- `systems/TerrainSystem.hpp` — paint mode state, `setSculptMode`/`setSculptTool` simplified, blend map API, multi-page brush helpers +- `systems/TerrainSystem.cpp` — `setSculptMode`, `setSculptTool`, `setPaintMode`, `applySplatBrush` (multi-page + correct texel mapping), `saveBlendMaps`, `loadBlendMaps`, `saveHeightmap` +- `systems/EditorUISystem.cpp` — gate on `isSculpting() || isPainting()`, dispatch `applySplatBrush`/`applySculptBrush` +- `systems/TerrainTests.cpp` — splat round-trip, frame-driven `pumpFrames`, try-catch steps, fixed layer index/coordinate checks; test now paints off-centre on page (1,0) to exercise the corrected world-to-slot mapping; added edge/cross-page stroke at the crash position and GPU blend-texture readback verification; save/load round-trip now uses a full deactivate/reactivate cycle to exercise the activation-time blend-map restore +- `systems/SceneSerializer.cpp` — serialize/deserialize `Layer.name`, blend map save during serialization +- `systems/TerrainCommands.hpp` — `SplatPaint` command uses paint-mode API +- `ui/TerrainEditor.hpp` — separate Sculpting / Splat Painting sections, Save/Load Blend Maps buttons, layer name editing, texture combo + browse modal + thumbnail preview, paint-layer selector with names; "Add Layer" now ensures the default base layer exists first +- `components/Terrain.hpp` — added `Layer.name` +- `EditorApp.cpp` — LuaScripts filesystem fallback, ESC exits sculpt/paint mode +- `lua-scripts/data2.lua` — minimal placeholder for app init (new file) ### Milestone 4 — Procedural roads - Node/edge editing UI (add, remove, split, join, select, move). diff --git a/src/features/editScene/components/Terrain.hpp b/src/features/editScene/components/Terrain.hpp index 3f716b0..ee98993 100644 --- a/src/features/editScene/components/Terrain.hpp +++ b/src/features/editScene/components/Terrain.hpp @@ -45,6 +45,7 @@ struct TerrainComponent { // Layer texture settings (diffuse+normal pairs). struct Layer { + std::string name; std::string diffuseTexture; std::string normalTexture; float worldSize = 100.0f; diff --git a/src/features/editScene/main.cpp b/src/features/editScene/main.cpp index 3f681c7..55c0687 100644 --- a/src/features/editScene/main.cpp +++ b/src/features/editScene/main.cpp @@ -1,6 +1,7 @@ #include #include "EditorApp.hpp" #include "systems/SceneSerializer.hpp" +#include "systems/TerrainTests.hpp" #include "OgreRoot.h" struct ExitAfterFirstFrameListener : public Ogre::FrameListener { @@ -17,6 +18,37 @@ struct ExitAfterFirstFrameListener : public Ogre::FrameListener { } }; +/** Frame-driven terrain test runner. + * Terrain init needs the render loop active (GPU context). + * Runs the suite on the first frame, then exits. */ +struct TerrainTestFrameListener : public Ogre::FrameListener { + Ogre::Root *root; + EditorApp &app; + int iterations; + bool triggered = false; + TerrainTestFrameListener(Ogre::Root *r, EditorApp &a, int n) + : root(r), app(a), iterations(n) {} + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + if (triggered) + return true; + triggered = true; + + std::cout << "Running terrain tests (" + << iterations + << " iterations)..." << std::endl; + int result = TerrainTestRunner::run(app, iterations); + if (result != 0) + std::cerr << "TERRAIN TESTS FAILED" << std::endl; + else + std::cout << "TERRAIN TESTS: ALL " + << iterations + << " ITERATIONS PASSED" << std::endl; + root->queueEndRendering(); + return true; + } +}; + int main(int argc, char *argv[]) { try { @@ -26,6 +58,7 @@ int main(int argc, char *argv[]) bool gameMode = false; bool debugBuoyancy = false; bool exitAfterFirstFrame = false; + int terrainTestIterations = 0; Ogre::String sceneFile; for (int i = 1; i < argc; i++) { Ogre::String arg = argv[i]; @@ -35,6 +68,12 @@ int main(int argc, char *argv[]) debugBuoyancy = true; } else if (arg == "--exit-after-first-frame") { exitAfterFirstFrame = true; + } else if (arg.find("--run-terrain-tests=") == 0) { + Ogre::String val = arg.substr(20); + terrainTestIterations = Ogre::StringConverter::parseInt( + val, 1); + if (terrainTestIterations < 1) + terrainTestIterations = 1; } else if (arg.length() > 0 && arg[0] != '-') { sceneFile = arg; } @@ -50,6 +89,15 @@ int main(int argc, char *argv[]) app.initApp(); + // Use frame-driven terrain test if requested. + // Terrain init requires an active render loop (GPU). + TerrainTestFrameListener terrainTestListener( + app.getRoot(), app, + terrainTestIterations); + if (terrainTestIterations > 0) + app.getRoot()->addFrameListener( + &terrainTestListener); + // Auto-load scene if provided as argument (editor mode only) if (!sceneFile.empty() && app.getGameMode() == EditorApp::GameMode::Editor) { diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 5ff74b1..aeb6812 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -327,53 +327,62 @@ void EditorUISystem::registerModularComponents() void EditorUISystem::update(float deltaTime) { - /* Sculpt mode: left mouse on terrain applies brush. + /* Sculpt/paint 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() || ts->isPainting())) { - ImGuiIO &io = ImGui::GetIO(); - 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); - /* Decal at hit (physical); - * brush converts to physical - * coords that visual-map - * back to hit location. */ - ts->updateBrushDecal(hit, normal, - ts->isPainting() ? - ts->getPaintRadius() : - ts->getSculptRadius()); - if (ImGui::IsMouseDown( - ImGuiMouseButton_Left)) { - if (ts->isPainting()) { - ts->applySplatBrush( - hit); - } else { - Ogre::Vector3 brushPos = - hit; - brushPos.x = - ts->visualToPhysicalX( - hit.x); - brushPos.z = - ts->visualToPhysicalZ( - hit.z); - ts->applySculptBrush( - brushPos); + if (ts) { + if (ts->isSculpting() || ts->isPainting()) { + ImGuiIO &io = ImGui::GetIO(); + 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); + /* Decal at hit (physical); + * brush converts to physical + * coords that visual-map + * back to hit location. */ + ts->updateBrushDecal(hit, normal, + ts->isPainting() ? + ts->getPaintRadius() : + ts->getSculptRadius()); + if (ImGui::IsMouseDown( + ImGuiMouseButton_Left)) { + if (ts->isPainting()) { + Ogre::LogManager::getSingleton().logMessage( + "EditorUISystem: dispatching splat brush at " + + Ogre::StringConverter::toString(hit) + + " layer=" + + Ogre::StringConverter::toString(ts->getPaintLayerIndex())); + ts->applySplatBrush( + hit); + } else { + Ogre::Vector3 brushPos = + hit; + brushPos.x = + ts->visualToPhysicalX( + hit.x); + brushPos.z = + ts->visualToPhysicalZ( + hit.z); + ts->applySculptBrush( + brushPos); + } } + } else { + ts->hideBrushDecal(); } - } else { - ts->hideBrushDecal(); } + } else { + ts->hideBrushDecal(); } } } diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index da352fa..b3f2e59 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -4161,6 +4161,7 @@ nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) nlohmann::json layersJson = nlohmann::json::array(); for (auto &l : tc.layers) { nlohmann::json lj; + lj["name"] = l.name; lj["diffuseTexture"] = l.diffuseTexture; lj["normalTexture"] = l.normalTexture; lj["worldSize"] = l.worldSize; @@ -4254,6 +4255,7 @@ void SceneSerializer::deserializeTerrain(flecs::entity entity, if (json.contains("layers") && json["layers"].is_array()) { for (auto &lj : json["layers"]) { TerrainComponent::Layer layer; + layer.name = lj.value("name", ""); layer.diffuseTexture = lj.value("diffuseTexture", ""); layer.normalTexture = lj.value("normalTexture", ""); layer.worldSize = lj.value("worldSize", 100.0f); diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index 2892fe1..f1fdc18 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -27,6 +28,7 @@ #include #include +#include #include TerrainSystem *TerrainSystem::s_instance = nullptr; @@ -59,8 +61,7 @@ bool TerrainSystem::TerrainBodyDrawFilter::ShouldDraw( const JPH::Body &inBody) const { if (!showTerrain) - return m_terrainIds.find(inBody.GetID()) == - m_terrainIds.end(); + return m_terrainIds.find(inBody.GetID()) == m_terrainIds.end(); return true; } @@ -110,16 +111,15 @@ void TerrainSystem::CustomTerrainDefiner::define(Ogre::TerrainGroup *group, long x, long y) { Ogre::uint16 terrainSize = group->getTerrainSize(); - float *heightMap = OGRE_ALLOC_T(float, terrainSize * terrainSize, + float *heightMap = OGRE_ALLOC_T(float, terrainSize *terrainSize, Ogre::MEMCATEGORY_GEOMETRY); m_sys->fillPageHeightData(group, x, y, heightMap); group->defineTerrain(x, y, heightMap); Ogre::LogManager::getSingleton().logMessage( - "Terrain: defined page (" + - Ogre::StringConverter::toString(x) + ", " + - Ogre::StringConverter::toString(y) + ")"); + "Terrain: defined page (" + Ogre::StringConverter::toString(x) + + ", " + Ogre::StringConverter::toString(y) + ")"); OGRE_FREE(heightMap, Ogre::MEMCATEGORY_GEOMETRY); } @@ -142,10 +142,8 @@ JPH::ShapeRefC TerrainSystem::buildPageCollider(Ogre::Terrain *terrain) for (int x = 0; x < size - 1; ++x) { float vx0 = (float)x * scale - worldSize * 0.5f; float vz0 = worldSize * 0.5f - (float)z * scale; - float vx1 = (float)(x + 1) * scale - - worldSize * 0.5f; - float vz1 = worldSize * 0.5f - - (float)(z + 1) * scale; + float vx1 = (float)(x + 1) * scale - worldSize * 0.5f; + float vz1 = worldSize * 0.5f - (float)(z + 1) * scale; float h00 = heightData[z * size + x]; float h10 = heightData[z * size + x + 1]; @@ -217,8 +215,7 @@ void TerrainSystem::processColliderCreates() std::lock_guard lock(m_colliderQueueMutex); const size_t initial = mColliderCreateQueue.size(); - for (size_t i = 0; i < initial && !mColliderCreateQueue.empty(); - ++i) { + for (size_t i = 0; i < initial && !mColliderCreateQueue.empty(); ++i) { auto [x, y] = mColliderCreateQueue.front(); mColliderCreateQueue.pop_front(); @@ -244,14 +241,12 @@ void TerrainSystem::processColliderCreates() } Ogre::Vector3 pagePos = - terrain->_getRootSceneNode() - ->_getDerivedPosition(); + terrain->_getRootSceneNode()->_getDerivedPosition(); JPH::BodyCreationSettings bodySettings( shape.GetPtr(), JPH::RVec3(pagePos.x, pagePos.y, pagePos.z), - JPH::Quat::sIdentity(), - JPH::EMotionType::Static, + JPH::Quat::sIdentity(), JPH::EMotionType::Static, Layers::NON_MOVING); JPH::BodyID bodyId = m_physics->createBody(bodySettings); if (bodyId.IsInvalid()) { @@ -262,8 +257,7 @@ void TerrainSystem::processColliderCreates() continue; } - m_physics->addBody(bodyId, - JPH::EActivation::DontActivate); + m_physics->addBody(bodyId, JPH::EActivation::DontActivate); mColliders[key] = { x, y, bodyId, false }; mPendingColliderPages.erase(key); @@ -395,17 +389,13 @@ bool TerrainSystem::saveHeightmap(const std::string &path) if (!m_heightmapLoaded) return false; - size_t lastSlash = path.rfind('/'); - if (lastSlash != std::string::npos) { - std::string dir = path.substr(0, lastSlash); - mkdir(dir.c_str(), 0755); - } + std::filesystem::path p(path); + std::filesystem::create_directories(p.parent_path()); std::ofstream file(path, std::ios::binary); if (!file) { Ogre::LogManager::getSingleton().logMessage( - "Terrain: failed to create heightmap file: " + - path); + "Terrain: failed to create heightmap file: " + path); return false; } @@ -439,7 +429,7 @@ bool TerrainSystem::saveBlendMaps(const std::string &dirPath) const if (!mTerrainGroup || !m_active) return false; - mkdir(dirPath.c_str(), 0755); + std::filesystem::create_directories(dirPath); auto &slots = mTerrainGroup->getTerrainSlots(); int savedPages = 0; @@ -459,7 +449,7 @@ bool TerrainSystem::saveBlendMaps(const std::string &dirPath) const /* Each blendable layer (1..numLayers-1) gets its own file. */ for (int l = 1; l < numLayers; ++l) { Ogre::TerrainLayerBlendMap *bm = - t->getLayerBlendMap((Ogre::uint8)(l - 1)); + t->getLayerBlendMap((Ogre::uint8)l); if (!bm) continue; @@ -467,12 +457,11 @@ bool TerrainSystem::saveBlendMaps(const std::string &dirPath) const if (!data) continue; - std::string fname = dirPath + "/page_" + - Ogre::StringConverter::toString(slot->x) + - "_" + + std::string fname = + dirPath + "/page_" + + Ogre::StringConverter::toString(slot->x) + "_" + Ogre::StringConverter::toString(slot->y) + - "_l" + - Ogre::StringConverter::toString(l) + + "_l" + Ogre::StringConverter::toString(l) + ".bin"; std::ofstream file(fname, std::ios::binary); @@ -521,12 +510,11 @@ bool TerrainSystem::loadBlendMaps(const std::string &dirPath) int bmSize = (int)t->getLayerBlendMapSize(); for (int l = 1; l < numLayers; ++l) { - std::string fname = dirPath + "/page_" + - Ogre::StringConverter::toString(slot->x) + - "_" + + std::string fname = + dirPath + "/page_" + + Ogre::StringConverter::toString(slot->x) + "_" + Ogre::StringConverter::toString(slot->y) + - "_l" + - Ogre::StringConverter::toString(l) + + "_l" + Ogre::StringConverter::toString(l) + ".bin"; std::ifstream file(fname, std::ios::binary); @@ -556,7 +544,7 @@ bool TerrainSystem::loadBlendMaps(const std::string &dirPath) } Ogre::TerrainLayerBlendMap *bm = - t->getLayerBlendMap((Ogre::uint8)(l - 1)); + t->getLayerBlendMap((Ogre::uint8)l); if (!bm) continue; @@ -629,12 +617,11 @@ void TerrainSystem::fillPageHeightData(Ogre::TerrainGroup *group, long x, for (int j = 0; j < terrainSize; ++j) { for (int i = 0; i < terrainSize; ++i) { - const long wx = (long)(worldPos.x + - (Ogre::Real)i * step); - const long wz = (long)(worldPos.z + - (Ogre::Real)j * step); - heightMap[j * terrainSize + i] = - sampleHeightAt(wx, wz); + const long wx = + (long)(worldPos.x + (Ogre::Real)i * step); + const long wz = + (long)(worldPos.z + (Ogre::Real)j * step); + heightMap[j * terrainSize + i] = sampleHeightAt(wx, wz); } } } @@ -650,10 +637,10 @@ float TerrainSystem::sampleHeightAtLocked(long worldX, long worldZ) const if (!m_heightmapLoaded || m_heightData.empty()) return proceduralHeight(worldX, worldZ); - float fx = ((float)worldX - m_heightmapWorldMinX) / m_heightmapWorldSize * - (float)m_heightmapRes; - float fz = ((float)worldZ - m_heightmapWorldMinZ) / m_heightmapWorldSize * - (float)m_heightmapRes; + float fx = ((float)worldX - m_heightmapWorldMinX) / + m_heightmapWorldSize * (float)m_heightmapRes; + float fz = ((float)worldZ - m_heightmapWorldMinZ) / + m_heightmapWorldSize * (float)m_heightmapRes; int x0 = (int)floorf(fx); int z0 = (int)floorf(fz); @@ -674,10 +661,8 @@ 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; + return (1.0f - tx) * (1.0f - tz) * h00 + tx * (1.0f - tz) * h10 + + (1.0f - tx) * tz * h01 + tx * tz * h11; } void TerrainSystem::setHeightAt(long worldX, long worldZ, float value) @@ -686,10 +671,10 @@ void TerrainSystem::setHeightAt(long worldX, long worldZ, float value) if (!m_heightmapLoaded) return; - int x = (int)(((float)worldX - m_heightmapWorldMinX) / m_heightmapWorldSize * - (float)m_heightmapRes); - int z = (int)(((float)worldZ - m_heightmapWorldMinZ) / m_heightmapWorldSize * - (float)m_heightmapRes); + int x = (int)(((float)worldX - m_heightmapWorldMinX) / + m_heightmapWorldSize * (float)m_heightmapRes); + int z = (int)(((float)worldZ - m_heightmapWorldMinZ) / + m_heightmapWorldSize * (float)m_heightmapRes); if (x < 0 || x >= m_heightmapRes || z < 0 || z >= m_heightmapRes) return; m_heightData[z * m_heightmapRes + x] = value; @@ -716,8 +701,8 @@ void TerrainSystem::updatePageGeometry(long pageX, long pageY) return; uint16_t size = terrain->getSize(); - float *temp = OGRE_ALLOC_T(float, size * size, - Ogre::MEMCATEGORY_GEOMETRY); + float *temp = + OGRE_ALLOC_T(float, size *size, Ogre::MEMCATEGORY_GEOMETRY); fillPageHeightData(mTerrainGroup, pageX, pageY, temp); float *dest = terrain->getHeightData(); @@ -803,12 +788,18 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform) m_pageMaxY = 1; m_heightmapWorldMinX = (float)m_pageMinX * tc.worldSize; m_heightmapWorldMinZ = (float)m_pageMinY * tc.worldSize; - m_heightmapWorldSize = (float)(m_pageMaxX - m_pageMinX + 1) * - tc.worldSize; + m_heightmapWorldSize = + (float)(m_pageMaxX - m_pageMinX + 1) * tc.worldSize; ensureHeightmapLoaded(tc); - mTerrainGlobals = OGRE_NEW Ogre::TerrainGlobalOptions(); + /* Reuse TerrainGlobalOptions if it survived a previous + * deactivate/reactivate cycle. Old terrain instances + * (intentionally leaked) still reference the singleton + * during rendering, so we must not delete it. */ + if (!mTerrainGlobals) { + mTerrainGlobals = OGRE_NEW Ogre::TerrainGlobalOptions(); + } mTerrainGlobals->setMaxPixelError(tc.maxPixelError); mTerrainGlobals->setCompositeMapDistance(tc.compositeMapDistance); mTerrainGlobals->setCompositeMapAmbient(m_sceneMgr->getAmbientLight()); @@ -817,9 +808,10 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform) new Ogre::TerrainMaterialGeneratorA()); mTerrainGlobals->setDefaultMaterialGenerator(matGen); - mTerrainGroup = OGRE_NEW Ogre::TerrainGroup( - m_sceneMgr, Ogre::Terrain::ALIGN_X_Z, - (uint16_t)tc.terrainSize, tc.worldSize); + mTerrainGroup = OGRE_NEW Ogre::TerrainGroup(m_sceneMgr, + Ogre::Terrain::ALIGN_X_Z, + (uint16_t)tc.terrainSize, + tc.worldSize); mTerrainGroup->setOrigin(xform.position); Ogre::Terrain::ImportData &defaultImp = @@ -860,8 +852,8 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform) mPagedWorld = mPageManager->createWorld(); mTerrainPagedWorldSection = mTerrainPaging->createWorldSection( - mPagedWorld, mTerrainGroup, 300.0f, 500.0f, - m_pageMinX, m_pageMinY, m_pageMaxX, m_pageMaxY); + mPagedWorld, mTerrainGroup, 300.0f, 500.0f, m_pageMinX, + m_pageMinY, m_pageMaxX, m_pageMaxY); mTerrainDefiner = std::make_unique(this); mTerrainPagedWorldSection->setDefiner(mTerrainDefiner.get()); @@ -875,10 +867,11 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform) * camera and use page unload hooks (DummyPageProvider) safely. */ for (long py = m_pageMinY; py <= m_pageMaxY; ++py) { for (long px = m_pageMinX; px <= m_pageMaxX; ++px) { - Ogre::uint16 terrainSize = mTerrainGroup->getTerrainSize(); - float *heightMap = OGRE_ALLOC_T( - float, terrainSize * terrainSize, - Ogre::MEMCATEGORY_GEOMETRY); + Ogre::uint16 terrainSize = + mTerrainGroup->getTerrainSize(); + 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); @@ -886,13 +879,15 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform) } mTerrainGroup->loadAllTerrains(true); - /* Restore saved blend maps if they exist. */ + m_active = true; + + /* Restore saved blend maps if they exist. This must happen after + * m_active is set because loadBlendMaps early-outs when inactive. */ loadSceneBlendMaps(tc); if (m_physics) m_physics->setBodyDrawFilter(&mBodyDrawFilter); - m_active = true; Ogre::LogManager::getSingleton().logMessage( "TerrainSystem: activation complete"); } @@ -914,7 +909,7 @@ void TerrainSystem::deactivate() m_dirtyPages.clear(); m_reloadQueue.clear(); m_sculptMode = false; - m_paintMode = false; + m_paintMode = false; /* Clean up sculpt previews without trying to reload terrain * pages (we're shutting down the whole terrain). */ @@ -953,6 +948,13 @@ void TerrainSystem::deactivate() if (mPageManager) mPageManager->setPagingOperationsEnabled(false); + /* Remove the actual terrain page instances from the scene manager before + * we drop the group. The group object itself is intentionally leaked (see + * below), but the terrain pages must be destroyed or they stay rendered + * and obscure the new terrain after reactivation. */ + if (mTerrainGroup) + mTerrainGroup->removeAllTerrains(); + /* Release RTShader SubRenderState objects held by the terrain material * generator before the RTSS is torn down. The persistent instances live * in the generator's main RenderState; remove them explicitly so the @@ -961,15 +963,17 @@ void TerrainSystem::deactivate() Ogre::TerrainMaterialGeneratorPtr gen = mTerrainGlobals->getDefaultMaterialGenerator(); if (gen) { - auto *genA = dynamic_cast( - gen.get()); + auto *genA = + dynamic_cast( + gen.get()); if (genA) { Ogre::RTShader::RenderState *rs = genA->getMainRenderState(); if (rs) { Ogre::RTShader::SubRenderStateList srs = rs->getSubRenderStates(); - for (Ogre::RTShader::SubRenderState *sub : srs) + for (Ogre::RTShader::SubRenderState + *sub : srs) rs->removeSubRenderState(sub); } } @@ -997,8 +1001,6 @@ void TerrainSystem::deactivate() Ogre::TerrainMaterialGeneratorPtr()); } - mTerrainGlobals = nullptr; - Ogre::LogManager::getSingleton().logMessage( "TerrainSystem: deactivated"); } @@ -1051,9 +1053,8 @@ float TerrainSystem::getHeightAt(const Ogre::Vector3 &worldPos) const * sample directly from the in-memory heightmap. Convert * visual world position to physical heightmap coords. */ if (m_sculptPreviewsActive && m_heightmapLoaded) - return sampleHeightAt( - (long)visualToPhysicalX(worldPos.x), - (long)visualToPhysicalZ(worldPos.z)); + return sampleHeightAt((long)visualToPhysicalX(worldPos.x), + (long)visualToPhysicalZ(worldPos.z)); if (mTerrainGroup) return mTerrainGroup->getHeightAtWorldPosition(worldPos); @@ -1090,8 +1091,7 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, /* 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 h = sampleHeightAt((long)pt.x, (long)pt.z); float tNew = (h - origin.y) / dir.y; if (tNew < 0.0f || tNew > maxDist) return false; @@ -1103,17 +1103,14 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, outT = tNew; /* Normal from heightmap gradient. */ float eps = 1.0f; - float dx = sampleHeightAt( - (long)(ptNew.x + eps), - (long)ptNew.z) - + 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); + float dz = + sampleHeightAt((long)ptNew.x, + (long)(ptNew.z + eps)) - + h; + outNormal = Ogre::Vector3(-dx, eps * 2.0f, -dz); outNormal.normalise(); return true; } @@ -1140,15 +1137,14 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, if (jphSys) { JPH::RayCastResult hit; if (jphSys->GetNarrowPhaseQuery().CastRay( - rayCast, hit, - JPH::BroadPhaseLayerFilter(), + rayCast, hit, JPH::BroadPhaseLayerFilter(), JPH::ObjectLayerFilter(), JPH::BodyFilter())) { for (auto &pair : mColliders) { - if (pair.second.bodyId == - hit.mBodyID) { + if (pair.second.bodyId == hit.mBodyID) { outT = hit.mFraction; - outNormal = Ogre::Vector3::UNIT_Y; + outNormal = + Ogre::Vector3::UNIT_Y; return true; } } @@ -1199,8 +1195,7 @@ void TerrainSystem::snapCameraAboveTerrain() Ogre::SceneNode *targetNode = m_sceneMgr->getSceneNode("EditorCameraTarget"); if (!targetNode) - targetNode = m_sceneMgr->getSceneNode( - "EditorCameraTargetNode"); + targetNode = m_sceneMgr->getSceneNode("EditorCameraTargetNode"); if (!targetNode) return; @@ -1209,14 +1204,10 @@ void TerrainSystem::snapCameraAboveTerrain() * clipping through terrain on steep slopes. */ float r = 3.0f; float h = getHeightAt(pos); - h = std::max(h, getHeightAt( - Ogre::Vector3(pos.x - r, pos.y, pos.z))); - h = std::max(h, getHeightAt( - Ogre::Vector3(pos.x + r, pos.y, pos.z))); - h = std::max(h, getHeightAt( - Ogre::Vector3(pos.x, pos.y, pos.z - r))); - h = std::max(h, getHeightAt( - Ogre::Vector3(pos.x, pos.y, pos.z + r))); + h = std::max(h, getHeightAt(Ogre::Vector3(pos.x - r, pos.y, pos.z))); + h = std::max(h, getHeightAt(Ogre::Vector3(pos.x + r, pos.y, pos.z))); + h = std::max(h, getHeightAt(Ogre::Vector3(pos.x, pos.y, pos.z - r))); + h = std::max(h, getHeightAt(Ogre::Vector3(pos.x, pos.y, pos.z + r))); if (pos.y < h + 5.0f) targetNode->setPosition(pos.x, h + 5.0f, pos.z); } @@ -1256,8 +1247,7 @@ void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) Ogre::StringConverter::toString(worldPos.x) + ", " + Ogre::StringConverter::toString(worldPos.y) + ", " + Ogre::StringConverter::toString(worldPos.z) + - ") radius=" + - Ogre::StringConverter::toString(m_sculptRadius)); + ") radius=" + Ogre::StringConverter::toString(m_sculptRadius)); std::lock_guard lock(m_heightmapMutex); @@ -1273,7 +1263,7 @@ void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) int z1 = std::min(m_heightmapRes - 1, cz + rSamples); if (m_sculptTool == SculptTool::Smooth) { - std::vector> s; + std::vector > s; for (int z = z0; z <= z1; ++z) for (int x = x0; x <= x1; ++x) { float dx = ((float)x - (float)cx) * spacing; @@ -1284,12 +1274,11 @@ void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) } float avg = 0; for (auto &p : s) - avg += m_heightData[p.second * m_heightmapRes + - p.first]; + avg += m_heightData[p.second * m_heightmapRes + p.first]; avg /= (float)s.size(); for (auto &p : s) { float &h = m_heightData[p.second * m_heightmapRes + - p.first]; + p.first]; h = avg * m_sculptStrength + h * (1.0f - m_sculptStrength); } @@ -1301,26 +1290,27 @@ void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) float dz = ((float)z - (float)cz) * spacing; if (dx * dx + dz * dz <= m_sculptRadius * m_sculptRadius) { - float &h = m_heightData[z * m_heightmapRes + - x]; + float &h = + m_heightData[z * m_heightmapRes + + x]; h = h * (1.0f - m_sculptStrength) + ch * m_sculptStrength; } } } else { - float sign = (m_sculptTool == SculptTool::Raise) ? - 1.0f : - -1.0f; + float sign = (m_sculptTool == SculptTool::Raise) ? 1.0f : -1.0f; for (int z = z0; z <= z1; ++z) for (int x = x0; x <= x1; ++x) { float dx = ((float)x - (float)cx) * spacing; float dz = ((float)z - (float)cz) * spacing; float d2 = dx * dx + dz * dz; if (d2 <= m_sculptRadius * m_sculptRadius) { - float falloff = 1.0f - - sqrtf(d2) / m_sculptRadius; + float falloff = + 1.0f - + sqrtf(d2) / m_sculptRadius; m_heightData[z * m_heightmapRes + x] += - sign * m_sculptStrength * falloff; + sign * m_sculptStrength * + falloff; } } } @@ -1341,80 +1331,251 @@ void TerrainSystem::applySculptBrush(const Ogre::Vector3 &worldPos) void TerrainSystem::applySplatBrush(const Ogre::Vector3 &worldPos) { - if (!mTerrainGroup || !m_active) + if (!mTerrainGroup || !m_active) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: applySplatBrush early return - no group or not active"); return; + } /* Blend maps live on the terrain instance — must be loaded. * In sculpt mode terrain is unloaded; skip. */ - if (m_sculptPreviewsActive) + if (m_sculptPreviewsActive) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: applySplatBrush early return - sculpt previews active"); return; + } int layerIdx = m_paintLayerIndex; - if (layerIdx < 1) + if (layerIdx < 1) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: applySplatBrush early return - 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()) + Ogre::Real ws = mTerrainGroup->getTerrainWorldSize(); + if (ws <= 0.0f) return; - if (layerIdx >= (int)terrain->getLayerCount()) - return; + /* Determine the terrain slot that contains the brush centre and the + * range of slots the brush radius touches. Use Ogre's helper so the + * origin and alignment are handled exactly like the rest of the terrain + * system. */ + long centreX = 0, centreY = 0; + mTerrainGroup->convertWorldPositionToTerrainSlot(worldPos, ¢reX, + ¢reY); - /* Blend map index is layerIdx-1 (layer 0 has no blend map). */ - Ogre::TerrainLayerBlendMap *blendMap = - terrain->getLayerBlendMap((Ogre::uint8)(layerIdx - 1)); - if (!blendMap) - return; + long slotRadius = (long)ceilf(m_paintRadius / ws); - int bmSize = (int)terrain->getLayerBlendMapSize(); - Ogre::Real ws = terrain->getWorldSize(); - Ogre::Real halfSize = ws * 0.5f; + for (long py = centreY - slotRadius; py <= centreY + slotRadius; ++py) { + for (long px = centreX - slotRadius; px <= centreX + slotRadius; + ++px) { + Ogre::Terrain *terrain = + mTerrainGroup->getTerrain(px, py); + if (!terrain || !terrain->isLoaded()) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: applySplatBrush skip page (" + + Ogre::StringConverter::toString(px) + + "," + + Ogre::StringConverter::toString(py) + + ") not loaded"); + continue; + } - /* Page corner in world space. */ - Ogre::Vector3 pageCorner; - mTerrainGroup->convertTerrainSlotToWorldPosition(px, py, &pageCorner); + if (layerIdx >= (int)terrain->getLayerCount()) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: applySplatBrush skip page (" + + Ogre::StringConverter::toString(px) + + "," + + Ogre::StringConverter::toString(py) + + ") layerIdx >= layerCount(" + + Ogre::StringConverter::toString( + (int)terrain->getLayerCount()) + + ")"); + continue; + } - /* 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); + /* getLayerBlendMap expects the actual layer index (1..n), + * not the internal blend-map array index. */ + Ogre::TerrainLayerBlendMap *blendMap = + terrain->getLayerBlendMap( + (Ogre::uint8)layerIdx); + if (!blendMap) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: applySplatBrush skip page (" + + Ogre::StringConverter::toString(px) + + "," + + Ogre::StringConverter::toString(py) + + ") blendMap null for layer " + + Ogre::StringConverter::toString( + layerIdx)); + continue; + } - float spacing = ws / (float)bmSize; - int rSamples = (int)(m_paintRadius / spacing) + 1; + /* Use the actual blend-map image dimensions (the GPU texture may + * be a different size than getLayerBlendMapSize() due to + * hardware padding / packing). */ + size_t maxX, maxY; + blendMap->convertUVToImageSpace(1.0f, 1.0f, &maxX, &maxY); + int bmW = (int)maxX + 1; + int bmH = (int)maxY + 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); + /* Convert the brush centre to blend-map UV using Ogre's helper. + * Values can be outside [0,1] when the centre is not inside this + * page (e.g. neighbouring pages touched by a large brush). */ + float brushU, brushV; + blendMap->convertWorldToUVSpace(worldPos, &brushU, + &brushV); - 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_paintRadius * m_paintRadius) { - float falloff = - 1.0f - sqrtf(d2) / m_paintRadius; - float cur = blendMap->getBlendValue( - (Ogre::uint32)x, (Ogre::uint32)y); - float val = std::min( - 1.0f, cur + m_paintStrength * falloff); - blendMap->setBlendValue((Ogre::uint32)x, (Ogre::uint32)y, - val); + /* Keep the unclamped float image coordinates so the distance + * falloff remains centred on the true brush position even when + * we clamp the affected pixel range to the page bounds. */ + float cxRaw = brushU * (float)(bmW - 1); + float cyRaw = brushV * (float)(bmH - 1); + + /* Texel spacing in world units. */ + float spacingX = ws / (float)(bmW - 1); + float spacingY = ws / (float)(bmH - 1); + int rSamplesX = (int)(m_paintRadius / spacingX) + 1; + int rSamplesY = (int)(m_paintRadius / spacingY) + 1; + + /* Pixel range potentially touched by the brush on this page. */ + int x0 = (int)floorf(cxRaw - (float)rSamplesX); + int x1 = (int)ceilf(cxRaw + (float)rSamplesX); + int y0 = (int)floorf(cyRaw - (float)rSamplesY); + int y1 = (int)ceilf(cyRaw + (float)rSamplesY); + + /* Clamp to the page image bounds. */ + x0 = std::max(0, x0); + x1 = std::min(bmW - 1, x1); + y0 = std::max(0, y0); + y1 = std::min(bmH - 1, y1); + + if (x0 > x1 || y0 > y1) + continue; + + /* Integer brush centre clamped to bounds; used for debug + * readback and logging only. */ + int cxClamped = (int)std::max(0.0f, + std::min(cxRaw, + (float)(bmW - 1))); + int cyClamped = (int)std::max(0.0f, + std::min(cyRaw, + (float)(bmH - 1))); + + bool painted = false; + for (int y = y0; y <= y1; ++y) { + for (int x = x0; x <= x1; ++x) { + float dx = (x - cxRaw) * spacingX; + float dy = (y - cyRaw) * spacingY; + float d2 = dx * dx + dy * dy; + if (d2 > m_paintRadius * m_paintRadius) + continue; + + float falloff = + 1.0f - + sqrtf(d2) / m_paintRadius; + float cur = blendMap->getBlendValue( + (Ogre::uint32)x, + (Ogre::uint32)y); + float delta = m_paintStrength * falloff; + float val = std::max( + 0.0f, + std::min(1.0f, cur + delta)); + blendMap->setBlendValue((Ogre::uint32)x, + (Ogre::uint32)y, + val); + painted = true; + } + } + + if (painted) { + /* dirtyRect uses half-open bounds. */ + blendMap->dirtyRect( + Ogre::Rect((long)x0, (long)y0, + (long)x1 + 1, + (long)y1 + 1)); + float cpuBefore = blendMap->getBlendValue( + (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(); + + /* Debug: read back the GPU blend value at the centre pixel + * to verify the upload actually reached the GPU. */ + static int s_splatDebugCount = 0; + if (s_splatDebugCount < 10) { + try { + std::pair texIdx = + terrain->getLayerBlendTextureIndex( + (Ogre::uint8)layerIdx); + Ogre::TexturePtr tex = + terrain->getLayerBlendTexture( + texIdx.first); + if (tex) { + Ogre::HardwarePixelBufferSharedPtr buf = + tex->getBuffer(); + Ogre::PixelBox pb = buf->lock( + Ogre::Box((Ogre::uint32)cxClamped, + (Ogre::uint32)cyClamped, + (Ogre::uint32)cxClamped + 1, + (Ogre::uint32)cyClamped + 1), + Ogre::HardwareBuffer::HBL_READ_ONLY); + Ogre::PixelFormat fmt = buf->getFormat(); + int bpp = (int)Ogre::PixelUtil::getNumElemBytes( + fmt); + std::string vals; + for (int i = 0; i < bpp && i < 4; ++i) { + if (i > 0) + vals += ","; + vals += Ogre::StringConverter::toString( + (int)((uint8_t *)pb.data)[i]); + } + buf->unlock(); + Ogre::LogManager::getSingleton().logMessage( + "Terrain: blend debug cpuBefore=" + + Ogre::StringConverter::toString( + cpuBefore) + + " gpuBytes(" + Ogre::StringConverter::toString(cxClamped) + "," + Ogre::StringConverter::toString(cyClamped) + ")=[" + + vals + "] fmt=" + + Ogre::PixelUtil::getFormatName(fmt) + + " bpp=" + + Ogre::StringConverter::toString(bpp)); + } + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: blend debug readback failed: " + + Ogre::String(e.what())); + } + ++s_splatDebugCount; + } + + Ogre::LogManager::getSingleton().logMessage( + "Terrain: splat painted layer " + + Ogre::StringConverter::toString( + layerIdx) + + " on page (" + + Ogre::StringConverter::toString(px) + + "," + + Ogre::StringConverter::toString(py) + + ") at world(" + + Ogre::StringConverter::toString( + worldPos.x) + + "," + + Ogre::StringConverter::toString( + worldPos.z) + + ") image(" + + Ogre::StringConverter::toString( + cxClamped) + + "," + + Ogre::StringConverter::toString( + cyClamped) + + ")"); } } } - - blendMap->dirtyRect(Ogre::Rect((long)x0, (long)y0, - (long)x1, (long)y1)); - blendMap->update(); } /* ------------------------------------------------------------------ */ @@ -1432,7 +1593,8 @@ void TerrainSystem::ensureSculptPreviewMaterial() "TerrainSculptPreview", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); - Ogre::Pass *pass = m_sculptPreviewMaterial->getTechnique(0)->createPass(); + 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); @@ -1475,7 +1637,7 @@ void TerrainSystem::beginSculptPreviews() mPageManager->setPagingOperationsEnabled(false); /* 2. Remember all loaded pages and unload them. */ - std::vector> loadedPages; + std::vector > loadedPages; for (const auto &kv : mTerrainGroup->getTerrainSlots()) { Ogre::TerrainGroup::TerrainSlot *slot = kv.second; if (slot && slot->instance && slot->instance->isLoaded()) @@ -1493,11 +1655,12 @@ void TerrainSystem::beginSculptPreviews() /* SceneNode at page minimum corner (match terrain * root node position). */ Ogre::Vector3 worldPos; - mTerrainGroup->convertTerrainSlotToWorldPosition( - x, y, &worldPos); + mTerrainGroup->convertTerrainSlotToWorldPosition(x, y, + &worldPos); - Ogre::SceneNode *node = m_sceneMgr->getRootSceneNode() - ->createChildSceneNode(worldPos); + Ogre::SceneNode *node = + m_sceneMgr->getRootSceneNode()->createChildSceneNode( + worldPos); Ogre::ManualObject *mo = m_sceneMgr->createManualObject(); mo->setDynamic(true); @@ -1544,16 +1707,15 @@ void TerrainSystem::buildSculptPreview(long pageX, long pageY) std::vector heights(size * size); { Ogre::Vector3 worldPos; - mTerrainGroup->convertTerrainSlotToWorldPosition( - pageX, pageY, &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); + heights[j * size + i] = sampleHeightAt(wx, wz); } } } @@ -1562,18 +1724,16 @@ void TerrainSystem::buildSculptPreview(long pageX, long pageY) 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 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]; + 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]; + 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; @@ -1663,9 +1823,9 @@ void TerrainSystem::endSculptPreviews() 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); + 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); @@ -1688,16 +1848,15 @@ void TerrainSystem::endSculptPreviews() /* ------------------------------------------------------------------ */ void TerrainSystem::updateBrushDecal(const Ogre::Vector3 &worldPos, - const Ogre::Vector3 &normal, - float radius) + const Ogre::Vector3 &normal, float radius) { if (!m_sceneMgr) return; /* Lazy-create the decal. */ if (!m_brushDecal) { - m_brushDecalNode = m_sceneMgr->getRootSceneNode() - ->createChildSceneNode(); + m_brushDecalNode = + m_sceneMgr->getRootSceneNode()->createChildSceneNode(); m_brushDecal = m_sceneMgr->createManualObject(); m_brushDecal->setDynamic(true); @@ -1715,28 +1874,24 @@ void TerrainSystem::updateBrushDecal(const Ogre::Vector3 &worldPos, "TerrainBrushDecal", Ogre::ResourceGroupManager:: DEFAULT_RESOURCE_GROUP_NAME); - Ogre::Pass *pass = - mat->getTechnique(0)->createPass(); + Ogre::Pass *pass = mat->getTechnique(0)->createPass(); pass->setLightingEnabled(false); - pass->setSceneBlending( - Ogre::SBT_TRANSPARENT_ALPHA); + pass->setSceneBlending(Ogre::SBT_TRANSPARENT_ALPHA); pass->setDepthWriteEnabled(false); pass->setDepthCheckEnabled(false); pass->setCullingMode(Ogre::CULL_NONE); /* Vertex colours provide the actual colour. */ - pass->setVertexColourTracking( - Ogre::TVC_DIFFUSE | Ogre::TVC_AMBIENT); + 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(); + Ogre::RTShader::ShaderGenerator *shaderGen = Ogre:: + RTShader::ShaderGenerator::getSingletonPtr(); if (shaderGen) { shaderGen->createShaderBasedTechnique( *mat, - Ogre::MaterialManager:: - DEFAULT_SCHEME_NAME, + Ogre::MaterialManager::DEFAULT_SCHEME_NAME, Ogre::RTShader::ShaderGenerator:: DEFAULT_SCHEME_NAME); shaderGen->validateMaterial( @@ -1746,19 +1901,16 @@ void TerrainSystem::updateBrushDecal(const Ogre::Vector3 &worldPos, } } - /* Section 1: semi-transparent circle (triangle fan). */ const int segments = 32; m_brushDecal->begin("TerrainBrushDecal", - Ogre::RenderOperation::OT_TRIANGLE_FAN); + 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)); + 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); } @@ -1767,7 +1919,7 @@ void TerrainSystem::updateBrushDecal(const Ogre::Vector3 &worldPos, /* 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); + 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); @@ -1783,9 +1935,8 @@ void TerrainSystem::updateBrushDecal(const Ogre::Vector3 &worldPos, /* 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); + m_brushDecalNode->setPosition(worldPos.x, worldPos.y + 0.5f, + worldPos.z); /* Orient the decal to face the terrain normal. */ m_brushDecalNode->setOrientation( @@ -1924,7 +2075,9 @@ void TerrainCommandQueue::executeCommandInternal(const TerrainCommand &cmd) break; case TerrainCommandType::SplatPaint: m_terrainSystem->setPaintRadius(cmd.radius); - m_terrainSystem->setPaintStrength(cmd.splatWeight); + /* Use command strength as the brush delta; negative values + * erase, positive values paint. */ + m_terrainSystem->setPaintStrength(cmd.strength); m_terrainSystem->setPaintLayerIndex(cmd.layerIndex); /* SplatPaint uses visual world coords directly * (blend-map texel mapping is visual-space). */ diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index f87c2ea..c7f31c4 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -99,6 +99,8 @@ public: /* Paint mode is mutually exclusive with sculpt. */ if (m_sculptMode) setSculptMode(false); + } else { + hideBrushDecal(); } } bool isPainting() const { return m_paintMode; } diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 9e96727..184a789 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -5,12 +5,13 @@ #include "../components/Terrain.hpp" #include "../components/Transform.hpp" #include "../components/EntityName.hpp" -#include "../components/EditorMarker.hpp" #include "../systems/SceneSerializer.hpp" #include +#include #include #include +#include #include #include #include @@ -22,6 +23,31 @@ int TerrainTestRunner::m_failures = 0; /* Helpers */ /* ------------------------------------------------------------------ */ +/* Read back the GPU blend texture byte for a given layer and image pixel. + * Returns true on success, writing the channel byte to *outByte. */ +static bool readGPUBlendByte(Ogre::Terrain *t, int layerIdx, int x, int y, + uint8_t *outByte) +{ + std::pair texIdx = + t->getLayerBlendTextureIndex((Ogre::uint8)layerIdx); + Ogre::TexturePtr tex = t->getLayerBlendTexture(texIdx.first); + if (!tex) + 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::PixelFormat fmt = buf->getFormat(); + unsigned char shifts[4]; + Ogre::PixelUtil::getBitShifts(fmt, shifts); + int byteOffset = shifts[texIdx.second] / 8; + *outByte = ((uint8_t *)pb.data)[byteOffset]; + buf->unlock(); + return true; +} + static void pumpFrames(EditorApp &app, TerrainSystem *ts, int count) { Ogre::FrameEvent evt; @@ -30,10 +56,8 @@ static void pumpFrames(EditorApp &app, TerrainSystem *ts, int count) for (int i = 0; i < count; ++i) { /* Run terrain system update to process activation, * colliders, and dirty page rebuilds. - * We do NOT call app.frameRenderingQueued() because - * it may crash outside the render loop (null viewport, - * uninitialized render targets, etc.). The terrain - * system's update() is self-contained. */ + * Called from within the render loop (frame listener), + * so GPU context / render targets are valid. */ if (ts) ts->update(evt.timeSinceLastFrame); } @@ -44,8 +68,11 @@ static flecs::entity createTerrainEntity(EditorApp &app) flecs::world *w = app.getWorld(); flecs::entity e = w->entity(); - { auto nc = EntityNameComponent(); nc.name = "TestTerrain"; e.set(nc); } - e.set({}); + { + auto nc = EntityNameComponent(); + nc.name = "TestTerrain"; + e.set(nc); + } TerrainComponent tc; tc.enabled = true; @@ -59,13 +86,17 @@ static flecs::entity createTerrainEntity(EditorApp &app) tc.minBatchSize = 17; tc.maxBatchSize = 65; /* One layer for splat tests. */ - tc.layers.push_back({"Ground23_col.jpg", "Ground23_normheight.dds", 100.0f}); - tc.layers.push_back({"Ground23_col.jpg", "Ground23_normheight.dds", 100.0f}); + tc.layers.push_back({ "Base", "Ground23_col.jpg", + "Ground23_normheight.dds", 100.0f }); + tc.layers.push_back({ "Grass", "Ground23_col.jpg", + "Ground23_normheight.dds", 100.0f }); e.set(tc); TransformComponent xform; /* Create a scene node so terrain activation works. */ - xform.node = app.getSceneManager()->getRootSceneNode()->createChildSceneNode(); + xform.node = app.getSceneManager() + ->getRootSceneNode() + ->createChildSceneNode(); xform.position = Ogre::Vector3(0, 0, 0); e.set(xform); @@ -134,9 +165,9 @@ bool TerrainTestRunner::testSculptOperations(EditorApp &app, TerrainSystem *ts) /* Apply raise command. */ TerrainCommandQueue &q = ts->getCommandQueue(); q.clear(); - q.push(TerrainCommand::raise(testPos, 30.0f, 1.0f)); - q.push(TerrainCommand::raise(testPos, 30.0f, 1.0f)); - q.push(TerrainCommand::raise(testPos, 30.0f, 1.0f)); + q.push(TerrainCommand::raise(testPos, 200.0f, 1.0f)); + q.push(TerrainCommand::raise(testPos, 200.0f, 1.0f)); + q.push(TerrainCommand::raise(testPos, 200.0f, 1.0f)); q.executeAll(); /* Pump frames so dirty pages rebuild. */ @@ -156,9 +187,9 @@ bool TerrainTestRunner::testSculptOperations(EditorApp &app, TerrainSystem *ts) /* Apply lower command. */ q.clear(); - q.push(TerrainCommand::lower(testPos, 30.0f, 1.0f)); - q.push(TerrainCommand::lower(testPos, 30.0f, 1.0f)); - q.push(TerrainCommand::lower(testPos, 30.0f, 1.0f)); + q.push(TerrainCommand::lower(testPos, 200.0f, 1.0f)); + q.push(TerrainCommand::lower(testPos, 200.0f, 1.0f)); + q.push(TerrainCommand::lower(testPos, 200.0f, 1.0f)); q.executeAll(); pumpFrames(app, ts, 3); @@ -176,7 +207,7 @@ bool TerrainTestRunner::testSculptOperations(EditorApp &app, TerrainSystem *ts) /* Test smooth. */ q.clear(); - q.push(TerrainCommand::smooth(testPos, 30.0f, 0.5f)); + q.push(TerrainCommand::smooth(testPos, 200.0f, 0.5f)); q.executeAll(); pumpFrames(app, ts, 3); @@ -184,9 +215,9 @@ bool TerrainTestRunner::testSculptOperations(EditorApp &app, TerrainSystem *ts) Ogre::Vector3 flatPos(400, 0, 400); float hPreFlat = ts->getHeightAt(flatPos); q.clear(); - q.push(TerrainCommand::flatten(flatPos, 25.0f, 1.0f)); - q.push(TerrainCommand::flatten(flatPos, 25.0f, 1.0f)); - q.push(TerrainCommand::flatten(flatPos, 25.0f, 1.0f)); + q.push(TerrainCommand::flatten(flatPos, 200.0f, 1.0f)); + q.push(TerrainCommand::flatten(flatPos, 200.0f, 1.0f)); + q.push(TerrainCommand::flatten(flatPos, 200.0f, 1.0f)); q.executeAll(); pumpFrames(app, ts, 3); @@ -209,13 +240,16 @@ bool TerrainTestRunner::testSplatOperations(EditorApp &app, TerrainSystem *ts) if (!ts->isActive()) return false; - Ogre::Vector3 paintPos(300, 0, 300); + /* Paint well away from the page centre to exercise the world-to-slot + * mapping used in interactive mode. Position (1500,0,0) is inside + * page (1,0) for the default 2000-unit world size. */ + Ogre::Vector3 paintPos(1500.0f, 0.0f, 0.0f); TerrainCommandQueue &q = ts->getCommandQueue(); q.clear(); /* Paint layer 1 at high weight. */ - q.push(TerrainCommand::splat(paintPos, 30.0f, 0.5f, 1, 1.0f)); + q.push(TerrainCommand::splat(paintPos, 500.0f, 0.5f, 1, 1.0f)); q.executeAll(); /* Pump frames for blend map update. */ @@ -228,24 +262,40 @@ bool TerrainTestRunner::testSplatOperations(EditorApp &app, TerrainSystem *ts) bool hasBlend = false; Ogre::TerrainGroup *group = ts->getTerrainGroup(); if (group) { - /* Paint pos is on page (0,0). */ - Ogre::Terrain *t = group->getTerrain(0, 0); - if (t && t->isLoaded() && t->getLayerCount() > 1) { - Ogre::TerrainLayerBlendMap *bm = - t->getLayerBlendMap(0); - if (bm) { - int bms = (int)t->getLayerBlendMapSize(); - /* Sample a few texels near centre. */ - for (int y = bms/2 - 5; y <= bms/2 + 5; - ++y) - for (int x = bms/2 - 5; - x <= bms/2 + 5; ++x) - if (bm->getBlendValue( - (Ogre::uint32)x, - (Ogre::uint32)y) > - 0.01f) - hasBlend = true; + /* Paint pos (1500,0,0) is on page (1,0). */ + Ogre::Terrain *t = group->getTerrain(1, 0); + if (t && t->isLoaded()) { + int layerCount = (int)t->getLayerCount(); + int bms = (int)t->getLayerBlendMapSize(); + if (layerCount > 1) { + Ogre::TerrainLayerBlendMap *bm = + t->getLayerBlendMap(1); + if (bm) { + /* The brush centre at x=1500 is 25% across page (1,0) + * (page spans [1000,3000]). Sample around the + * corresponding image column. */ + int expectedX = + (int)(0.25f * (bms - 1)); + for (int y = bms / 2 - 5; + y <= bms / 2 + 5 && !hasBlend; ++y) + for (int x = expectedX - 5; + x <= expectedX + 5; ++x) { + if (x < 0 || x >= bms) + continue; + float v = bm->getBlendValue( + (Ogre::uint32)x, + (Ogre::uint32)y); + if (v > 0.01f) + hasBlend = true; + } + } else { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: getLayerBlendMap(1) returned null"); + } } + } else { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: page(1,0) not loaded"); } } @@ -256,54 +306,184 @@ bool TerrainTestRunner::testSplatOperations(EditorApp &app, TerrainSystem *ts) } Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: splat operations passed"); + "TerrainTests: splat paint verified"); -/* Round-trip: save blend maps, reload, verify persistence. */ -{ - /* Find the TerrainComponent to get terrainId for saving. */ - flecs::world *w = app.getWorld(); - flecs::entity te = flecs::entity::null(); - w->query().each( - [&](flecs::entity e, TerrainComponent &tc) { - te = e; - }); - if (te.is_alive() && te.has()) { - auto &tc = te.get_mut(); - ts->saveSceneBlendMaps(tc); - ts->loadSceneBlendMaps(tc); - } - - /* Re-check blend values after reload. */ - bool hasBlendAfter = false; - Ogre::TerrainGroup *group = ts->getTerrainGroup(); - if (group) { - Ogre::Terrain *t = group->getTerrain(0, 0); - if (t && t->isLoaded() && t->getLayerCount() > 1) { - Ogre::TerrainLayerBlendMap *bm = - t->getLayerBlendMap(0); - if (bm) { - int bms = (int)t->getLayerBlendMapSize(); - for (int y = bms/2 - 5; y <= bms/2 + 5; ++y) - for (int x = bms/2 - 5; - x <= bms/2 + 5; ++x) - if (bm->getBlendValue( - (Ogre::uint32)x, - (Ogre::uint32)y) > - 0.01f) - hasBlendAfter = true; - } + /* Round-trip: save blend maps while painted, erase, reload, verify + * persistence. */ + { + /* Find the TerrainComponent to get terrainId for saving. */ + flecs::world *w = app.getWorld(); + flecs::entity te = flecs::entity::null(); + w->query().each( + [&](flecs::entity e, TerrainComponent &tc) { te = e; }); + if (te.is_alive() && te.has()) { + auto &tc = te.get_mut(); + ts->saveSceneBlendMaps(tc); } } - if (!hasBlendAfter) { - Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: FAIL - blend map values lost after save+reload round-trip"); - return false; - } -} -Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: splat operations (incl. save/load) passed"); -return true; + /* Test erase brush (negative strength). Erase at the same + * off-centre position and verify the painted page is cleared. */ + { + q.clear(); + q.push(TerrainCommand::splat(paintPos, 500.0f, -1.0f, 1, 1.0f)); + q.executeAll(); + pumpFrames(app, ts, 2); + + bool erased = true; + Ogre::TerrainGroup *group2 = ts->getTerrainGroup(); + if (group2) { + Ogre::Terrain *t = group2->getTerrain(1, 0); + if (t && t->isLoaded() && t->getLayerCount() > 1) { + Ogre::TerrainLayerBlendMap *bm = + t->getLayerBlendMap(1); + if (bm) { + int bms = + (int)t->getLayerBlendMapSize(); + int expectedX = + (int)(0.25f * (bms - 1)); + for (int y = bms / 2 - 5; + y <= bms / 2 + 5 && erased; ++y) + for (int x = expectedX - 5; + x <= expectedX + 5; ++x) { + if (x < 0 || x >= bms) + continue; + if (bm->getBlendValue( + (Ogre::uint32) + x, + (Ogre::uint32) + y) > + 0.9f) + erased = false; + } + } + } + } + if (!erased) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - erase brush did not reduce blend values"); + return false; + } + } + + /* Reload saved blend maps and verify paint is restored on page (1,0). + * Use a full deactivate/reactivate cycle so the activation path (which + * early-returned when m_active was false) is exercised. */ + { + flecs::world *w = app.getWorld(); + flecs::entity te = flecs::entity::null(); + w->query().each( + [&](flecs::entity e, TerrainComponent &tc) { te = e; }); + if (te.is_alive() && te.has()) { + ts->deactivate(); + pumpFrames(app, ts, 3); + } + + bool hasBlendAfter = false; + Ogre::TerrainGroup *group = ts->getTerrainGroup(); + if (group) { + Ogre::Terrain *t = group->getTerrain(1, 0); + if (t && t->isLoaded() && t->getLayerCount() > 1) { + Ogre::TerrainLayerBlendMap *bm = + t->getLayerBlendMap(1); + if (bm) { + int bms = + (int)t->getLayerBlendMapSize(); + int expectedX = + (int)(0.25f * (bms - 1)); + for (int y = bms / 2 - 5; + y <= bms / 2 + 5 && !hasBlendAfter; + ++y) + for (int x = expectedX - 5; + x <= expectedX + 5; ++x) { + if (x < 0 || x >= bms) + continue; + if (bm->getBlendValue( + (Ogre::uint32) + x, + (Ogre::uint32) + y) > + 0.01f) + hasBlendAfter = + true; + } + } + } + } + if (!hasBlendAfter) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - blend map values lost after save+reload round-trip"); + return false; + } + } + + /* Edge/cross-page paint: this position is inside page (0,0) but close + * enough to the -X edge that a large brush also touches page (-1,0). + * It previously produced out-of-bounds image coordinates and crashed. */ + { + q.clear(); + Ogre::Vector3 edgePos(-938.271f, 0.0f, 646.655f); + q.push(TerrainCommand::splat(edgePos, 500.0f, 1.0f, 1, + 1.0f)); + q.executeAll(); + pumpFrames(app, ts, 2); + + bool edgeOk = false; + Ogre::TerrainGroup *group3 = ts->getTerrainGroup(); + if (group3) { + Ogre::Terrain *t = group3->getTerrain(0, 0); + if (t && t->isLoaded() && t->getLayerCount() > 1) { + Ogre::TerrainLayerBlendMap *bm = + t->getLayerBlendMap(1); + if (bm) { + float u, v; + bm->convertWorldToUVSpace(edgePos, &u, + &v); + size_t imgX, imgY; + bm->convertUVToImageSpace(u, v, &imgX, + &imgY); + int expectedX = (int)imgX; + int expectedY = (int)imgY; + int bms = (int)t->getLayerBlendMapSize(); + for (int y = std::max(0, 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); + ++x) + if (bm->getBlendValue( + (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) && + gpuByte > 0) + edgeOk = true; + else + edgeOk = false; + } + } + } + } + if (!edgeOk) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - edge/cross-page splat paint did not update blend map or GPU"); + return false; + } + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: edge/cross-page splat paint verified"); + } + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: splat operations (incl. save/load) passed"); + return true; } bool TerrainTestRunner::testVerifyGeometry(EditorApp &app, TerrainSystem *ts) @@ -316,9 +496,9 @@ bool TerrainTestRunner::testVerifyGeometry(EditorApp &app, TerrainSystem *ts) * multiple positions across the terrain. */ bool allFinite = true; const Ogre::Vector3 samplePoints[] = { - { 0, 0, 0 }, { 500, 0, 500 }, { -500, 0, -500 }, - { 500, 0, -500 }, { -500, 0, 500 }, { 1000, 0, 0 }, - { 0, 0, 1000 }, { -1000, 0, 1000 }, { 1000, 0, 1000 }, + { 0, 0, 0 }, { 500, 0, 500 }, { -500, 0, -500 }, + { 500, 0, -500 }, { -500, 0, 500 }, { 1000, 0, 0 }, + { 0, 0, 1000 }, { -1000, 0, 1000 }, { 1000, 0, 1000 }, { -1000, 0, -1000 }, }; @@ -330,8 +510,7 @@ bool TerrainTestRunner::testVerifyGeometry(EditorApp &app, TerrainSystem *ts) Ogre::StringConverter::toString(p.x) + "," + Ogre::StringConverter::toString(p.y) + "," + Ogre::StringConverter::toString(p.z) + - ") = " + - Ogre::StringConverter::toString(h)); + ") = " + Ogre::StringConverter::toString(h)); allFinite = false; } } @@ -366,13 +545,13 @@ bool TerrainTestRunner::testDeleteTerrain(EditorApp &app, TerrainSystem *ts) flecs::world *w = app.getWorld(); flecs::entity found = flecs::entity::null(); - w->query().each( - [&](flecs::entity e, TerrainComponent &) { - if (e.has() && - e.get().name == "TestTerrain") { - found = e; - } - }); + w->query().each([&](flecs::entity e, + TerrainComponent &) { + if (e.has() && + e.get().name == "TestTerrain") { + found = e; + } + }); if (found.is_alive()) { destroyTerrainEntity(app, found); @@ -410,14 +589,13 @@ void TerrainTestRunner::logResult(const TerrainTestResult &r) { std::string status = r.passed ? "PASS" : "FAIL"; Ogre::LogManager::getSingleton().logMessage( - "TerrainTests: [" + status + "] iter=" + - Ogre::StringConverter::toString(r.iteration) + " " + + "TerrainTests: [" + status + + "] iter=" + Ogre::StringConverter::toString(r.iteration) + " " + r.name + " - " + r.message); if (!r.passed) - std::cerr << "TERRAIN TEST FAILED: " << r.name - << " (iter " << r.iteration << "): " << r.message - << std::endl; + std::cerr << "TERRAIN TEST FAILED: " << r.name << " (iter " + << r.iteration << "): " << r.message << std::endl; } /* ------------------------------------------------------------------ */ @@ -453,7 +631,7 @@ int TerrainTestRunner::run(EditorApp &app, int iterations) { "splat", testSplatOperations }, { "verify", testVerifyGeometry }, { "delete", testDeleteTerrain }, - + }; bool iterPassed = true; diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 60c906d..90832a6 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -6,6 +6,16 @@ #include "../components/Terrain.hpp" #include "../systems/TerrainSystem.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include + /** * Editor panel for TerrainComponent. * @@ -15,6 +25,9 @@ * * Milestone 2: "Show Terrain Colliders" checkbox (runtime-only, * not serialized). + * + * Milestone 3: sculpting, splat painting with layer/texture + * selection, heightmap save/load, blend-map save/load. */ class TerrainEditor : public ComponentEditor { public: @@ -41,6 +54,7 @@ public: /* --- Sculpting (M3) --- */ if (ts) { + ImGui::PushID("Sculpt"); ImGui::Text("Sculpting"); bool sculpt = ts->getSculptMode(); if (ImGui::Checkbox("Sculpt Mode", &sculpt)) @@ -65,14 +79,16 @@ public: float strength = ts->getSculptStrength(); if (ImGui::SliderFloat("Strength", &strength, 0.01f, - 1.0f)) + 1.0f)) ts->setSculptStrength(strength); + ImGui::PopID(); } ImGui::Separator(); /* --- Splat painting (M3) --- */ if (ts) { + ImGui::PushID("Paint"); ImGui::Text("Splat Painting"); bool paint = ts->getPaintMode(); if (ImGui::Checkbox("Paint Mode", &paint)) @@ -81,18 +97,20 @@ public: ImGui::TextColored(ImVec4(0.2f, 0.5f, 1, 1), "PAINT MODE ACTIVE"); - int li = ts->getPaintLayerIndex(); - if (ImGui::InputInt("Layer Index", &li, 1, 1)) - ts->setPaintLayerIndex(std::max(1, li)); + renderPaintLayerSelector(tc, ts); float pradius = ts->getPaintRadius(); if (ImGui::SliderFloat("Radius", &pradius, 5.0f, 500.0f)) ts->setPaintRadius(pradius); float pstrength = ts->getPaintStrength(); - if (ImGui::SliderFloat("Strength", &pstrength, 0.01f, - 1.0f)) + if (ImGui::SliderFloat("Strength", &pstrength, -1.0f, + 1.0f, "%.2f")) ts->setPaintStrength(pstrength); + if (pstrength < 0.0f) + ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1), + "(erasing)"); + ImGui::PopID(); } ImGui::Separator(); @@ -142,16 +160,91 @@ public: ImGui::Separator(); - /* Layers summary */ - ImGui::Text("Layers: %zu", tc.layers.size()); + /* Layers — editable with add/remove. Max 5 (SM2Profile limit). */ + ImGui::Text("Layers: %zu / 5", tc.layers.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Add Layer") && + tc.layers.size() < 5) { + /* If the component has no explicit layers yet, the terrain is + * using the implicit default base. Insert that base layer first + * so the new layer becomes a splat-mapped layer on top instead of + * replacing the ground. */ + if (tc.layers.empty()) { + TerrainComponent::Layer baseLayer; + baseLayer.name = "Base"; + baseLayer.diffuseTexture = "Ground23_col.jpg"; + baseLayer.normalTexture = "Ground23_normheight.dds"; + baseLayer.worldSize = 100.0f; + tc.layers.push_back(baseLayer); + } + + TerrainComponent::Layer newLayer; + newLayer.name = "Layer " + + std::to_string(tc.layers.size()); + /* Use a visually distinct texture so the user can see + * the result of splat painting immediately. */ + newLayer.diffuseTexture = "Ground37_diffspec.dds"; + newLayer.normalTexture = "Ground37_normheight.dds"; + newLayer.worldSize = 100.0f; + tc.layers.push_back(newLayer); + changed = true; + if (ts) + ts->deactivate(); + Ogre::LogManager::getSingleton().logMessage( + "TerrainEditor: added layer " + newLayer.name + + " diffuse=" + newLayer.diffuseTexture + + " normal=" + newLayer.normalTexture); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Remove Layer") && + tc.layers.size() > 1) { + tc.layers.pop_back(); + changed = true; + if (ts) + ts->deactivate(); + } for (size_t i = 0; i < tc.layers.size(); ++i) { auto &l = tc.layers[i]; - ImGui::BulletText("[%zu] %s / %s (ws=%.0f)", i, - l.diffuseTexture.c_str(), - l.normalTexture.c_str(), - l.worldSize); - } + ImGui::PushID((int)i); + if (ImGui::TreeNode("Layer", "[%zu] %s", i, + l.name.empty() ? + l.diffuseTexture.c_str() : + l.name.c_str())) { + char buf[256]; + strncpy(buf, l.name.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = 0; + if (ImGui::InputText("Name", buf, sizeof(buf))) { + l.name = buf; + changed = true; + } + if (texturePicker("Diffuse", l.diffuseTexture)) { + changed = true; + if (ts) + ts->deactivate(); + } + renderTexturePreview(l.diffuseTexture); + + if (texturePicker("Normal", l.normalTexture)) { + changed = true; + if (ts) + ts->deactivate(); + } + renderTexturePreview(l.normalTexture); + + float worldSize = l.worldSize; + if (ImGui::SliderFloat("World Size", &worldSize, + 1.0f, 1000.0f, "%.1f")) { + l.worldSize = worldSize; + changed = true; + if (ts) + ts->deactivate(); + } + + ImGui::TreePop(); + } + ImGui::PopID(); + } ImGui::Separator(); if (ImGui::Button("Reset to Defaults")) { @@ -167,6 +260,309 @@ public: { return "Terrain"; } + +private: + static std::vector &getTextureList() + { + static std::vector s_textures; + return s_textures; + } + + static bool &getTexturesScanned() + { + static bool s_scanned = false; + return s_scanned; + } + + static std::string &getTextureSearchBuffer() + { + static std::string s_search; + return s_search; + } + + static std::string &getTexturePickerTarget() + { + static std::string s_target; + return s_target; + } + + static bool &getTexturePickerOpen() + { + static bool s_open = false; + return s_open; + } + + static bool isTextureExtension(const std::string &ext) + { + static const char *imageExts[] = { "jpg", "jpeg", "png", + "dds", "tga", "bmp", "tif", + "tiff", "gif", "ktx" }; + std::string lower = ext; + std::transform(lower.begin(), lower.end(), lower.begin(), + ::tolower); + for (const char *e : imageExts) { + if (lower == e) + return true; + } + return false; + } + + static void scanTextureFiles() + { + if (getTexturesScanned()) + return; + + std::vector &textures = getTextureList(); + textures.clear(); + + Ogre::ResourceGroupManager &rgm = + Ogre::ResourceGroupManager::getSingleton(); + Ogre::StringVector groups = rgm.getResourceGroups(); + + for (const auto &group : groups) { + Ogre::FileInfoListPtr fileList = + rgm.findResourceFileInfo(group, "*"); + if (!fileList) + continue; + + for (const auto &fileInfo : *fileList) { + const std::string &filename = fileInfo.filename; + size_t dotPos = filename.find_last_of('.'); + if (dotPos == std::string::npos) + continue; + + std::string ext = filename.substr(dotPos + 1); + if (isTextureExtension(ext)) + textures.push_back(filename); + } + } + + std::sort(textures.begin(), textures.end()); + textures.erase(std::unique(textures.begin(), textures.end()), + textures.end()); + + getTexturesScanned() = true; + } + + static bool texturePicker(const char *label, std::string ¤t) + { + bool changed = false; + + std::vector &textures = getTextureList(); + if (!getTexturesScanned()) + scanTextureFiles(); + + /* Simple combo if the list is small, otherwise just show + * current value with a Browse button. */ + int currentIdx = -1; + std::vector items; + items.reserve(textures.size() + 1); + items.push_back("(none)"); + for (size_t i = 0; i < textures.size(); ++i) { + if (textures[i] == current) + currentIdx = (int)i + 1; + items.push_back(textures[i].c_str()); + } + + int idx = currentIdx; + if (ImGui::Combo(label, &idx, items.data(), + (int)items.size())) { + if (idx <= 0) + current.clear(); + else if (idx - 1 < (int)textures.size()) + current = textures[idx - 1]; + changed = true; + } + + ImGui::SameLine(); + if (ImGui::Button("Browse")) { + getTexturePickerTarget() = label; + getTexturePickerOpen() = true; + getTextureSearchBuffer().clear(); + } + + renderTextureBrowser(label, current); + return changed; + } + + static void renderTextureBrowser(const char *label, + std::string ¤t) + { + if (getTexturePickerTarget() != label) + return; + + if (getTexturePickerOpen()) + ImGui::OpenPopup("Texture Browser"); + + bool open = getTexturePickerOpen(); + if (!ImGui::BeginPopupModal("Texture Browser", &open, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (!open) + getTexturePickerOpen() = false; + return; + } + + std::vector &textures = getTextureList(); + + char searchBuf[256]; + strncpy(searchBuf, getTextureSearchBuffer().c_str(), + sizeof(searchBuf) - 1); + searchBuf[sizeof(searchBuf) - 1] = 0; + if (ImGui::InputText("Search", searchBuf, sizeof(searchBuf))) + getTextureSearchBuffer() = searchBuf; + + ImGui::Separator(); + + ImGui::BeginChild("TextureList", ImVec2(400, 300), true); + + std::string search = getTextureSearchBuffer(); + std::transform(search.begin(), search.end(), search.begin(), + ::tolower); + + for (const auto &tex : textures) { + if (!search.empty()) { + std::string lower = tex; + std::transform(lower.begin(), lower.end(), + lower.begin(), ::tolower); + if (lower.find(search) == std::string::npos) + continue; + } + + bool isCurrent = (current == tex); + if (isCurrent) + ImGui::PushStyleColor( + ImGuiCol_Text, + ImVec4(0.2f, 0.8f, 0.2f, 1.0f)); + + if (ImGui::Selectable(tex.c_str(), isCurrent)) { + current = tex; + getTexturePickerOpen() = false; + ImGui::CloseCurrentPopup(); + } + + if (isCurrent) + ImGui::PopStyleColor(); + } + + if (textures.empty()) + ImGui::TextDisabled("No textures found"); + + ImGui::EndChild(); + + ImGui::Separator(); + + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + getTexturePickerOpen() = false; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Refresh", ImVec2(120, 0))) { + getTexturesScanned() = false; + scanTextureFiles(); + } + + ImGui::EndPopup(); + getTexturePickerOpen() = open; + } + + static void renderTexturePreview(const std::string &textureName) + { + if (textureName.empty()) + return; + + Ogre::TextureManager &tm = + Ogre::TextureManager::getSingleton(); + Ogre::ResourceGroupManager &rgm = + Ogre::ResourceGroupManager::getSingleton(); + + /* Find the resource group that contains this texture. */ + Ogre::String groupName = Ogre::ResourceGroupManager:: + DEFAULT_RESOURCE_GROUP_NAME; + if (!tm.resourceExists(textureName, groupName)) { + for (const auto &g : rgm.getResourceGroups()) { + if (tm.resourceExists(textureName, g)) { + groupName = g; + break; + } + } + } + + /* Try to load the texture and display a small thumbnail. + * We attempt to retrieve the renderer-specific native + * handle (OpenGL texture ID) and fall back to a coloured + * placeholder if that fails. */ + try { + Ogre::TexturePtr tex = tm.load(textureName, groupName); + if (!tex) + return; + + Ogre::uint glID = 0; + try { + glID = tex->getCustomAttribute("GLID"); + } catch (const std::exception &) { + glID = 0; + } + + if (glID != 0) { + ImTextureID texID = (ImTextureID)(uintptr_t)glID; + ImGui::Image(texID, ImVec2(64, 64), + ImVec2(0, 0), ImVec2(1, 1)); + } else { + ImVec4 colour(0.5f, 0.5f, 0.5f, 1.0f); + ImGui::ColorButton( + "Preview", colour, + ImGuiColorEditFlags_NoTooltip | + ImGuiColorEditFlags_NoBorder, + ImVec2(64, 64)); + } + ImGui::SameLine(); + ImGui::Text("%dx%d", (int)tex->getWidth(), + (int)tex->getHeight()); + } catch (const std::exception &) { + } + } + + static void renderPaintLayerSelector(TerrainComponent &tc, + TerrainSystem *ts) + { + if (tc.layers.size() < 2) { + ImGui::TextColored( + ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Add a layer to enable splat painting."); + return; + } + + if (!tc.layers.empty() && + tc.layers[0].diffuseTexture == + tc.layers[ts->getPaintLayerIndex()].diffuseTexture) { + ImGui::TextColored( + ImVec4(1.0f, 0.8f, 0.2f, 1.0f), + "Warning: paint layer uses the same diffuse texture as the base layer."); + } + + int layerIdx = ts->getPaintLayerIndex(); + layerIdx = std::max(1, std::min(layerIdx, + (int)tc.layers.size() - 1)); + if (layerIdx != ts->getPaintLayerIndex()) + ts->setPaintLayerIndex(layerIdx); + + std::vector items; + items.reserve(tc.layers.size()); + static char labels[5][128]; + for (size_t i = 0; i < tc.layers.size() && i < 5; ++i) { + const auto &l = tc.layers[i]; + snprintf(labels[i], sizeof(labels[i]), "[%zu] %s", i, + l.name.empty() ? l.diffuseTexture.c_str() : + l.name.c_str()); + items.push_back(labels[i]); + } + + int idx = layerIdx; + if (ImGui::Combo("Paint Layer", &idx, items.data(), + (int)items.size())) { + ts->setPaintLayerIndex(idx); + } + } }; #endif // EDITSCENE_TERRAINEDITOR_HPP