diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 779e272..02ca82f 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -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, diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 89bd5a4..810d74d 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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 diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 2c1f3d9..471c2e4 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -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( 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( + 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(); m_world.component(); m_world.component(); + m_world.component(); // Register light and camera components m_world.component(); @@ -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(); diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index 37b6082..db55791 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -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 m_sunSystem; std::unique_ptr m_skyboxSystem; std::unique_ptr m_waterPlaneSystem; + std::unique_ptr m_terrainSystem; std::unique_ptr m_lightSystem; std::unique_ptr m_cameraSystem; std::unique_ptr m_lodSystem; diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index cfe9b65..66c1a29 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -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()` 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()) { + + auto &tc = entity.get_mut(); + + m_componentRegistry.render(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). diff --git a/src/features/editScene/components/Terrain.hpp b/src/features/editScene/components/Terrain.hpp new file mode 100644 index 0000000..0b85b0b --- /dev/null +++ b/src/features/editScene/components/Terrain.hpp @@ -0,0 +1,98 @@ +#ifndef EDITSCENE_TERRAIN_HPP +#define EDITSCENE_TERRAIN_HPP +#pragma once + +#include +#include +#include +#include + +/** + * 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 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 sidePrefabs; + }; + std::vector roadNodes; + std::vector 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 auxMaps; + + // Runtime-only: managed by TerrainSystem. Not serialized. + bool dirty = true; + bool rebuildInProgress = false; + + void markDirty() + { + dirty = true; + } +}; + +#endif // EDITSCENE_TERRAIN_HPP diff --git a/src/features/editScene/components/TerrainModule.cpp b/src/features/editScene/components/TerrainModule.cpp new file mode 100644 index 0000000..10c80d9 --- /dev/null +++ b/src/features/editScene/components/TerrainModule.cpp @@ -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( + "Terrain", "Environment", + std::make_unique(), + /* Adder */ + [](flecs::entity e) { + if (!e.has()) + e.set({}); + }, + /* Remover */ + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/systems/AnimationTreeRegistry.cpp b/src/features/editScene/systems/AnimationTreeRegistry.cpp index c3b1f43..8d95187 100644 --- a/src/features/editScene/systems/AnimationTreeRegistry.cpp +++ b/src/features/editScene/systems/AnimationTreeRegistry.cpp @@ -107,23 +107,55 @@ void AnimationTreeRegistry::markModified(const Ogre::String &name) } } -std::vector +std::vector AnimationTreeRegistry::getAvailableSkeletonSources() const { - std::vector result; + std::vector 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 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 AnimationTreeRegistry::getAnimationsForSource(const Ogre::String &source) const { diff --git a/src/features/editScene/systems/AnimationTreeRegistry.hpp b/src/features/editScene/systems/AnimationTreeRegistry.hpp index 34c4d4c..cd8524c 100644 --- a/src/features/editScene/systems/AnimationTreeRegistry.hpp +++ b/src/features/editScene/systems/AnimationTreeRegistry.hpp @@ -74,10 +74,18 @@ public: /* ------------------------------------------------------------------ */ /* Skeleton source helpers */ /* ------------------------------------------------------------------ */ - std::vector getAvailableSkeletonSources() const; + struct SkeletonSource { + Ogre::String skeletonName; + Ogre::String meshName; + }; + + std::vector getAvailableSkeletonSources() const; std::vector 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 */ /* ------------------------------------------------------------------ */ diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 3a92e57..723e6e4 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -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()) { + auto &tc = entity.get_mut(); + m_componentRegistry.render(entity, tc); + componentCount++; + } + // Render LOD Settings if present if (entity.has()) { auto &lodSettings = entity.get_mut(); diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 60316fe..02a8492 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -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()) - registryId = entity.get().registryId; - - if (registryId == 0) { - registryId = CharacterRegistry::getSingleton().createCharacter( - "Migrated", "Character", "", true); - entity.set( - 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(); - 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(), - json["frontAxis"][1].get(), - json["frontAxis"][2].get()); - if (fa.squaredLength() > 0.0001f) - fa.normalise(); - rec->frontAxis = fa; - } - CharacterRegistry::getSingleton().autoSave(); - } - - entity.add(); - 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(at); } diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp new file mode 100644 index 0000000..a68b8cb --- /dev/null +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -0,0 +1,303 @@ +#include "TerrainSystem.hpp" +#include "../components/Terrain.hpp" +#include "../components/Transform.hpp" + +#include +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------------ */ + +TerrainSystem::TerrainSystem(flecs::world &world, Ogre::SceneManager *sceneMgr, + Ogre::Camera *camera) + : m_world(world) + , m_sceneMgr(sceneMgr) + , m_camera(camera) + , m_terrainQuery(world.query()) +{ +} + +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(); + 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(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); +} diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp new file mode 100644 index 0000000..f23f4e9 --- /dev/null +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -0,0 +1,111 @@ +#ifndef EDITSCENE_TERRAINSYSTEM_HPP +#define EDITSCENE_TERRAINSYSTEM_HPP +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 mTerrainDefiner; + std::unique_ptr mDummyPageProvider; + + // Query for the active terrain entity + flecs::query + m_terrainQuery; +}; + +#endif // EDITSCENE_TERRAINSYSTEM_HPP diff --git a/src/features/editScene/ui/AnimationTreeRegistryEditor.cpp b/src/features/editScene/ui/AnimationTreeRegistryEditor.cpp index 66f9934..44faddb 100644 --- a/src/features/editScene/ui/AnimationTreeRegistryEditor.cpp +++ b/src/features/editScene/ui/AnimationTreeRegistryEditor.cpp @@ -51,13 +51,16 @@ void AnimationTreeRegistryEditor::renderListPane() std::vector 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(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 sources = reg.getAvailableSkeletonSources(); + std::vector 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(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(); diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp new file mode 100644 index 0000000..1c1fe22 --- /dev/null +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -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 { +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