Paint mode is functional

This commit is contained in:
2026-07-08 23:27:15 +03:00
parent 25106a23c4
commit 568b3bc917
10 changed files with 1616 additions and 708 deletions
+50 -2
View File
@@ -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) */
+399 -328
View File
@@ -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<Layer> 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<Layer> 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<RoadNode> roadNodes;
std::vector<RoadEdge> 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<RoadNode> roadNodes;
std::vector<RoadEdge> 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<CustomTerrainDefiner> 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<uint64_t, TerrainCollider> 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 14** 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 14** 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<TerrainSystem> m_terrainSystem;
```
**In `EditorApp::setupECS()`** — register the component:
```cpp
m_world.component<TerrainComponent>();
**In `EditorApp::setupECS()`** — register the component :
```cpp m_world.component<TerrainComponent>();
```
**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<TerrainSystem>(
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<TerrainSystem>(
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<ComponentType>()` checks. Add the following after the
explicit `entity.has<ComponentType>()` 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<TerrainComponent>())
{
auto &tc = entity.get_mut<TerrainComponent>();
// Render Terrain if present
if (entity.has<TerrainComponent>()) {
auto &tc = entity.get_mut<TerrainComponent>();
m_componentRegistry.render<TerrainComponent>(entity, tc);
componentCount++;
m_componentRegistry.render<TerrainComponent>(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<float> heightSamples;
uint32_t sampleCount = 0;
JPH::Vec3 offset = JPH::Vec3::sZero();
JPH::Vec3 scale = JPH::Vec3::sReplicate(1.0f);
std::vector<float> 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<void(const JPH::Triangle &t)> &fn);
// Zigzag-aware triangle iteration used by collide/cast functions.
void
VisitTriangles(const JPH::AABB &localBox,
const std::function<void(const JPH::Triangle &t)> &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<AuxMap> 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<AuxMap> auxMaps;
```
**Storage**: `heightmaps/<terrainId>/<auxMap.fileName>` — 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<RoadSidePrefab> 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<RoadSidePrefab> 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<TerrainComponent>(
"Terrain", "Environment",
std::make_unique<TerrainEditor>(),
[](flecs::entity e) {
if (!e.has<TerrainComponent>()) {
e.set<TerrainComponent>({});
}
},
[](flecs::entity e) {
if (e.has<TerrainComponent>()) {
e.remove<TerrainComponent>();
}
});
registry.registerComponent<TerrainComponent>(
"Terrain", "Environment", std::make_unique<TerrainEditor>(),
[](flecs::entity e) {
if (!e.has<TerrainComponent>()) {
e.set<TerrainComponent>({});
}
},
[](flecs::entity e) {
if (e.has<TerrainComponent>()) {
e.remove<TerrainComponent>();
}
});
}
```
@@ -1041,7 +1067,7 @@ Add to `SceneSerializer::serializeEntity()`:
```cpp
if (entity.has<TerrainComponent>()) {
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<uint64_t, TerrainCollider>` 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/<terrainId>` 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).
@@ -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;
+48
View File
@@ -1,6 +1,7 @@
#include <iostream>
#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) {
@@ -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();
}
}
}
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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; }
+279 -101
View File
@@ -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 <OgreLogManager.h>
#include <OgrePixelFormat.h>
#include <OgreTerrain.h>
#include <OgreTerrainGroup.h>
#include <OgreTexture.h>
#include <iostream>
#include <cmath>
#include <chrono>
@@ -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<Ogre::uint8, Ogre::uint8> 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<EntityNameComponent>(nc); }
e.set<EditorMarkerComponent>({});
{
auto nc = EntityNameComponent();
nc.name = "TestTerrain";
e.set<EntityNameComponent>(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<TerrainComponent>(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<TransformComponent>(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<TerrainComponent>().each(
[&](flecs::entity e, TerrainComponent &tc) {
te = e;
});
if (te.is_alive() && te.has<TerrainComponent>()) {
auto &tc = te.get_mut<TerrainComponent>();
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<TerrainComponent>().each(
[&](flecs::entity e, TerrainComponent &tc) { te = e; });
if (te.is_alive() && te.has<TerrainComponent>()) {
auto &tc = te.get_mut<TerrainComponent>();
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<TerrainComponent>().each(
[&](flecs::entity e, TerrainComponent &tc) { te = e; });
if (te.is_alive() && te.has<TerrainComponent>()) {
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<TerrainComponent>().each(
[&](flecs::entity e, TerrainComponent &) {
if (e.has<EntityNameComponent>() &&
e.get<EntityNameComponent>().name == "TestTerrain") {
found = e;
}
});
w->query<TerrainComponent>().each([&](flecs::entity e,
TerrainComponent &) {
if (e.has<EntityNameComponent>() &&
e.get<EntityNameComponent>().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;
+409 -13
View File
@@ -6,6 +6,16 @@
#include "../components/Terrain.hpp"
#include "../systems/TerrainSystem.hpp"
#include <OgreResourceGroupManager.h>
#include <OgreTextureManager.h>
#include <OgreTexture.h>
#include <imgui.h>
#include <algorithm>
#include <cstring>
#include <string>
#include <vector>
/**
* 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<TerrainComponent> {
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<std::string> &getTextureList()
{
static std::vector<std::string> 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<std::string> &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 &current)
{
bool changed = false;
std::vector<std::string> &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<const char *> 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 &current)
{
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<std::string> &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<const char *> 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