Paint mode is functional
This commit is contained in:
@@ -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) */
|
||||
|
||||
@@ -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 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<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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ¤t)
|
||||
{
|
||||
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 ¤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<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
|
||||
|
||||
Reference in New Issue
Block a user