decal works

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