Implemented terrain system core

This commit is contained in:
2026-06-26 23:46:19 +03:00
parent 5a7d97045f
commit 52de5cef44
15 changed files with 802 additions and 120 deletions
+3 -2
View File
@@ -151,8 +151,9 @@ to avoid stuck keys caused by missed `keyReleased` events. One-shot action flags
`animation_tree.json`. Each `AnimationTreeComponent` references a registry
tree by name only.
- Trees are authored in **Tools -> Animation Tree Registry**. Each registry
entry can select a skeleton source mesh from the `Characters` resource group;
this is used only by the editor to populate animation-name dropdowns.
entry can select a skeleton source from the `Characters` resource group (one
representative mesh per skeleton); this is used only by the editor to
populate animation-name dropdowns.
- `AnimationTreeSystem` resolves the tree from the registry each frame and
evaluates it.
- Selects a root bone (`Root`, `mixamorig:Hips`, then `Spineroot`), freezes it,
+7 -1
View File
@@ -1,7 +1,7 @@
project(editScene)
set(CMAKE_CXX_STANDARD 17)
find_package(OGRE REQUIRED COMPONENTS Bites Overlay MeshLodGenerator CONFIG)
find_package(OGRE REQUIRED COMPONENTS Bites Overlay MeshLodGenerator Paging Terrain CONFIG)
find_package(flecs REQUIRED CONFIG)
find_package(nlohmann_json REQUIRED)
find_package(SDL2 REQUIRED)
@@ -23,6 +23,7 @@ set(EDITSCENE_SOURCES
systems/EditorSunSystem.cpp
systems/EditorSkyboxSystem.cpp
systems/EditorWaterPlaneSystem.cpp
systems/TerrainSystem.cpp
systems/LightSystem.cpp
systems/CameraSystem.cpp
systems/LodSystem.cpp
@@ -163,6 +164,7 @@ set(EDITSCENE_SOURCES
components/BuoyancyInfoModule.cpp
components/WaterPhysicsModule.cpp
components/WaterPlaneModule.cpp
components/TerrainModule.cpp
components/SunModule.cpp
components/SkyboxModule.cpp
camera/EditorCamera.cpp
@@ -193,6 +195,7 @@ set(EDITSCENE_HEADERS
components/BuoyancyInfo.hpp
components/WaterPhysics.hpp
components/WaterPlane.hpp
components/Terrain.hpp
components/Sun.hpp
components/Skybox.hpp
components/Light.hpp
@@ -284,6 +287,7 @@ set(EDITSCENE_HEADERS
systems/EditorSunSystem.hpp
systems/EditorSkyboxSystem.hpp
systems/EditorWaterPlaneSystem.hpp
systems/TerrainSystem.hpp
systems/LightSystem.hpp
systems/CameraSystem.hpp
systems/LodSystem.hpp
@@ -378,6 +382,8 @@ target_link_libraries(editSceneEditor
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
+15
View File
@@ -8,6 +8,7 @@
#include "systems/EditorSunSystem.hpp"
#include "systems/EditorSkyboxSystem.hpp"
#include "systems/EditorWaterPlaneSystem.hpp"
#include "systems/TerrainSystem.hpp"
#include "systems/LightSystem.hpp"
#include "systems/CameraSystem.hpp"
#include "systems/LodSystem.hpp"
@@ -60,6 +61,7 @@
#include "components/InWater.hpp"
#include "components/WaterPhysics.hpp"
#include "components/WaterPlane.hpp"
#include "components/Terrain.hpp"
#include "components/Sun.hpp"
#include "components/Skybox.hpp"
#include "components/Light.hpp"
@@ -356,6 +358,13 @@ void EditorApp::setup()
m_waterPlaneSystem = std::make_unique<EditorWaterPlaneSystem>(
m_world, m_sceneMgr);
// TerrainSystem — owns Ogre terrain singletons, needs camera for paging.
{
Ogre::Camera *cam = m_camera ? m_camera->getCamera() : nullptr;
m_terrainSystem = std::make_unique<TerrainSystem>(
m_world, m_sceneMgr, cam);
}
// Apply debug setting if it was set before system creation
if (m_debugBuoyancy) {
m_buoyancySystem->setDebugEnabled(true);
@@ -1195,6 +1204,7 @@ void EditorApp::setupECS()
m_world.component<BuoyancyInfo>();
m_world.component<WaterPhysics>();
m_world.component<WaterPlane>();
m_world.component<TerrainComponent>();
// Register light and camera components
m_world.component<LightComponent>();
@@ -1440,6 +1450,11 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
m_proceduralMeshSystem->update();
}
/* --- Terrain update (before static world so navmesh can use terrain) --- */
if (m_terrainSystem) {
m_terrainSystem->update(evt.timeSinceLastFrame);
}
/* --- Static world generation (meshes + physics) --- */
if (m_roomLayoutSystem) {
m_roomLayoutSystem->update();
+2
View File
@@ -40,6 +40,7 @@ class EditorSunSystem;
class EditorSkyboxSystem;
class EditorWaterPlaneSystem;
class NormalDebugSystem;
class TerrainSystem;
class SmartObjectSystem;
class GoapRunnerSystem;
class PathFollowingSystem;
@@ -266,6 +267,7 @@ private:
std::unique_ptr<EditorSunSystem> m_sunSystem;
std::unique_ptr<EditorSkyboxSystem> m_skyboxSystem;
std::unique_ptr<EditorWaterPlaneSystem> m_waterPlaneSystem;
std::unique_ptr<TerrainSystem> m_terrainSystem;
std::unique_ptr<EditorLightSystem> m_lightSystem;
std::unique_ptr<EditorCameraSystem> m_cameraSystem;
std::unique_ptr<EditorLodSystem> m_lodSystem;
+55 -13
View File
@@ -274,19 +274,26 @@ Sources (in order of priority):
Page-local sampling in `define()`:
```cpp
// convertTerrainSlotToWorldPosition returns the MINIMUM CORNER of page (x,y),
// NOT the center. Vertex (i,j) maps to:
// world.x = corner.x + i * worldSize / (terrainSize-1)
// world.z = corner.z + j * worldSize / (terrainSize-1)
Ogre::Real worldSize = terrainGroup->getTerrainWorldSize();
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 + x - (terrainSize - 1) / 2);
long wz = (long)(worldPos.z + z - (terrainSize - 1) / 2);
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 the
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
@@ -453,6 +460,41 @@ m_buoyancySystem.reset();
m_physicsSystem.reset();
```
### 2.9 EditorUISystem wiring
`EditorUISystem::renderComponentList()` hardcodes each component type with
explicit `entity.has<ComponentType>()` checks. Add the following after the
WaterPlane block in `src/features/editScene/systems/EditorUISystem.cpp`:
```cpp
#include "../components/Terrain.hpp" // at top with other includes
// In renderComponentList(), after WaterPlane:
// Render Terrain if present
if (entity.has<TerrainComponent>()) {
auto &tc = entity.get_mut<TerrainComponent>();
m_componentRegistry.render<TerrainComponent>(entity, tc);
componentCount++;
}
```
## 3. ZigzagHeightfieldShape (custom Jolt collision shape)
`JPH::HeightFieldShape` is plain row-major and always splits quads along the
@@ -1102,21 +1144,20 @@ target_link_libraries(editSceneEditor PUBLIC
Definition of done: a paged terrain renders in the editor; terrain pages load
and unload as the camera moves.
### Milestone 2 — Serialization + physics integration
- Scene serialization for `TerrainComponent` (save/load round-trip).
### Milestone 2 — Physics integration
- Implement `ZigzagHeightfieldShape` as a `JPH::MeshShape` per page with zigzag
triangulation matching Ogre.
- Queue collider creation after page load and derived-data completion.
- Maintain `mColliders` map; safe remove/re-add on scene clear or component
removal.
- Add raycast helper to `TerrainSystem` for gameplay/editor tools.
Definition of done: rigid bodies and characters collide flush with the visible
terrain; removing/re-adding the terrain entity is leak-free.
### Milestone 3 — Serialization + heightmap editing + splat painting
- Scene serialization for `TerrainComponent` (save/load JSON round-trip).
- Binary heightmap file save/load alongside scene JSON.
Definition of done: terrain saves/loads from JSON; rigid bodies and characters
collide flush with the visible terrain; removing/re-adding the terrain entity
is leak-free.
### Milestone 3 — Heightmap editing + splat painting
- Brush tools (raise/lower/smooth/flatten) with curve-based brush profile.
- Splat paint brush (select layer, set opacity, paint blend values via
`TerrainLayerBlendMap`).
@@ -1128,8 +1169,9 @@ is leak-free.
- `AuxMap` infrastructure (create, load, save, edit with brushes).
- Fixup chunk cleanup (clear all, delete individual chunks by world position).
Definition of done: user can sculpt the terrain in the editor and save/load the
result; visual and collision update after sculpting.
Definition of done: user can sculpt the terrain in the editor; save scene,
reload, terrain restored with edited heightmap and layer paint; visual and
collision update after sculpting.
### Milestone 4 — Procedural roads
- Node/edge editing UI (add, remove, split, join, select, move).
@@ -0,0 +1,98 @@
#ifndef EDITSCENE_TERRAIN_HPP
#define EDITSCENE_TERRAIN_HPP
#pragma once
#include <Ogre.h>
#include <string>
#include <vector>
#include <cstdint>
/**
* Terrain component — singleton ECS component holding serializable
* parameters for the Ogre::Terrain-based terrain feature.
*
* Only one entity per scene should carry this component. All runtime
* Ogre/Jolt objects live in TerrainSystem, not here.
*/
struct TerrainComponent {
bool enabled = true;
// Page/tile vertex resolution. Must be 2^n+1 (e.g. 33, 65, 129, 257).
int terrainSize = 65;
// Stable unique ID — generated once at component creation, serialized,
// never changed. Used as the directory key for binary terrain data.
uint64_t terrainId = 0;
// Base binary heightmap resolution. Default 256x256.
int heightmapSize = 256;
// World-space size of one Ogre terrain page in units.
float worldSize = 2000.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;
// Minimum and maximum batch LOD sizes. Must be 2^n+1 and <= terrainSize.
int minBatchSize = 17;
int maxBatchSize = 65;
// Base heightmap binary file path.
std::string heightmapFile = "heightmap.bin";
// Layer texture settings (diffuse+normal pairs).
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;
float verticalOffset = 0.0f;
int id = 0;
};
struct RoadEdge {
int nodeA = -1;
int nodeB = -1;
float roadLevelA = 0.0f;
float roadLevelB = 0.0f;
int lanesPerDirectionOverride = 0;
int lanesAtoB = 0;
int lanesBtoA = 0;
struct RoadSidePrefab {
std::string prefabPath;
float edgeT = 0.5f;
float sideOffset = 5.0f;
bool leftSide = true;
};
std::vector<RoadSidePrefab> sidePrefabs;
};
std::vector<RoadNode> roadNodes;
std::vector<RoadEdge> roadEdges;
// Auxiliary maps (foliage density, material masks, etc.).
struct AuxMap {
std::string name;
std::string fileName;
int resolution = 256;
float defaultValue = 0.0f;
};
std::vector<AuxMap> auxMaps;
// Runtime-only: managed by TerrainSystem. Not serialized.
bool dirty = true;
bool rebuildInProgress = false;
void markDirty()
{
dirty = true;
}
};
#endif // EDITSCENE_TERRAIN_HPP
@@ -0,0 +1,24 @@
#include "Terrain.hpp"
#include "Transform.hpp"
#include "EditorMarker.hpp"
#include "EntityName.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/TerrainEditor.hpp"
REGISTER_COMPONENT_GROUP("Terrain", "Environment", TerrainComponent,
TerrainEditor)
{
registry.registerComponent<TerrainComponent>(
"Terrain", "Environment",
std::make_unique<TerrainEditor>(),
/* Adder */
[](flecs::entity e) {
if (!e.has<TerrainComponent>())
e.set<TerrainComponent>({});
},
/* Remover */
[](flecs::entity e) {
if (e.has<TerrainComponent>())
e.remove<TerrainComponent>();
});
}
@@ -107,23 +107,55 @@ void AnimationTreeRegistry::markModified(const Ogre::String &name)
}
}
std::vector<Ogre::String>
std::vector<AnimationTreeRegistry::SkeletonSource>
AnimationTreeRegistry::getAvailableSkeletonSources() const
{
std::vector<Ogre::String> result;
std::vector<SkeletonSource> result;
Ogre::ResourceGroupManager &rgm =
Ogre::ResourceGroupManager::getSingleton();
try {
Ogre::StringVectorPtr names =
rgm.findResourceNames("Characters", "*.mesh");
if (names)
result.assign(names->begin(), names->end());
if (!names)
return result;
std::unordered_map<Ogre::String, Ogre::String> seen;
for (const auto &meshName : *names) {
Ogre::String skelName =
getSkeletonNameForMesh(meshName);
if (skelName.empty())
continue;
if (seen.find(skelName) != seen.end())
continue;
seen[skelName] = meshName;
result.push_back({ skelName, meshName });
}
} catch (...) {
}
std::sort(result.begin(), result.end());
std::sort(result.begin(), result.end(),
[](const SkeletonSource &a, const SkeletonSource &b) {
return a.skeletonName < b.skeletonName;
});
return result;
}
Ogre::String AnimationTreeRegistry::getSkeletonNameForMesh(
const Ogre::String &meshName) const
{
try {
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().load(
meshName, "Characters");
if (!mesh || !mesh->hasSkeleton())
return Ogre::String();
return mesh->getSkeletonName();
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"AnimationTreeRegistry: failed to read skeleton for '" +
meshName + "': " + e.what());
}
return Ogre::String();
}
std::vector<Ogre::String>
AnimationTreeRegistry::getAnimationsForSource(const Ogre::String &source) const
{
@@ -74,10 +74,18 @@ public:
/* ------------------------------------------------------------------ */
/* Skeleton source helpers */
/* ------------------------------------------------------------------ */
std::vector<Ogre::String> getAvailableSkeletonSources() const;
struct SkeletonSource {
Ogre::String skeletonName;
Ogre::String meshName;
};
std::vector<SkeletonSource> getAvailableSkeletonSources() const;
std::vector<Ogre::String> getAnimationsForSource(
const Ogre::String &source) const;
/* Return the skeleton resource name for a given mesh, or empty. */
Ogre::String getSkeletonNameForMesh(const Ogre::String &meshName) const;
/* ------------------------------------------------------------------ */
/* Persistence */
/* ------------------------------------------------------------------ */
@@ -13,6 +13,7 @@
#include "../components/BuoyancyInfo.hpp"
#include "../components/WaterPhysics.hpp"
#include "../components/WaterPlane.hpp"
#include "../components/Terrain.hpp"
#include "../components/Sun.hpp"
#include "../components/Skybox.hpp"
#include "../components/Light.hpp"
@@ -1005,6 +1006,13 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
componentCount++;
}
// Render Terrain if present
if (entity.has<TerrainComponent>()) {
auto &tc = entity.get_mut<TerrainComponent>();
m_componentRegistry.render<TerrainComponent>(entity, tc);
componentCount++;
}
// Render LOD Settings if present
if (entity.has<LodSettingsComponent>()) {
auto &lodSettings = entity.get_mut<LodSettingsComponent>();
@@ -473,15 +473,13 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
deserializeCharacter(entity, json["character"]);
}
/* Deserialize identity before slots so legacy slot data migrates into
* the correct registry record. */
if (json.contains("characterIdentity")) {
deserializeCharacterIdentity(entity, json["characterIdentity"]);
}
if (json.contains("characterSlots")) {
deserializeCharacterSlots(entity, json["characterSlots"]);
}
/* CharacterSlots -> CharacterRegistry migration is disabled; all scenes
* have been migrated. */
(void)0;
if (json.contains("characterSpawner")) {
deserializeCharacterSpawner(entity, json["characterSpawner"]);
@@ -491,10 +489,9 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
deserializeAnimationTree(entity, json["animationTree"]);
}
if (json.contains("animationTreeTemplate")) {
deserializeAnimationTreeTemplate(entity,
json["animationTreeTemplate"]);
}
/* AnimationTreeTemplate migration is disabled; all scenes have been
* migrated to AnimationTreeRegistry. */
(void)0;
if (json.contains("startupMenu")) {
deserializeStartupMenu(entity, json["startupMenu"]);
@@ -712,15 +709,13 @@ void SceneSerializer::deserializeEntityComponents(
deserializeCharacter(entity, json["character"]);
}
/* Deserialize identity before slots so legacy slot data migrates into
* the correct registry record. */
if (json.contains("characterIdentity")) {
deserializeCharacterIdentity(entity, json["characterIdentity"]);
}
if (json.contains("characterSlots")) {
deserializeCharacterSlots(entity, json["characterSlots"]);
}
/* CharacterSlots -> CharacterRegistry migration is disabled; all scenes
* have been migrated. */
(void)0;
if (json.contains("characterSpawner")) {
deserializeCharacterSpawner(entity, json["characterSpawner"]);
@@ -730,10 +725,9 @@ void SceneSerializer::deserializeEntityComponents(
deserializeAnimationTree(entity, json["animationTree"]);
}
if (json.contains("animationTreeTemplate")) {
deserializeAnimationTreeTemplate(entity,
json["animationTreeTemplate"]);
}
/* AnimationTreeTemplate migration is disabled; all scenes have been
* migrated to AnimationTreeRegistry. */
(void)0;
if (json.contains("startupMenu")) {
deserializeStartupMenu(entity, json["startupMenu"]);
@@ -2300,78 +2294,14 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
void SceneSerializer::deserializeCharacterSlots(flecs::entity entity,
const nlohmann::json &json)
{
/* Migrate legacy CharacterSlots data into the character registry.
* The empty component is added as a placeholder reminder. */
uint64_t registryId = 0;
if (entity.has<CharacterIdentityComponent>())
registryId = entity.get<CharacterIdentityComponent>().registryId;
if (registryId == 0) {
registryId = CharacterRegistry::getSingleton().createCharacter(
"Migrated", "Character", "", true);
entity.set<CharacterIdentityComponent>(
CharacterIdentityComponent{ registryId });
}
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(registryId);
if (rec) {
rec->inlineSex = json.value("sex", rec->inlineSex);
if (json.contains("slots") && json["slots"].is_object()) {
for (auto &[slot, mesh] : json["slots"].items()) {
SlotSelection sel;
sel.explicitMesh = mesh.get<std::string>();
rec->inlineSlotSelections[slot] = sel;
}
}
if (json.contains("slotSelections") &&
json["slotSelections"].is_object()) {
for (auto &[slot, selJson] :
json["slotSelections"].items()) {
SlotSelection sel;
if (selJson.contains("layer0Mesh"))
sel.layer0Mesh =
selJson.value("layer0Mesh", "");
if (selJson.contains("layer1Mesh"))
sel.layer1Mesh =
selJson.value("layer1Mesh", "");
if (selJson.contains("layer2Mesh"))
sel.layer2Mesh =
selJson.value("layer2Mesh", "");
if (selJson.contains("layer") &&
sel.layer1Mesh.empty() &&
sel.layer2Mesh.empty()) {
int oldLayer = selJson.value("layer", 2);
if (oldLayer == 1)
sel.layer1Mesh = "auto";
else if (oldLayer >= 2)
sel.layer2Mesh = "auto";
}
sel.explicitMesh =
selJson.value("explicitMesh", "");
rec->inlineSlotSelections[slot] = sel;
}
}
if (json.contains("frontAxis") &&
json["frontAxis"].is_array() &&
json["frontAxis"].size() >= 3) {
Ogre::Vector3 fa(
json["frontAxis"][0].get<float>(),
json["frontAxis"][1].get<float>(),
json["frontAxis"][2].get<float>());
if (fa.squaredLength() > 0.0001f)
fa.normalise();
rec->frontAxis = fa;
}
CharacterRegistry::getSingleton().autoSave();
}
entity.add<CharacterSlotsComponent>();
CharacterRegistry::getSingleton().markCharacterDirty(registryId);
/* CharacterSlots -> CharacterRegistry migration is disabled. */
(void)entity;
(void)json;
}
/* Migrate a legacy inline animation tree into the registry and return
* the registry name to use. */
/* Migration disabled: legacy inline animation trees are no longer
* automatically imported into the registry. */
#if 0
static Ogre::String migrateInlineAnimationTree(
const nlohmann::json &json, const Ogre::String &preferredName)
{
@@ -2392,6 +2322,7 @@ static Ogre::String migrateInlineAnimationTree(
reg.registerTree(def);
return name;
}
#endif
nlohmann::json SceneSerializer::serializeAnimationTree(flecs::entity entity)
{
@@ -2414,7 +2345,10 @@ void SceneSerializer::deserializeAnimationTree(flecs::entity entity,
if (json.contains("treeName")) {
/* Current format. */
at.treeName = json.value("treeName", "");
} else if (json.contains("root")) {
}
/* Legacy inline tree / templateName migration is disabled. */
#if 0
else if (json.contains("root")) {
/* Legacy inline tree: register in registry. */
Ogre::String preferred = json.value("templateName", "");
at.treeName = migrateInlineAnimationTree(json, preferred);
@@ -2429,6 +2363,7 @@ void SceneSerializer::deserializeAnimationTree(flecs::entity entity,
reg.registerTree(def);
}
}
#endif
entity.set<AnimationTreeComponent>(at);
}
@@ -0,0 +1,303 @@
#include "TerrainSystem.hpp"
#include "../components/Terrain.hpp"
#include "../components/Transform.hpp"
#include <OgreTerrain.h>
#include <OgreTerrainGroup.h>
#include <OgreTerrainPaging.h>
#include <OgreTerrainPagedWorldSection.h>
#include <OgreTerrainMaterialGeneratorA.h>
#include <OgreLogManager.h>
#include <cmath>
/* ------------------------------------------------------------------------ */
TerrainSystem::TerrainSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
Ogre::Camera *camera)
: m_world(world)
, m_sceneMgr(sceneMgr)
, m_camera(camera)
, m_terrainQuery(world.query<TerrainComponent, TransformComponent>())
{
}
TerrainSystem::~TerrainSystem()
{
deactivate();
}
/* ------------------------------------------------------------------ */
bool TerrainSystem::DummyPageProvider::prepareProceduralPage(
Ogre::Page *, Ogre::PagedWorldSection *)
{
return true;
}
bool TerrainSystem::DummyPageProvider::loadProceduralPage(
Ogre::Page *, Ogre::PagedWorldSection *)
{
return true;
}
bool TerrainSystem::DummyPageProvider::unloadProceduralPage(
Ogre::Page *page, Ogre::PagedWorldSection *)
{
if (owner && owner->mTerrainGroup) {
long x, y;
owner->mTerrainGroup->unpackIndex(page->CHUNK_ID, &x, &y);
Ogre::LogManager::getSingleton().logMessage(
"Terrain: unloaded page " +
Ogre::StringConverter::toString(x) + ", " +
Ogre::StringConverter::toString(y));
}
return true;
}
bool TerrainSystem::DummyPageProvider::unprepareProceduralPage(
Ogre::Page *, Ogre::PagedWorldSection *)
{
return true;
}
/* ------------------------------------------------------------------ */
/* CustomTerrainDefiner — generates height procedurally */
/* ------------------------------------------------------------------ */
static float proceduralHeight(long worldX, long worldZ)
{
/* Simple procedural terrain: rolling hills using layered sine waves.
* The heights center around 0 so the terrain sits on the Y=0 plane. */
float h = 0.0f;
/* Large-scale hills (amplitude ±15) */
h += 15.0f * sinf((float)worldX * 0.005f) *
cosf((float)worldZ * 0.005f);
/* Medium bumps (amplitude ±5) */
h += 5.0f * sinf((float)worldX * 0.02f + 1.3f) *
cosf((float)worldZ * 0.02f + 0.7f);
/* Fine detail (amplitude ±2) */
h += 2.0f * sinf((float)worldX * 0.05f + 2.1f) *
sinf((float)worldZ * 0.05f + 0.3f);
return h;
}
void TerrainSystem::CustomTerrainDefiner::define(Ogre::TerrainGroup *group,
long x, long y)
{
Ogre::uint16 terrainSize = group->getTerrainSize();
Ogre::Real worldSize = group->getTerrainWorldSize();
float *heightMap = OGRE_ALLOC_T(float, terrainSize * terrainSize,
Ogre::MEMCATEGORY_GEOMETRY);
/* convertTerrainSlotToWorldPosition returns the MINIMUM CORNER
* of page (x,y). Vertex (i,j) is at:
* wx = worldPos.x + i * worldSize / (terrainSize - 1)
* wz = worldPos.z + j * worldSize / (terrainSize - 1) */
Ogre::Vector3 worldPos;
group->convertTerrainSlotToWorldPosition(x, y, &worldPos);
Ogre::Real step = worldSize / Ogre::Real(terrainSize - 1);
for (int j = 0; j < terrainSize; ++j) {
for (int i = 0; i < terrainSize; ++i) {
long wx = (long)(worldPos.x +
(Ogre::Real)i * step);
long wz = (long)(worldPos.z +
(Ogre::Real)j * step);
heightMap[j * terrainSize + i] =
proceduralHeight(wx, wz);
}
}
group->defineTerrain(x, y, heightMap);
Ogre::LogManager::getSingleton().logMessage(
"Terrain: defined page (" +
Ogre::StringConverter::toString(x) + ", " +
Ogre::StringConverter::toString(y) + ")");
OGRE_FREE(heightMap, Ogre::MEMCATEGORY_GEOMETRY);
}
/* ------------------------------------------------------------------ */
/* activate — build all Ogre terrain singletons */
/* ------------------------------------------------------------------ */
void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform)
{
Ogre::LogManager::getSingleton().logMessage(
"TerrainSystem: activating terrain...");
/* 1 — TerrainGlobalOptions */
mTerrainGlobals = OGRE_NEW Ogre::TerrainGlobalOptions();
mTerrainGlobals->setMaxPixelError(tc.maxPixelError);
mTerrainGlobals->setCompositeMapDistance(tc.compositeMapDistance);
mTerrainGlobals->setCompositeMapAmbient(
m_sceneMgr->getAmbientLight());
Ogre::TerrainMaterialGeneratorPtr matGen(
new Ogre::TerrainMaterialGeneratorA());
mTerrainGlobals->setDefaultMaterialGenerator(matGen);
/* 2 — TerrainGroup */
mTerrainGroup = OGRE_NEW Ogre::TerrainGroup(
m_sceneMgr, Ogre::Terrain::ALIGN_X_Z,
tc.terrainSize, tc.worldSize);
mTerrainGroup->setOrigin(xform.position);
/* 3 — ImportData defaults */
Ogre::Terrain::ImportData &defaultImp =
mTerrainGroup->getDefaultImportSettings();
defaultImp.terrainSize = tc.terrainSize;
defaultImp.worldSize = tc.worldSize;
defaultImp.minBatchSize = tc.minBatchSize;
defaultImp.maxBatchSize = tc.maxBatchSize;
defaultImp.inputScale = 1.0f;
/* Layer declaration from the material generator. */
defaultImp.layerDeclaration = matGen->getLayerDeclaration();
/* Build layer instances from the component. For M1, if no layers
* are configured, create a single default dirt/rock layer so the
* terrain is visible. */
if (tc.layers.empty()) {
Ogre::Terrain::LayerInstance li;
li.worldSize = 100.0f;
li.textureNames.push_back("Ground23_col.jpg");
li.textureNames.push_back("Ground23_normheight.dds");
defaultImp.layerList.push_back(li);
} else {
for (auto &cl : tc.layers) {
Ogre::Terrain::LayerInstance li;
li.worldSize = cl.worldSize;
li.textureNames.push_back(cl.diffuseTexture);
li.textureNames.push_back(cl.normalTexture);
defaultImp.layerList.push_back(li);
}
}
/* 4 — PageManager + camera */
mPageManager = OGRE_NEW Ogre::PageManager();
mDummyPageProvider = std::make_unique<DummyPageProvider>();
mDummyPageProvider->owner = this;
mPageManager->setPageProvider(mDummyPageProvider.get());
mPageManager->addCamera(m_camera);
/* 5 — TerrainPaging → PagedWorld → section */
mTerrainPaging = OGRE_NEW Ogre::TerrainPaging(mPageManager);
mPagedWorld = mPageManager->createWorld();
/* Set page range — start with a modest area for M1. */
const long pageMinX = -2, pageMinY = -2;
const long pageMaxX = 2, pageMaxY = 2;
mTerrainPagedWorldSection = mTerrainPaging->createWorldSection(
mPagedWorld, mTerrainGroup, 300.0f, 500.0f,
pageMinX, pageMinY, pageMaxX, pageMaxY);
/* 6 — TerrainDefiner */
mTerrainDefiner = std::make_unique<CustomTerrainDefiner>(this);
mTerrainPagedWorldSection->setDefiner(mTerrainDefiner.get());
/* 7 — Load initial pages synchronously (editor mode). */
mTerrainGroup->freeTemporaryResources();
mTerrainGroup->loadAllTerrains(true);
m_active = true;
Ogre::LogManager::getSingleton().logMessage(
"TerrainSystem: activation complete");
}
/* ------------------------------------------------------------------ */
/* deactivate */
/* ------------------------------------------------------------------ */
void TerrainSystem::deactivate()
{
if (!m_active && !mTerrainGroup)
return;
Ogre::LogManager::getSingleton().logMessage(
"TerrainSystem: deactivating...");
m_active = false;
/* Wait for background work to finish. */
if (mTerrainGroup && mTerrainGroup->isDerivedDataUpdateInProgress()) {
Ogre::LogManager::getSingleton().logMessage(
"TerrainSystem: waiting for derived data update...");
do {
mTerrainGroup->update(false);
} while (mTerrainGroup->isDerivedDataUpdateInProgress());
}
/* Remove camera from page manager. */
if (mPageManager && m_camera)
mPageManager->removeCamera(m_camera);
/* Destroy in correct order. */
mTerrainDefiner.reset();
if (mTerrainGroup) {
mTerrainGroup->removeAllTerrains();
}
/* TerrainPaging holds ref to PageManager — destroy first. */
OGRE_DELETE mTerrainPaging;
mTerrainPaging = nullptr;
OGRE_DELETE mPageManager;
mPageManager = nullptr;
OGRE_DELETE mTerrainGroup;
mTerrainGroup = nullptr;
OGRE_DELETE mTerrainGlobals;
mTerrainGlobals = nullptr;
mPagedWorld = nullptr;
mTerrainPagedWorldSection = nullptr;
mDummyPageProvider.reset();
Ogre::LogManager::getSingleton().logMessage(
"TerrainSystem: deactivated");
}
/* ------------------------------------------------------------------ */
/* update */
/* ------------------------------------------------------------------ */
void TerrainSystem::update(float /*deltaTime*/)
{
/* Check if a terrain entity appeared. */
m_terrainQuery.each([&](flecs::entity e, TerrainComponent &tc,
TransformComponent &xform) {
(void)e;
if (!tc.enabled && m_active) {
deactivate();
return;
}
if (tc.enabled && !m_active)
activate(tc, xform);
});
/* Pump the terrain group — drives LOD, page load/unload. */
if (m_active && mTerrainGroup) {
mTerrainGroup->update(false);
}
}
/* ------------------------------------------------------------------ */
/* getHeightAt */
/* ------------------------------------------------------------------ */
float TerrainSystem::getHeightAt(const Ogre::Vector3 &worldPos) const
{
if (!m_active || !mTerrainGroup)
return 0.0f;
return mTerrainGroup->getHeightAtWorldPosition(worldPos);
}
@@ -0,0 +1,111 @@
#ifndef EDITSCENE_TERRAINSYSTEM_HPP
#define EDITSCENE_TERRAINSYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <OgreTerrain.h>
#include <OgreTerrainGroup.h>
#include <OgreTerrainPaging.h>
#include <OgreTerrainPagedWorldSection.h>
#include <OgrePageManager.h>
#include <OgrePage.h>
#include <OgrePagedWorld.h>
#include <memory>
#include <unordered_map>
#include <cstdint>
// Forward declarations
namespace JPH {
class BodyID;
}
/**
* Singleton system that owns all Ogre terrain objects (TerrainGroup,
* TerrainPaging, PageManager, etc.) and the Jolt collision state.
*
* Created by EditorApp in setup(), one instance per scene. Activation
* happens when an entity with TerrainComponent + TransformComponent
* appears in the ECS world.
*/
class TerrainSystem {
public:
TerrainSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
Ogre::Camera *camera);
~TerrainSystem();
TerrainSystem(const TerrainSystem &) = delete;
TerrainSystem &operator=(const TerrainSystem &) = delete;
/** Per-frame update — pump terrain group, process collider queues. */
void update(float deltaTime);
/** Deactivate terrain: remove colliders, unload pages, destroy
* Ogre singletons. Called on scene clear. */
void deactivate();
/** Return true when terrain is active and rendering. */
bool isActive() const { return m_active; }
/** Get terrain height at world position (for raycasts/query). */
float getHeightAt(const Ogre::Vector3 &worldPos) const;
private:
/** Internal: build all Ogre terrain singletons from the component. */
void activate(struct TerrainComponent &tc,
struct TransformComponent &xform);
/** Dummy page provider — satisfies PageManager without disk I/O. */
class DummyPageProvider : public Ogre::PageProvider {
public:
bool prepareProceduralPage(Ogre::Page *page,
Ogre::PagedWorldSection *section) override;
bool loadProceduralPage(Ogre::Page *page,
Ogre::PagedWorldSection *section) override;
bool unloadProceduralPage(Ogre::Page *page,
Ogre::PagedWorldSection *section) override;
bool unprepareProceduralPage(Ogre::Page *page,
Ogre::PagedWorldSection *section) override;
TerrainSystem *owner = nullptr;
};
/** Custom terrain definer — generates height from procedural
* function or base heightmap. */
class CustomTerrainDefiner :
public Ogre::TerrainPagedWorldSection::TerrainDefiner {
public:
CustomTerrainDefiner(TerrainSystem *sys)
: Ogre::TerrainPagedWorldSection::TerrainDefiner()
, m_sys(sys)
{
}
void define(Ogre::TerrainGroup *terrainGroup, long x,
long y) override;
private:
TerrainSystem *m_sys;
};
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
Ogre::Camera *m_camera;
bool m_active = false;
// Ogre terrain singletons
Ogre::TerrainGlobalOptions *mTerrainGlobals = 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;
std::unique_ptr<DummyPageProvider> mDummyPageProvider;
// Query for the active terrain entity
flecs::query<struct TerrainComponent, struct TransformComponent>
m_terrainQuery;
};
#endif // EDITSCENE_TERRAINSYSTEM_HPP
@@ -51,13 +51,16 @@ void AnimationTreeRegistryEditor::renderListPane()
std::vector<Ogre::String> names = reg.getTreeNames();
std::sort(names.begin(), names.end());
for (const auto &name : names) {
for (size_t i = 0; i < names.size(); ++i) {
const auto &name = names[i];
bool selected = (m_selectedTreeName == name);
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(name.c_str(), selected)) {
if (m_selectedTreeName != name)
m_nodeEditor.reset();
m_selectedTreeName = name;
}
ImGui::PopID();
}
ImGui::EndChild();
@@ -96,28 +99,42 @@ void AnimationTreeRegistryEditor::renderEditPane()
}
/* Skeleton source */
std::vector<Ogre::String> sources = reg.getAvailableSkeletonSources();
std::vector<AnimationTreeRegistry::SkeletonSource> sources =
reg.getAvailableSkeletonSources();
Ogre::String currentSource = def->skeletonSource;
Ogre::String preview = currentSource.empty() ? "(none)" : currentSource;
Ogre::String currentSkelName =
currentSource.empty() ?
Ogre::String() :
reg.getSkeletonNameForMesh(currentSource);
Ogre::String preview =
currentSkelName.empty() ? "(none)" : currentSkelName;
if (ImGui::BeginCombo("Skeleton Source", preview.c_str())) {
bool noneSel = currentSource.empty();
if (ImGui::Selectable("(none)", noneSel)) {
def->skeletonSource = "";
reg.markModified(def->name);
}
for (const auto &src : sources) {
bool sel = (currentSource == src);
if (ImGui::Selectable(src.c_str(), sel)) {
def->skeletonSource = src;
for (size_t i = 0; i < sources.size(); ++i) {
const auto &src = sources[i];
bool sel = (currentSource == src.meshName);
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(src.skeletonName.c_str(), sel)) {
def->skeletonSource = src.meshName;
reg.markModified(def->name);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Representative mesh: %s",
src.meshName.c_str());
}
ImGui::PopID();
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Mesh from the Characters resource group used to "
"populate animation-name dropdowns.");
"Skeleton used to populate animation-name dropdowns. "
"One representative mesh per skeleton is shown.");
}
ImGui::Separator();
@@ -0,0 +1,80 @@
#ifndef EDITSCENE_TERRAINEDITOR_HPP
#define EDITSCENE_TERRAINEDITOR_HPP
#pragma once
#include "ComponentEditor.hpp"
#include "../components/Terrain.hpp"
/**
* Editor panel for TerrainComponent.
*
* Milestone 1: minimal enable/disable toggle and display of
* key parameters. Heightmap editing, road tools, splat painting
* come in later milestones.
*/
class TerrainEditor : public ComponentEditor<TerrainComponent> {
public:
bool renderComponent(flecs::entity entity,
TerrainComponent &tc) override
{
bool changed = false;
ImGui::PushID("Terrain");
/* Enable */
if (ImGui::Checkbox("Enabled", &tc.enabled))
changed = true;
ImGui::Separator();
/* Read-only display of sizing params (restart needed to change).
* In M2+ these get Restart Terrain buttons. */
ImGui::Text("Page size: %d x %d vertices", tc.terrainSize,
tc.terrainSize);
ImGui::Text("World size: %.0f units", tc.worldSize);
ImGui::Text("Heightmap: %d x %d", tc.heightmapSize,
tc.heightmapSize);
ImGui::Text("Batch: %d - %d", tc.minBatchSize,
tc.maxBatchSize);
ImGui::Separator();
/* Quality params (hot — change takes effect next define). */
if (ImGui::SliderFloat("Max Pixel Error", &tc.maxPixelError,
0.5f, 10.0f, "%.1f"))
changed = true;
if (ImGui::SliderFloat("Composite Map Dist",
&tc.compositeMapDistance, 50.0f,
2000.0f, "%.0f"))
changed = true;
ImGui::Separator();
/* Layers summary */
ImGui::Text("Layers: %zu", tc.layers.size());
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::Separator();
if (ImGui::Button("Reset to Defaults")) {
tc = TerrainComponent();
changed = true;
}
ImGui::PopID();
return changed;
}
const char *getName() const override
{
return "Terrain";
}
};
#endif // EDITSCENE_TERRAINEDITOR_HPP