Fix transport sync and stabilize WMO/tunnel grounding

This commit is contained in:
Kelsi
2026-02-12 00:04:53 -08:00
parent 8bf63b1f06
commit a0f8120157
9 changed files with 637 additions and 94 deletions

View File

@@ -16,6 +16,7 @@
#include <unordered_map>
#include <unordered_set>
#include <map>
#include <optional>
namespace wowee::game {
class TransportManager;
@@ -468,6 +469,11 @@ public:
using GameObjectSpawnCallback = std::function<void(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation)>;
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(uint64_t guid, float x, float y, float z, float orientation)>;
void setGameObjectMoveCallback(GameObjectMoveCallback cb) { gameObjectMoveCallback_ = std::move(cb); }
// GameObject despawn callback (online mode - triggered when gameobject leaves view)
using GameObjectDespawnCallback = std::function<void(uint64_t guid)>;
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<network::WorldSocket> 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<uint64_t, TransportAttachment> transportAttachments_;
std::unordered_set<uint64_t> transportGuids_; // GUIDs of known transport GameObjects
std::unordered_set<uint64_t> 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> transportManager_; // Transport movement manager

View File

@@ -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);

View File

@@ -430,6 +430,7 @@ private:
// Animation update buffers (avoid per-frame allocation)
std::vector<size_t> boneWorkIndices_; // Reused each frame
std::vector<std::future<void>> animFutures_; // Reused each frame
bool spatialIndexDirty_ = false;
// Smoke particle system
std::vector<SmokeParticle> smokeParticles;

View File

@@ -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<glm::vec3> 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<glm::vec3> 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<uint32_t>(std::hash<std::string>{}(doodadTemplate.m2Path));
auto m2Data = assetManager->readFile(doodadTemplate.m2Path);

View File

@@ -8,6 +8,7 @@
#include "pipeline/asset_manager.hpp"
#include "pipeline/dbc_loader.hpp"
#include "core/logger.hpp"
#include <glm/gtx/quaternion.hpp>
#include <algorithm>
#include <cmath>
#include <cctype>
@@ -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<uint32_t>(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<uint64_t> 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_);

View File

@@ -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<float>::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<float>(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<float>(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);

View File

@@ -50,6 +50,19 @@ std::optional<float> selectHighestFloor(const std::optional<float>& a,
return best;
}
std::optional<float> selectClosestFloor(const std::optional<float>& a,
const std::optional<float>& 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<float> groundH;
std::optional<float> centerTerrainH;
std::optional<float> 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<float> camTerrainH;
if (!cachedInsideInteriorWMO) {
camTerrainH = getTerrainFloorAt(smoothedCamPos.x, smoothedCamPos.y);
}
std::optional<float> 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<float> 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;
}

View File

@@ -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,

View File

@@ -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<uint32_t>& instanceIds) {
}
std::unordered_set<uint32_t> 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<uint32_t>& 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();