diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 5dc9ff9..ffc0801 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -16,6 +16,7 @@ #include #include #include +#include namespace wowee::game { class TransportManager; @@ -468,6 +469,11 @@ public: using GameObjectSpawnCallback = std::function; void setGameObjectSpawnCallback(GameObjectSpawnCallback cb) { gameObjectSpawnCallback_ = std::move(cb); } + // GameObject move callback (online mode - triggered when gameobject position updates) + // Parameters: guid, x, y, z (canonical), orientation + using GameObjectMoveCallback = std::function; + void setGameObjectMoveCallback(GameObjectMoveCallback cb) { gameObjectMoveCallback_ = std::move(cb); } + // GameObject despawn callback (online mode - triggered when gameobject leaves view) using GameObjectDespawnCallback = std::function; void setGameObjectDespawnCallback(GameObjectDespawnCallback cb) { gameObjectDespawnCallback_ = std::move(cb); } @@ -504,6 +510,7 @@ public: // Check if a GUID is a known transport bool isTransportGuid(uint64_t guid) const { return transportGuids_.count(guid) > 0; } + bool hasServerTransportUpdate(uint64_t guid) const { return serverUpdatedTransportGuids_.count(guid) > 0; } glm::vec3 getComposedWorldPosition(); // Compose transport transform * local offset TransportManager* getTransportManager() { return transportManager_.get(); } void setPlayerOnTransport(uint64_t transportGuid, const glm::vec3& localOffset) { @@ -886,6 +893,11 @@ private: * Fail connection with reason */ void fail(const std::string& reason); + void updateAttachedTransportChildren(float deltaTime); + void setTransportAttachment(uint64_t childGuid, ObjectType type, uint64_t transportGuid, + const glm::vec3& localOffset, bool hasLocalOrientation, + float localOrientation); + void clearTransportAttachment(uint64_t childGuid); // Network std::unique_ptr socket; @@ -1004,10 +1016,20 @@ private: TransportMoveCallback transportMoveCallback_; TransportSpawnCallback transportSpawnCallback_; GameObjectSpawnCallback gameObjectSpawnCallback_; + GameObjectMoveCallback gameObjectMoveCallback_; GameObjectDespawnCallback gameObjectDespawnCallback_; // Transport tracking + struct TransportAttachment { + ObjectType type = ObjectType::OBJECT; + uint64_t transportGuid = 0; + glm::vec3 localOffset{0.0f}; + float localOrientation = 0.0f; + bool hasLocalOrientation = false; + }; + std::unordered_map transportAttachments_; std::unordered_set transportGuids_; // GUIDs of known transport GameObjects + std::unordered_set serverUpdatedTransportGuids_; uint64_t playerTransportGuid_ = 0; // Transport the player is riding (0 = none) glm::vec3 playerTransportOffset_ = glm::vec3(0.0f); // Player offset on transport std::unique_ptr transportManager_; // Transport movement manager diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index a9293da..3c671b4 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -165,6 +165,7 @@ private: // Cached isInsideWMO result (throttled to avoid per-frame cost) bool cachedInsideWMO = false; + bool cachedInsideInteriorWMO = false; int insideWMOCheckCounter = 0; glm::vec3 lastInsideWMOCheckPos = glm::vec3(0.0f); diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 46c87c5..bece252 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -430,6 +430,7 @@ private: // Animation update buffers (avoid per-frame allocation) std::vector boneWorkIndices_; // Reused each frame std::vector> animFutures_; // Reused each frame + bool spatialIndexDirty_ = false; // Smoke particle system std::vector smokeParticles; diff --git a/src/core/application.cpp b/src/core/application.cpp index 4f11f35..6fd912f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -498,13 +498,15 @@ void Application::update(float deltaTime) { (gameHandler->isOnTaxiFlight() || gameHandler->isTaxiMountActive() || gameHandler->isTaxiActivationPending()); + bool onTransportNow = gameHandler && gameHandler->isOnTransport(); if (worldEntryMovementGraceTimer_ > 0.0f) { worldEntryMovementGraceTimer_ -= deltaTime; } if (renderer && renderer->getCameraController()) { - renderer->getCameraController()->setExternalFollow(onTaxi); - renderer->getCameraController()->setExternalMoving(onTaxi); - if (onTaxi) { + const bool externallyDrivenMotion = onTaxi || onTransportNow; + renderer->getCameraController()->setExternalFollow(externallyDrivenMotion); + renderer->getCameraController()->setExternalMoving(externallyDrivenMotion); + if (externallyDrivenMotion) { // Drop any stale local movement toggles while server drives taxi motion. renderer->getCameraController()->clearMovementInputs(); taxiLandingClampTimer_ = 0.0f; @@ -618,6 +620,8 @@ void Application::update(float deltaTime) { glm::vec3 canonical = gameHandler->getComposedWorldPosition(); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); renderer->getCharacterPosition() = renderPos; + // Keep movementInfo in lockstep with composed transport world position. + gameHandler->setPosition(canonical.x, canonical.y, canonical.z); // Update camera follow target if (renderer->getCameraController()) { glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); @@ -1117,6 +1121,30 @@ void Application::setupUICallbacks() { } }); + gameHandler->setGameObjectMoveCallback([this](uint64_t guid, float x, float y, float z, float orientation) { + auto it = gameObjectInstances_.find(guid); + if (it == gameObjectInstances_.end() || !renderer) { + return; + } + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + auto& info = it->second; + if (info.isWmo) { + if (auto* wr = renderer->getWMORenderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); + wr->setInstanceTransform(info.instanceId, transform); + } + } else { + if (auto* mr = renderer->getM2Renderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); + mr->setInstanceTransform(info.instanceId, transform); + } + } + }); + // Transport spawn callback (online mode) - register transports with TransportManager gameHandler->setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { auto* transportManager = gameHandler->getTransportManager(); @@ -1136,10 +1164,12 @@ void Application::setupUICallbacks() { // TransportAnimation.dbc is indexed by GameObject entry uint32_t pathId = entry; + const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid); bool clientAnim = transportManager->isClientSideAnimation(); LOG_INFO("Transport spawn callback: clientAnimation=", clientAnim, - " guid=0x", std::hex, guid, std::dec, " entry=", entry, " pathId=", pathId); + " guid=0x", std::hex, guid, std::dec, " entry=", entry, " pathId=", pathId, + " preferServer=", preferServerData); // Coordinates are already canonical (converted in game_handler.cpp when entity was created) glm::vec3 canonicalSpawnPos(x, y, z); @@ -1156,7 +1186,25 @@ void Application::setupUICallbacks() { hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); } - if (!hasUsablePath) { + if (preferServerData) { + // Server-first mode: keep authoritative server snapshots, but still choose a + // deterministic DBC path (entry/remap) as fallback if updates go stale. + if (!hasUsablePath) { + uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); + if (remappedPath != 0) { + pathId = remappedPath; + LOG_INFO("Server-first transport registration: fallback path ", pathId, + " for entry ", entry, " displayId=", displayId); + } else { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_INFO("Server-first transport registration: stationary fallback for GUID 0x", + std::hex, guid, std::dec, " entry=", entry); + } + } else { + LOG_INFO("Server-first transport registration: using entry DBC path for entry ", entry); + } + } else if (!hasUsablePath) { uint32_t inferredPath = transportManager->inferMovingPathForSpawn(canonicalSpawnPos); if (inferredPath != 0) { pathId = inferredPath; @@ -1220,6 +1268,7 @@ void Application::setupUICallbacks() { // TransportAnimation.dbc is indexed by GameObject entry uint32_t pathId = entry; + const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid); // Coordinates are already canonical (converted in game_handler.cpp) glm::vec3 canonicalSpawnPos(x, y, z); @@ -1234,7 +1283,25 @@ void Application::setupUICallbacks() { hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); } - if (!hasUsablePath) { + if (preferServerData) { + if (!hasUsablePath) { + uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); + if (remappedPath != 0) { + pathId = remappedPath; + LOG_INFO("Auto-spawned transport in server-first mode with fallback path: entry=", entry, + " remappedPath=", pathId, " displayId=", displayId, + " wmoInstance=", wmoInstanceId); + } else { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_INFO("Auto-spawned transport in server-first mode (stationary fallback): entry=", entry, + " displayId=", displayId, " wmoInstance=", wmoInstanceId); + } + } else { + LOG_INFO("Auto-spawned transport in server-first mode with entry DBC path: entry=", entry, + " displayId=", displayId, " wmoInstance=", wmoInstanceId); + } + } else if (!hasUsablePath) { uint32_t inferredPath = transportManager->inferMovingPathForSpawn(canonicalSpawnPos); if (inferredPath != 0) { pathId = inferredPath; @@ -3150,11 +3217,19 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t " pos=(", x, ", ", y, ", ", z, ")"); if (renderer) { if (info.isWmo) { - if (auto* wr = renderer->getWMORenderer()) - wr->setInstancePosition(info.instanceId, renderPos); + if (auto* wr = renderer->getWMORenderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); + wr->setInstanceTransform(info.instanceId, transform); + } } else { - if (auto* mr = renderer->getM2Renderer()) - mr->setInstancePosition(info.instanceId, renderPos); + if (auto* mr = renderer->getM2Renderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); + mr->setInstanceTransform(info.instanceId, transform); + } } } return; @@ -3170,9 +3245,9 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t // DisplayIds 455, 462 = Elevators/Ships → try standard ship // DisplayIds 807, 808 = Zeppelins // DisplayIds 2454, 1587 = Special ships/icebreakers - if (displayId == 455 || displayId == 462 || displayId == 20808 || displayId == 176231 || displayId == 176310) { + if (displayId == 455 || displayId == 462 || entry == 20808 || entry == 176231 || entry == 176310) { modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; - LOG_INFO("Overriding transport displayId ", displayId, " → transportship.wmo"); + LOG_INFO("Overriding transport entry/display ", entry, "/", displayId, " → transportship.wmo"); } else if (displayId == 807 || displayId == 808 || displayId == 175080 || displayId == 176495 || displayId == 164871) { modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; LOG_INFO("Overriding transport displayId ", displayId, " → transport_zeppelin.wmo"); @@ -3286,9 +3361,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t LOG_DEBUG("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); - // Spawn WMO doodads (chairs, furniture, etc.) as child M2 instances - // TODO: Re-enable after implementing deferred/background loading - // Currently disabled - spawning 134 doodads synchronously causes massive slowdown + // Spawn transport WMO doodads (chairs, furniture, etc.) as child M2 instances bool isTransport = false; if (gameHandler) { std::string lowerModelPath = modelPath; @@ -3298,13 +3371,17 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } auto* m2Renderer = renderer->getM2Renderer(); - if (false && m2Renderer && isTransport) { // DISABLED - too slow + if (m2Renderer && isTransport) { const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); if (doodadTemplates && !doodadTemplates->empty()) { - LOG_INFO("Spawning ", doodadTemplates->size(), " doodads for transport WMO instance ", instanceId); + constexpr size_t kMaxTransportDoodads = 192; + const size_t doodadBudget = std::min(doodadTemplates->size(), kMaxTransportDoodads); + LOG_INFO("Spawning ", doodadBudget, "/", doodadTemplates->size(), + " doodads for transport WMO instance ", instanceId); int spawnedDoodads = 0; - for (const auto& doodadTemplate : *doodadTemplates) { + for (size_t i = 0; i < doodadBudget; ++i) { + const auto& doodadTemplate = (*doodadTemplates)[i]; // Load M2 model (may be cached) uint32_t doodadModelId = static_cast(std::hash{}(doodadTemplate.m2Path)); auto m2Data = assetManager->readFile(doodadTemplate.m2Path); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f42740a..6278035 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8,6 +8,7 @@ #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" #include "core/logger.hpp" +#include #include #include #include @@ -114,6 +115,8 @@ void GameHandler::disconnect() { activeCharacterGuid_ = 0; playerNameCache.clear(); pendingNameQueries.clear(); + transportAttachments_.clear(); + serverUpdatedTransportGuids_.clear(); setState(WorldState::DISCONNECTED); LOG_INFO("Disconnected from world server"); } @@ -341,6 +344,7 @@ void GameHandler::update(float deltaTime) { // Update transport manager if (transportManager_) { transportManager_->update(deltaTime); + updateAttachedTransportChildren(deltaTime); } // Distance check timing @@ -1829,6 +1833,14 @@ void GameHandler::sendMovement(Opcode opcode) { // Add transport data if player is on a transport if (isOnTransport()) { + // Keep authoritative world position synchronized to parent transport transform + // so heartbeats/corrections don't drag the passenger through geometry. + if (transportManager_) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; + } movementInfo.flags |= static_cast(MovementFlags::ONTRANSPORT); movementInfo.transportGuid = playerTransportGuid_; movementInfo.transportX = playerTransportOffset_.x; @@ -1841,8 +1853,12 @@ void GameHandler::sendMovement(Opcode opcode) { // ONTRANSPORT expects local orientation (player yaw relative to transport yaw). float transportYaw = 0.0f; if (transportManager_) { - if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr && tr->hasServerYaw) { - transportYaw = tr->serverYaw; + if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr) { + if (tr->hasServerYaw) { + transportYaw = tr->serverYaw; + } else { + transportYaw = glm::eulerAngles(tr->rotation).z; + } } } @@ -1969,8 +1985,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (entityManager.hasEntity(guid)) { const bool isKnownTransport = transportGuids_.count(guid) > 0; if (isKnownTransport) { - LOG_INFO("Ignoring out-of-range removal for transport: 0x", std::hex, guid, std::dec); - continue; + if (playerTransportGuid_ == guid) { + LOG_INFO("Keeping transport in-range while player is aboard: 0x", std::hex, guid, std::dec); + continue; + } + LOG_INFO("Processing out-of-range removal for transport: 0x", std::hex, guid, std::dec); } LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); @@ -1984,6 +2003,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } transportGuids_.erase(guid); + serverUpdatedTransportGuids_.erase(guid); + clearTransportAttachment(guid); if (playerTransportGuid_ == guid) { playerTransportGuid_ = 0; playerTransportOffset_ = glm::vec3(0.0f); @@ -2034,6 +2055,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Convert transport offset from server → canonical coordinates glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, block.orientation); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; + } LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); } else { @@ -2045,18 +2073,21 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } - // GameObjects with UPDATEFLAG_POSITION carry a parent transport GUID and local offset. - // Use that to drive parent transport motion and compose correct child world position. - if (block.objectType == ObjectType::GAMEOBJECT && - (block.updateFlags & 0x0100) && - block.onTransport && - block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + // Track transport-relative children so they follow parent transport motion. + if (block.guid != playerGuid && + (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + setTransportAttachment(block.guid, block.objectType, block.transportGuid, + localOffset, hasLocalOrientation, block.transportO); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + clearTransportAttachment(block.guid); } } } @@ -2200,6 +2231,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } // Fire transport move callback for transports (position update on re-creation) if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); transportMoveCallback_(block.guid, go->getX(), go->getY(), go->getZ(), go->getOrientation()); } @@ -2360,6 +2392,28 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Update existing entity fields auto entity = entityManager.getEntity(block.guid); if (entity) { + if (block.hasMovement) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + entity->setPosition(pos.x, pos.y, pos.z, block.orientation); + + if (block.guid != playerGuid && + (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + setTransportAttachment(block.guid, entity->getType(), block.transportGuid, + localOffset, hasLocalOrientation, block.transportO); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + clearTransportAttachment(block.guid); + } + } + } + for (const auto& field : block.fields) { entity->setField(field.first, field.second); } @@ -2550,6 +2604,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } rebuildOnlineInventory(); } + if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { + if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + transportMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } else if (gameObjectMoveCallback_) { + gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } + } LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); } else { @@ -2571,25 +2635,24 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { entity->setPosition(pos.x, pos.y, pos.z, block.orientation); LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); - // Some GameObject movement blocks are transport-relative: the packet carries - // parent transport GUID + local child offset in UPDATEFLAG_POSITION. - if (entity->getType() == ObjectType::GAMEOBJECT && - (block.updateFlags & 0x0100) && - block.onTransport && - block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + if (block.guid != playerGuid && + (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + setTransportAttachment(block.guid, entity->getType(), block.transportGuid, + localOffset, hasLocalOrientation, block.transportO); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + clearTransportAttachment(block.guid); } } if (block.guid == playerGuid) { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; movementInfo.orientation = block.orientation; // Track player-on-transport state from MOVEMENT updates @@ -2598,8 +2661,22 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Convert transport offset from server → canonical coordinates glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, block.orientation); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; + } else { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; + } LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); } else { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; if (playerTransportGuid_ != 0) { LOG_INFO("Player left transport (MOVEMENT)"); playerTransportGuid_ = 0; @@ -2610,8 +2687,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Fire transport move callback if this is a known transport if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, block.orientation); } + // Fire move callback for non-transport gameobjects. + if (entity->getType() == ObjectType::GAMEOBJECT && + transportGuids_.count(block.guid) == 0 && + gameObjectMoveCallback_) { + gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } } else { LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); } @@ -2687,6 +2772,7 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { // Remove entity if (entityManager.hasEntity(data.guid)) { if (transportGuids_.count(data.guid) > 0) { + serverUpdatedTransportGuids_.erase(data.guid); LOG_INFO("Ignoring destroy for transport entity: 0x", std::hex, data.guid, std::dec); return; } @@ -2699,6 +2785,7 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { gameObjectDespawnCallback_(data.guid); } } + clearTransportAttachment(data.guid); entityManager.removeEntity(data.guid); LOG_INFO("Destroyed entity: 0x", std::hex, data.guid, std::dec, " (", (data.isDeath ? "death" : "despawn"), ")"); @@ -4576,11 +4663,16 @@ void GameHandler::handleMonsterMoveTransport(network::Packet& packet) { if (transportManager_) { // Use TransportManager to compose world position from local offset glm::vec3 localPos(localX, localY, localZ); + setTransportAttachment(moverGuid, entity->getType(), transportGuid, localPos, false, 0.0f); glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localPos); entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation()); LOG_INFO(" Composed NPC world position: (", worldPos.x, ", ", worldPos.y, ", ", worldPos.z, ")"); + + if (entity->getType() == ObjectType::UNIT && creatureMoveCallback_) { + creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0); + } } else { LOG_WARNING(" TransportManager not available for NPC position composition"); } @@ -7270,6 +7362,86 @@ void GameHandler::loadCharacterConfig() { } } +void GameHandler::setTransportAttachment(uint64_t childGuid, ObjectType type, uint64_t transportGuid, + const glm::vec3& localOffset, bool hasLocalOrientation, + float localOrientation) { + if (childGuid == 0 || transportGuid == 0) { + return; + } + + TransportAttachment& attachment = transportAttachments_[childGuid]; + attachment.type = type; + attachment.transportGuid = transportGuid; + attachment.localOffset = localOffset; + attachment.hasLocalOrientation = hasLocalOrientation; + attachment.localOrientation = localOrientation; +} + +void GameHandler::clearTransportAttachment(uint64_t childGuid) { + if (childGuid == 0) { + return; + } + transportAttachments_.erase(childGuid); +} + +void GameHandler::updateAttachedTransportChildren(float /*deltaTime*/) { + if (!transportManager_ || transportAttachments_.empty()) { + return; + } + + constexpr float kPosEpsilonSq = 0.0001f; + constexpr float kOriEpsilon = 0.001f; + std::vector stale; + stale.reserve(8); + + for (const auto& [childGuid, attachment] : transportAttachments_) { + auto entity = entityManager.getEntity(childGuid); + if (!entity) { + stale.push_back(childGuid); + continue; + } + + ActiveTransport* transport = transportManager_->getTransport(attachment.transportGuid); + if (!transport) { + continue; + } + + glm::vec3 composed = transportManager_->getPlayerWorldPosition( + attachment.transportGuid, attachment.localOffset); + + float composedOrientation = entity->getOrientation(); + if (attachment.hasLocalOrientation) { + float baseYaw = transport->hasServerYaw ? transport->serverYaw : 0.0f; + composedOrientation = baseYaw + attachment.localOrientation; + } + + glm::vec3 oldPos(entity->getX(), entity->getY(), entity->getZ()); + float oldOrientation = entity->getOrientation(); + glm::vec3 delta = composed - oldPos; + const bool positionChanged = glm::dot(delta, delta) > kPosEpsilonSq; + const bool orientationChanged = std::abs(composedOrientation - oldOrientation) > kOriEpsilon; + if (!positionChanged && !orientationChanged) { + continue; + } + + entity->setPosition(composed.x, composed.y, composed.z, composedOrientation); + + if (attachment.type == ObjectType::UNIT) { + if (creatureMoveCallback_) { + creatureMoveCallback_(childGuid, composed.x, composed.y, composed.z, 0); + } + } else if (attachment.type == ObjectType::GAMEOBJECT) { + if (gameObjectMoveCallback_) { + gameObjectMoveCallback_(childGuid, composed.x, composed.y, composed.z, composedOrientation); + } + } + } + + for (uint64_t guid : stale) { + transportAttachments_.erase(guid); + } +} + glm::vec3 GameHandler::getComposedWorldPosition() { if (playerTransportGuid_ != 0 && transportManager_) { return transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index d2bb03b..118d691 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -255,11 +255,10 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float pathTimeMs = transport.localClockMs % path.durationMs; } else { // Server-driven transport without clock sync: - // stay server-authoritative and never switch to DBC/client animation fallback. - // For short server update gaps, apply lightweight dead-reckoning only when we - // have measured velocity from at least one authoritative delta. - constexpr float kMaxExtrapolationSec = 8.0f; + // keep server-authoritative and dead-reckon from last known velocity. const float ageSec = elapsedTime_ - transport.lastServerUpdate; + constexpr float kMaxExtrapolationSec = 30.0f; + if (transport.hasServerVelocity && ageSec > 0.0f && ageSec <= kMaxExtrapolationSec) { const float blend = glm::clamp(1.0f - (ageSec / kMaxExtrapolationSec), 0.0f, 1.0f); transport.position += transport.serverLinearVelocity * (deltaTime * blend); @@ -577,6 +576,74 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos transport->serverAngularVelocity = 0.0f; transport->hasServerVelocity = true; } + } else { + // Bootstrap velocity from mapped DBC path on first authoritative sample. + // This avoids "stalled at dock" when server sends sparse transport snapshots. + auto pathIt2 = paths_.find(transport->pathId); + if (pathIt2 != paths_.end()) { + const auto& path = pathIt2->second; + if (path.points.size() >= 2 && path.durationMs > 0) { + glm::vec3 local = position - transport->basePosition; + size_t bestIdx = 0; + float bestDistSq = std::numeric_limits::max(); + for (size_t i = 0; i < path.points.size(); ++i) { + glm::vec3 d = path.points[i].pos - local; + float distSq = glm::dot(d, d); + if (distSq < bestDistSq) { + bestDistSq = distSq; + bestIdx = i; + } + } + + size_t n = path.points.size(); + size_t nextIdx = (bestIdx + 1) % n; + uint32_t t0 = path.points[bestIdx].tMs; + uint32_t t1 = path.points[nextIdx].tMs; + if (nextIdx == 0 && t1 <= t0 && path.durationMs > 0) { + t1 = path.durationMs; + } + if (t1 <= t0 && n > 2) { + size_t prevIdx = (bestIdx + n - 1) % n; + t0 = path.points[prevIdx].tMs; + t1 = path.points[bestIdx].tMs; + glm::vec3 seg = path.points[bestIdx].pos - path.points[prevIdx].pos; + float dtSeg = static_cast(t1 - t0) / 1000.0f; + if (dtSeg > 0.001f) { + glm::vec3 v = seg / dtSeg; + float speed = glm::length(v); + constexpr float kMaxSpeed = 60.0f; + if (speed > kMaxSpeed) { + v *= (kMaxSpeed / speed); + } + transport->serverLinearVelocity = v; + transport->serverAngularVelocity = 0.0f; + transport->hasServerVelocity = true; + } + } else { + glm::vec3 seg = path.points[nextIdx].pos - path.points[bestIdx].pos; + float dtSeg = static_cast(t1 - t0) / 1000.0f; + if (dtSeg > 0.001f) { + glm::vec3 v = seg / dtSeg; + float speed = glm::length(v); + constexpr float kMaxSpeed = 60.0f; + if (speed > kMaxSpeed) { + v *= (kMaxSpeed / speed); + } + transport->serverLinearVelocity = v; + transport->serverAngularVelocity = 0.0f; + transport->hasServerVelocity = true; + } + } + + if (transport->hasServerVelocity) { + LOG_INFO("Transport 0x", std::hex, guid, std::dec, + " bootstrapped velocity from DBC path ", transport->pathId, + " v=(", transport->serverLinearVelocity.x, ", ", + transport->serverLinearVelocity.y, ", ", + transport->serverLinearVelocity.z, ")"); + } + } + } } updateTransformMatrices(*transport); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 5ce6ef0..203b9e8 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -50,6 +50,19 @@ std::optional selectHighestFloor(const std::optional& a, return best; } +std::optional selectClosestFloor(const std::optional& a, + const std::optional& b, + float refZ) { + if (a && b) { + float da = std::abs(*a - refZ); + float db = std::abs(*b - refZ); + return (da <= db) ? a : b; + } + if (a) return a; + if (b) return b; + return std::nullopt; +} + } // namespace CameraController::CameraController(Camera* cam) : camera(cam) { @@ -537,6 +550,20 @@ void CameraController::update(float deltaTime) { verticalVelocity = 0.0f; } + // Refresh inside-WMO state before collision/grounding so we don't use stale + // terrain-first caches while entering enclosed tunnel/building spaces. + if (wmoRenderer && !externalFollow_) { + bool prevInside = cachedInsideWMO; + bool prevInsideInterior = cachedInsideInteriorWMO; + cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr); + cachedInsideInteriorWMO = wmoRenderer->isInsideInteriorWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f); + if (cachedInsideWMO != prevInside || cachedInsideInteriorWMO != prevInsideInterior) { + hasCachedFloor_ = false; + hasCachedCamFloor = false; + cachedPivotLift_ = 0.0f; + } + } + // Sweep collisions in small steps to reduce tunneling through thin walls/floors. // Skip entirely when stationary to avoid wasting collision calls. // Use tighter steps when inside WMO for more precise collision. @@ -587,15 +614,27 @@ void CameraController::update(float deltaTime) { // 1. Center-only sample for terrain/WMO floor selection. // Using only the center prevents tunnel entrances from snapping // to terrain when offset samples miss the WMO floor geometry. - // Slope limit: reject surfaces too steep to walk (prevent clipping) - constexpr float MIN_WALKABLE_NORMAL = 0.7f; // ~45° max slope + // Slope limit: reject surfaces too steep to walk (prevent clipping). + // WMO tunnel/bridge ramps are often steeper than outdoor terrain ramps. + constexpr float MIN_WALKABLE_NORMAL_TERRAIN = 0.7f; // ~45° + constexpr float MIN_WALKABLE_NORMAL_WMO = 0.45f; // allow tunnel ramps std::optional groundH; + std::optional centerTerrainH; + std::optional centerWmoH; { // Collision cache: skip expensive checks if barely moved (15cm threshold) float distMoved = glm::length(glm::vec2(targetPos.x, targetPos.y) - glm::vec2(lastCollisionCheckPos_.x, lastCollisionCheckPos_.y)); bool useCached = hasCachedFloor_ && distMoved < COLLISION_CACHE_DISTANCE; + if (useCached) { + // Never trust cached ground while actively descending or when + // vertical drift from cached floor is meaningful. + float dzCached = std::abs(targetPos.z - cachedFloorHeight_); + if (verticalVelocity < -0.4f || dzCached > 0.35f) { + useCached = false; + } + } if (useCached) { groundH = cachedFloorHeight_; @@ -613,11 +652,52 @@ void CameraController::update(float deltaTime) { } // Reject steep WMO slopes - if (wmoH && wmoNormalZ < MIN_WALKABLE_NORMAL) { + float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN; + if (wmoH && wmoNormalZ < minWalkableWmo) { wmoH = std::nullopt; // Treat as unwalkable } + centerTerrainH = terrainH; + centerWmoH = wmoH; - groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); + // Guard against extremely bad WMO void ramps, but keep normal tunnel + // transitions valid. Only reject when the WMO sample is implausibly far + // below terrain and player is not already descending. + if (terrainH && wmoH) { + float terrainMinusWmo = *terrainH - *wmoH; + if (terrainMinusWmo > 12.0f && verticalVelocity > -8.0f) { + wmoH = std::nullopt; + centerWmoH = std::nullopt; + } + } + + if (cachedInsideWMO && wmoH) { + // Transition seam (e.g. tunnel mouths): if terrain is much higher than + // nearby WMO walkable floor, prefer the WMO floor so we can enter. + bool preferWmoAtSeam = false; + if (terrainH) { + float terrainAboveWmo = *terrainH - *wmoH; + float wmoDropFromPlayer = targetPos.z - *wmoH; + float playerVsTerrain = targetPos.z - *terrainH; + bool descendingIntoTunnel = (verticalVelocity < -1.0f) || (playerVsTerrain < -0.35f); + if (terrainAboveWmo > 1.2f && terrainAboveWmo < 8.0f && + wmoDropFromPlayer >= -0.4f && wmoDropFromPlayer < 1.8f && + *wmoH <= targetPos.z + stepUpBudget && + descendingIntoTunnel) { + preferWmoAtSeam = true; + } + } + if (preferWmoAtSeam) { + groundH = wmoH; + } else if (terrainH) { + // At tunnel seams where both exist, pick the one closest to current feet Z + // to avoid oscillating between top terrain and deep WMO floors. + groundH = selectClosestFloor(terrainH, wmoH, targetPos.z); + } else { + groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); + } + } else { + groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); + } // Update cache lastCollisionCheckPos_ = targetPos; @@ -630,6 +710,81 @@ void CameraController::update(float deltaTime) { } } + // Transition safety: if no reachable floor was selected, choose the higher + // of terrain/WMO center surfaces when it is still near the player. + // This avoids dropping into void gaps at terrain<->WMO seams. + if (!groundH) { + auto highestCenter = selectHighestFloor(centerTerrainH, centerWmoH, std::nullopt); + if (highestCenter) { + float dz = targetPos.z - *highestCenter; + // Keep this fallback narrow: only for WMO seam cases, or very short + // transient misses while still almost touching the last floor. + bool allowFallback = cachedInsideWMO || (noGroundTimer_ < 0.10f && dz < 0.6f); + if (allowFallback && dz >= -0.5f && dz < 2.0f) { + groundH = highestCenter; + } + } + } + + // Continuity guard only for WMO seam overlap: avoid instantly switching to a + // much lower floor sample at tunnel mouths (bad WMO ramp chains into void). + if (groundH && hasRealGround_ && cachedInsideWMO && !cachedInsideInteriorWMO) { + float dropFromLast = lastGroundZ - *groundH; + if (dropFromLast > 1.5f) { + if (centerTerrainH && *centerTerrainH > *groundH + 1.5f) { + groundH = centerTerrainH; + } + } + } + + // Seam stability: while overlapping WMO shells, cap how fast floor height can + // step downward in a single frame to avoid following bad ramp samples into void. + if (groundH && cachedInsideWMO && !cachedInsideInteriorWMO && lastGroundZ > 1.0f) { + float maxDropPerFrame = (verticalVelocity < -8.0f) ? 2.0f : 0.60f; + float minAllowed = lastGroundZ - maxDropPerFrame; + // Extra seam guard: outside interior groups, avoid accepting floors that + // are far below nearby terrain. Keeps shark-mouth transitions from + // following erroneous WMO ramps into void. + if (centerTerrainH) { + // Never let terrain-based seam guard push floor above current feet; + // it should only prevent excessive downward drops. + float terrainGuard = std::min(*centerTerrainH - 1.0f, targetPos.z - 0.15f); + minAllowed = std::max(minAllowed, terrainGuard); + } + if (*groundH < minAllowed) { + *groundH = minAllowed; + } + } + + // 1b. Multi-sample WMO floors when in/near WMO space to avoid + // falling through narrow board/plank gaps where center ray misses. + if (wmoRenderer && cachedInsideWMO) { + constexpr float WMO_FOOTPRINT = 0.35f; + const glm::vec2 wmoOffsets[] = { + {0.0f, 0.0f}, + { WMO_FOOTPRINT, 0.0f}, {-WMO_FOOTPRINT, 0.0f}, + {0.0f, WMO_FOOTPRINT}, {0.0f, -WMO_FOOTPRINT} + }; + + float wmoProbeZ = std::max(targetPos.z, lastGroundZ) + stepUpBudget + 0.6f; + float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN; + + for (const auto& o : wmoOffsets) { + float nz = 1.0f; + auto wh = wmoRenderer->getFloorHeight(targetPos.x + o.x, targetPos.y + o.y, wmoProbeZ, &nz); + if (!wh) continue; + if (nz < minWalkableWmo) continue; + + // Keep to nearby, walkable steps only. + if (*wh > targetPos.z + stepUpBudget) continue; + if (*wh < targetPos.z - 2.5f) continue; + + if (!groundH || *wh > *groundH) { + groundH = wh; + } + } + } + // 2. Multi-sample for M2 objects (rugs, planks, bridges, ships) — // these are narrow and need offset probes to detect reliably. if (m2Renderer && !externalFollow_) { @@ -646,7 +801,7 @@ void CameraController::update(float deltaTime) { targetPos.x + o.x, targetPos.y + o.y, m2ProbeZ, &m2NormalZ); // Reject steep M2 slopes - if (m2H && m2NormalZ < MIN_WALKABLE_NORMAL) { + if (m2H && m2NormalZ < MIN_WALKABLE_NORMAL_TERRAIN) { continue; // Skip unwalkable M2 surface } @@ -676,7 +831,8 @@ void CameraController::update(float deltaTime) { // 3. Was grounded + ground is close (grace for slopes) bool nearGround = (dz >= 0.0f && dz <= stepUp); bool airFalling = (!grounded && verticalVelocity < -5.0f); - bool slopeGrace = (grounded && dz >= -1.0f && dz <= stepUp * 2.0f); + bool slopeGrace = (grounded && verticalVelocity > -1.0f && + dz >= -0.25f && dz <= stepUp * 1.5f); if (dz >= -fallCatch && (nearGround || airFalling || slopeGrace)) { targetPos.z = *groundH; @@ -691,18 +847,13 @@ void CameraController::update(float deltaTime) { hasRealGround_ = false; noGroundTimer_ += deltaTime; - if (noGroundTimer_ < NO_GROUND_GRACE) { - // Grace should prevent instant falling at seams, - // but should NEVER pull you down or cancel a jump. - targetPos.z = std::max(targetPos.z, lastGroundZ); - - // Only zero velocity if we're not moving upward. - if (verticalVelocity <= 0.0f) { - verticalVelocity = 0.0f; - grounded = true; - } else { - grounded = false; // jumping upward: don't "stick" to ghost ground - } + float dropFromLastGround = lastGroundZ - targetPos.z; + bool seamSizedGap = dropFromLastGround <= 0.35f; + if (noGroundTimer_ < NO_GROUND_GRACE && seamSizedGap) { + // Micro-gap grace only: keep continuity for tiny seam misses, + // but never convert air into persistent ground. + targetPos.z = std::max(targetPos.z, lastGroundZ - 0.10f); + grounded = false; } else { grounded = false; } @@ -738,7 +889,7 @@ void CameraController::update(float deltaTime) { // Pivot point at upper chest/neck. float mountedOffset = mounted_ ? mountHeightOffset_ : 0.0f; float pivotLift = 0.0f; - if (terrainManager && !externalFollow_) { + if (terrainManager && !externalFollow_ && !cachedInsideInteriorWMO) { float moved = glm::length(glm::vec2(targetPos.x - lastPivotLiftQueryPos_.x, targetPos.y - lastPivotLiftQueryPos_.y)); float distDelta = std::abs(currentDistance - lastPivotLiftDistance_); @@ -772,6 +923,10 @@ void CameraController::update(float deltaTime) { cachedPivotLift_ = desiredLift; } pivotLift = cachedPivotLift_; + } else if (cachedInsideInteriorWMO) { + // Inside WMO volumes (including tunnel/cave shells): terrain-above samples + // are not relevant for camera pivoting. + cachedPivotLift_ = 0.0f; } glm::vec3 pivot = targetPos + glm::vec3(0.0f, 0.0f, PIVOT_HEIGHT + mountedOffset + pivotLift); @@ -787,26 +942,13 @@ void CameraController::update(float deltaTime) { if (wmoRenderer) { float distFromLastCheck = glm::length(targetPos - lastInsideWMOCheckPos); if (++insideWMOCheckCounter >= 10 || distFromLastCheck > 2.0f) { - cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr); wmoRenderer->updateActiveGroup(targetPos.x, targetPos.y, targetPos.z + 1.0f); insideWMOCheckCounter = 0; lastInsideWMOCheckPos = targetPos; } - // Constrain zoom if there's a ceiling/upper floor above player - // Raycast upward from player to find ceiling, limit camera distance - glm::vec3 upRayOrigin = targetPos; - glm::vec3 upRayDir(0.0f, 0.0f, 1.0f); - float ceilingDist = wmoRenderer->raycastBoundingBoxes(upRayOrigin, upRayDir, 20.0f); - if (ceilingDist < 20.0f) { - // Found ceiling above — limit zoom to prevent camera from going through it - // Camera is behind player by currentDistance, at an angle - // Approximate: if ceiling is N units above, limit zoom to ~N units - float maxZoomForCeiling = std::max(1.5f, ceilingDist * 0.7f); - if (currentDistance > maxZoomForCeiling) { - currentDistance = maxZoomForCeiling; - } - } + // Do not clamp zoom target by ceiling checks. First-person should always + // be reachable; occlusion handling below will resolve camera placement safely. } // ===== Camera collision (sphere sweep approximation) ===== @@ -866,8 +1008,11 @@ void CameraController::update(float deltaTime) { // ===== Final floor clearance check ===== // Use WMO-aware floor so the camera doesn't pop above tunnels/caves. constexpr float MIN_FLOOR_CLEARANCE = 0.35f; - { - auto camTerrainH = getTerrainFloorAt(smoothedCamPos.x, smoothedCamPos.y); + if (!cachedInsideWMO) { + std::optional camTerrainH; + if (!cachedInsideInteriorWMO) { + camTerrainH = getTerrainFloorAt(smoothedCamPos.x, smoothedCamPos.y); + } std::optional camWmoH; if (wmoRenderer) { // Skip expensive WMO floor query if camera barely moved @@ -876,15 +1021,45 @@ void CameraController::update(float deltaTime) { if (camDelta < 0.3f && hasCachedCamFloor) { camWmoH = cachedCamWmoFloor; } else { + float camFloorProbeZ = smoothedCamPos.z; + if (cachedInsideInteriorWMO) { + // Inside tunnels/buildings, probe near player height so roof + // triangles above the camera don't get treated as floor. + camFloorProbeZ = std::min(smoothedCamPos.z, targetPos.z + 1.0f); + } camWmoH = wmoRenderer->getFloorHeight( - smoothedCamPos.x, smoothedCamPos.y, smoothedCamPos.z); + smoothedCamPos.x, smoothedCamPos.y, camFloorProbeZ); + + if (cachedInsideInteriorWMO && camWmoH) { + // Never let camera floor clamp latch to tunnel ceilings / upper decks. + float maxValidIndoorFloor = targetPos.z + 0.9f; + if (*camWmoH > maxValidIndoorFloor) { + camWmoH = std::nullopt; + } + } cachedCamWmoFloor = camWmoH; hasCachedCamFloor = true; lastCamFloorQueryPos = smoothedCamPos; } } - auto camFloorH = selectReachableFloor( - camTerrainH, camWmoH, smoothedCamPos.z, 0.5f); + // When camera/character are inside a WMO, force WMO floor usage for camera + // clearance to avoid snapping toward terrain above enclosed tunnels/caves. + std::optional camFloorH; + if (cachedInsideWMO && camWmoH && camTerrainH) { + // Transition seam: avoid terrain-above clamp near tunnel entrances. + float camDropFromPlayer = targetPos.z - *camWmoH; + if ((*camTerrainH - *camWmoH) > 1.2f && + (*camTerrainH - *camWmoH) < 8.0f && + camDropFromPlayer >= -0.4f && + camDropFromPlayer < 1.8f) { + camFloorH = camWmoH; + } else { + camFloorH = selectClosestFloor(camTerrainH, camWmoH, smoothedCamPos.z); + } + } else { + camFloorH = selectReachableFloor( + camTerrainH, camWmoH, smoothedCamPos.z, 0.5f); + } if (camFloorH && smoothedCamPos.z < *camFloorH + MIN_FLOOR_CLEARANCE) { smoothedCamPos.z = *camFloorH + MIN_FLOOR_CLEARANCE; } diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index cf68b3b..8ec2514 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1405,6 +1405,10 @@ static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { } void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) { + if (spatialIndexDirty_) { + rebuildSpatialIndex(); + } + float dtMs = deltaTime * 1000.0f; // Cache camera state for frustum-culling bone computation @@ -2485,7 +2489,7 @@ void M2Renderer::setInstancePosition(uint32_t instanceId, const glm::vec3& posit getTightCollisionBounds(modelIt->second, localMin, localMax); transformAABB(inst.modelMatrix, localMin, localMax, inst.worldBoundsMin, inst.worldBoundsMax); } - rebuildSpatialIndex(); + spatialIndexDirty_ = true; } void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) { @@ -2507,7 +2511,7 @@ void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tran getTightCollisionBounds(modelIt->second, localMin, localMax); transformAABB(inst.modelMatrix, localMin, localMax, inst.worldBoundsMin, inst.worldBoundsMax); } - rebuildSpatialIndex(); + spatialIndexDirty_ = true; } void M2Renderer::removeInstance(uint32_t instanceId) { @@ -2595,6 +2599,7 @@ void M2Renderer::rebuildSpatialIndex() { } } } + spatialIndexDirty_ = false; } void M2Renderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& queryMax, diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 60e08df..2a371a3 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -675,6 +675,11 @@ void WMORenderer::removeInstance(uint32_t instanceId) { auto it = std::find_if(instances.begin(), instances.end(), [instanceId](const WMOInstance& inst) { return inst.id == instanceId; }); if (it != instances.end()) { + if (m2Renderer_) { + for (const auto& doodad : it->doodads) { + m2Renderer_->removeInstance(doodad.m2InstanceId); + } + } instances.erase(it); rebuildSpatialIndex(); core::Logger::getInstance().debug("Removed WMO instance ", instanceId); @@ -687,6 +692,17 @@ void WMORenderer::removeInstances(const std::vector& instanceIds) { } std::unordered_set toRemove(instanceIds.begin(), instanceIds.end()); + if (m2Renderer_) { + for (const auto& inst : instances) { + if (toRemove.find(inst.id) == toRemove.end()) { + continue; + } + for (const auto& doodad : inst.doodads) { + m2Renderer_->removeInstance(doodad.m2InstanceId); + } + } + } + const size_t oldSize = instances.size(); instances.erase(std::remove_if(instances.begin(), instances.end(), [&toRemove](const WMOInstance& inst) { @@ -702,6 +718,13 @@ void WMORenderer::removeInstances(const std::vector& instanceIds) { } void WMORenderer::clearInstances() { + if (m2Renderer_) { + for (const auto& inst : instances) { + for (const auto& doodad : inst.doodads) { + m2Renderer_->removeInstance(doodad.m2InstanceId); + } + } + } instances.clear(); spatialGrid.clear(); instanceIndexById.clear();