From d03ff5ee4ce3f62eba8f37d780e209e43c1606c9 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 3 Feb 2026 15:17:54 -0800 Subject: [PATCH] Tune collision feel and align M2 movement/camera behavior --- include/rendering/m2_renderer.hpp | 9 ++ src/rendering/camera_controller.cpp | 167 ++++++++++++++++++++---- src/rendering/m2_renderer.cpp | 190 +++++++++++++++++++--------- src/rendering/wmo_renderer.cpp | 38 ++++-- 4 files changed, 308 insertions(+), 96 deletions(-) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index a7f9acab..8373c7d5 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace wowee { @@ -144,6 +145,14 @@ public: bool checkCollision(const glm::vec3& from, const glm::vec3& to, glm::vec3& adjustedPos, float playerRadius = 0.5f) const; + /** + * Approximate top surface height for standing/jumping on doodads. + * @param glX World X + * @param glY World Y + * @param glZ Query/reference Z (used to ignore unreachable tops) + */ + std::optional getFloorHeight(float glX, float glY, float glZ) const; + /** * Raycast against M2 bounding boxes for camera collision * @param origin Ray origin (e.g., character head position) diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index ddf75de6..10c79eec 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -165,6 +165,41 @@ void CameraController::update(float deltaTime) { if (thirdPerson && followTarget) { // Move the follow target (character position) instead of the camera glm::vec3 targetPos = *followTarget; + auto clampMoveAgainstWMO = [&](const glm::vec3& fromFeet, glm::vec3& toFeet) { + if (!wmoRenderer) return; + glm::vec3 move = toFeet - fromFeet; + move.z = 0.0f; + float moveDist = glm::length(glm::vec2(move)); + if (moveDist < 0.001f) return; + + glm::vec3 dir = glm::normalize(move); + glm::vec3 perp(-dir.y, dir.x, 0.0f); + constexpr float BODY_RADIUS = 0.38f; + constexpr float SAFETY = 0.08f; + constexpr float HEIGHTS[3] = {0.4f, 1.0f, 1.6f}; + const glm::vec3 probes[3] = { + fromFeet, + fromFeet + perp * BODY_RADIUS, + fromFeet - perp * BODY_RADIUS + }; + + float bestHit = moveDist + 1.0f; + for (const glm::vec3& probeBase : probes) { + for (float h : HEIGHTS) { + glm::vec3 origin = probeBase + glm::vec3(0.0f, 0.0f, h); + float hit = wmoRenderer->raycastBoundingBoxes(origin, dir, moveDist + SAFETY); + if (hit < bestHit) { + bestHit = hit; + } + } + } + + if (bestHit < moveDist + SAFETY) { + float allowed = std::max(0.0f, bestHit - SAFETY); + toFeet.x = fromFeet.x + dir.x * allowed; + toFeet.y = fromFeet.y + dir.y * allowed; + } + }; // Check for water at current position std::optional waterH; @@ -234,6 +269,7 @@ void CameraController::update(float deltaTime) { for (int i = 0; i < sweepSteps; i++) { glm::vec3 candidate = stepPos + stepDelta; + clampMoveAgainstWMO(stepPos, candidate); if (wmoRenderer) { glm::vec3 adjusted; @@ -269,13 +305,21 @@ void CameraController::update(float deltaTime) { auto getGroundAt = [&](float x, float y) -> std::optional { std::optional terrainH; std::optional wmoH; + std::optional m2H; if (terrainManager) { terrainH = terrainManager->getHeightAt(x, y); } if (wmoRenderer) { wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + 5.0f); } - return selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f); + if (m2Renderer) { + m2H = m2Renderer->getFloorHeight(x, y, targetPos.z); + } + auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f); + if (m2H && *m2H <= targetPos.z + 1.0f && (!base || *m2H > *base)) { + base = m2H; + } + return base; }; // Get ground height at target position @@ -340,17 +384,38 @@ void CameraController::update(float deltaTime) { // Ground the character to terrain or WMO floor { - std::optional terrainH; - std::optional wmoH; + auto sampleGround = [&](float x, float y) -> std::optional { + std::optional terrainH; + std::optional wmoH; + std::optional m2H; + if (terrainManager) { + terrainH = terrainManager->getHeightAt(x, y); + } + if (wmoRenderer) { + wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + eyeHeight); + } + if (m2Renderer) { + m2H = m2Renderer->getFloorHeight(x, y, targetPos.z); + } + auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f); + if (m2H && *m2H <= targetPos.z + 1.0f && (!base || *m2H > *base)) { + base = m2H; + } + return base; + }; - if (terrainManager) { - terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y); + // Sample center + small footprint to avoid slipping through narrow floor pieces. + std::optional groundH; + constexpr float FOOTPRINT = 0.28f; + const glm::vec2 offsets[] = { + {0.0f, 0.0f}, {FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f}, {0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT} + }; + for (const auto& o : offsets) { + auto h = sampleGround(targetPos.x + o.x, targetPos.y + o.y); + if (h && (!groundH || *h > *groundH)) { + groundH = h; + } } - if (wmoRenderer) { - wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, targetPos.z + eyeHeight); - } - - std::optional groundH = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f); if (groundH) { float groundDiff = *groundH - lastGroundZ; @@ -417,13 +482,7 @@ void CameraController::update(float deltaTime) { } } - // Raycast against M2 bounding boxes - if (m2Renderer && collisionDistance > MIN_DISTANCE) { - float m2Hit = m2Renderer->raycastBoundingBoxes(pivot, camDir, collisionDistance); - if (m2Hit < collisionDistance) { - collisionDistance = std::max(MIN_DISTANCE, m2Hit - CAM_SPHERE_RADIUS - CAM_EPSILON); - } - } + // Intentionally ignore M2 doodads for camera collision to match WoW feel. // Check floor collision along the camera path // Sample a few points to find where camera would go underground @@ -479,6 +538,41 @@ void CameraController::update(float deltaTime) { } else { // Free-fly camera mode (original behavior) glm::vec3 newPos = camera->getPosition(); + auto clampMoveAgainstWMO = [&](const glm::vec3& fromFeet, glm::vec3& toFeet) { + if (!wmoRenderer) return; + glm::vec3 move = toFeet - fromFeet; + move.z = 0.0f; + float moveDist = glm::length(glm::vec2(move)); + if (moveDist < 0.001f) return; + + glm::vec3 dir = glm::normalize(move); + glm::vec3 perp(-dir.y, dir.x, 0.0f); + constexpr float BODY_RADIUS = 0.38f; + constexpr float SAFETY = 0.08f; + constexpr float HEIGHTS[3] = {0.4f, 1.0f, 1.6f}; + const glm::vec3 probes[3] = { + fromFeet, + fromFeet + perp * BODY_RADIUS, + fromFeet - perp * BODY_RADIUS + }; + + float bestHit = moveDist + 1.0f; + for (const glm::vec3& probeBase : probes) { + for (float h : HEIGHTS) { + glm::vec3 origin = probeBase + glm::vec3(0.0f, 0.0f, h); + float hit = wmoRenderer->raycastBoundingBoxes(origin, dir, moveDist + SAFETY); + if (hit < bestHit) { + bestHit = hit; + } + } + } + + if (bestHit < moveDist + SAFETY) { + float allowed = std::max(0.0f, bestHit - SAFETY); + toFeet.x = fromFeet.x + dir.x * allowed; + toFeet.y = fromFeet.y + dir.y * allowed; + } + }; float feetZ = newPos.z - eyeHeight; // Check for water at feet position @@ -546,6 +640,7 @@ void CameraController::update(float deltaTime) { for (int i = 0; i < sweepSteps; i++) { glm::vec3 candidate = stepPos + stepDelta; + clampMoveAgainstWMO(stepPos, candidate); glm::vec3 adjusted; if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { candidate = adjusted; @@ -558,17 +653,37 @@ void CameraController::update(float deltaTime) { // Ground to terrain or WMO floor { - std::optional terrainH; - std::optional wmoH; + auto sampleGround = [&](float x, float y) -> std::optional { + std::optional terrainH; + std::optional wmoH; + std::optional m2H; + if (terrainManager) { + terrainH = terrainManager->getHeightAt(x, y); + } + if (wmoRenderer) { + wmoH = wmoRenderer->getFloorHeight(x, y, newPos.z); + } + if (m2Renderer) { + m2H = m2Renderer->getFloorHeight(x, y, newPos.z - eyeHeight); + } + auto base = selectReachableFloor(terrainH, wmoH, newPos.z - eyeHeight, 1.0f); + if (m2H && *m2H <= (newPos.z - eyeHeight) + 1.0f && (!base || *m2H > *base)) { + base = m2H; + } + return base; + }; - if (terrainManager) { - terrainH = terrainManager->getHeightAt(newPos.x, newPos.y); + std::optional groundH; + constexpr float FOOTPRINT = 0.28f; + const glm::vec2 offsets[] = { + {0.0f, 0.0f}, {FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f}, {0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT} + }; + for (const auto& o : offsets) { + auto h = sampleGround(newPos.x + o.x, newPos.y + o.y); + if (h && (!groundH || *h > *groundH)) { + groundH = h; + } } - if (wmoRenderer) { - wmoH = wmoRenderer->getFloorHeight(newPos.x, newPos.y, newPos.z); - } - - std::optional groundH = selectReachableFloor(terrainH, wmoH, newPos.z - eyeHeight, 1.0f); if (groundH) { lastGroundZ = *groundH; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 61039c3b..8cec777d 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -9,10 +9,29 @@ #include #include #include +#include +#include namespace wowee { namespace rendering { +namespace { + +void getTightCollisionBounds(const M2ModelGPU& model, glm::vec3& outMin, glm::vec3& outMax) { + glm::vec3 center = (model.boundMin + model.boundMax) * 0.5f; + glm::vec3 half = (model.boundMax - model.boundMin) * 0.5f; + + // Tighten footprint to reduce overly large object blockers. + half.x *= 0.60f; + half.y *= 0.60f; + half.z *= 0.90f; + + outMin = center - half; + outMax = center + half; +} + +} // namespace + void M2Instance::updateModelMatrix() { modelMatrix = glm::mat4(1.0f); modelMatrix = glm::translate(modelMatrix, position); @@ -175,8 +194,16 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { M2ModelGPU gpuModel; gpuModel.name = model.name; - gpuModel.boundMin = model.boundMin; - gpuModel.boundMax = model.boundMax; + // Use tight bounds from actual vertices for collision/camera occlusion. + // Header bounds in some M2s are overly conservative. + glm::vec3 tightMin( std::numeric_limits::max()); + glm::vec3 tightMax(-std::numeric_limits::max()); + for (const auto& v : model.vertices) { + tightMin = glm::min(tightMin, v.position); + tightMax = glm::max(tightMax, v.position); + } + gpuModel.boundMin = tightMin; + gpuModel.boundMax = tightMax; gpuModel.boundRadius = model.boundRadius; gpuModel.indexCount = static_cast(model.indices.size()); gpuModel.vertexCount = static_cast(model.vertices.size()); @@ -532,56 +559,102 @@ uint32_t M2Renderer::getTotalTriangleCount() const { return total; } +std::optional M2Renderer::getFloorHeight(float glX, float glY, float glZ) const { + std::optional bestFloor; + + for (const auto& instance : instances) { + auto it = models.find(instance.modelId); + if (it == models.end()) continue; + if (instance.scale <= 0.001f) continue; + + const M2ModelGPU& model = it->second; + glm::vec3 localMin, localMax; + getTightCollisionBounds(model, localMin, localMax); + + glm::mat4 invModel = glm::inverse(instance.modelMatrix); + glm::vec3 localPos = glm::vec3(invModel * glm::vec4(glX, glY, glZ, 1.0f)); + + // Must be within doodad footprint in local XY. + if (localPos.x < localMin.x || localPos.x > localMax.x || + localPos.y < localMin.y || localPos.y > localMax.y) { + continue; + } + + // Construct "top" point at queried XY in local space, then transform back. + glm::vec3 localTop(localPos.x, localPos.y, localMax.z); + glm::vec3 worldTop = glm::vec3(instance.modelMatrix * glm::vec4(localTop, 1.0f)); + + // Reachability filter: only consider floors slightly above current feet. + if (worldTop.z > glZ + 1.0f) continue; + + if (!bestFloor || worldTop.z > *bestFloor) { + bestFloor = worldTop.z; + } + } + + return bestFloor; +} + bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to, glm::vec3& adjustedPos, float playerRadius) const { + (void)from; adjustedPos = to; bool collided = false; - // Check against all M2 instances using their bounding boxes + // Check against all M2 instances in local space (rotation-aware). for (const auto& instance : instances) { auto it = models.find(instance.modelId); if (it == models.end()) continue; const M2ModelGPU& model = it->second; + if (instance.scale <= 0.001f) continue; - // Transform model bounds to world space (approximate with scaled AABB) - glm::vec3 worldMin = instance.position + model.boundMin * instance.scale; - glm::vec3 worldMax = instance.position + model.boundMax * instance.scale; + glm::mat4 invModel = glm::inverse(instance.modelMatrix); + glm::vec3 localPos = glm::vec3(invModel * glm::vec4(adjustedPos, 1.0f)); + float localRadius = playerRadius / instance.scale; - // Ensure min/max are correct - glm::vec3 actualMin = glm::min(worldMin, worldMax); - glm::vec3 actualMax = glm::max(worldMin, worldMax); + glm::vec3 localMin, localMax; + getTightCollisionBounds(model, localMin, localMax); + localMin -= glm::vec3(localRadius); + localMax += glm::vec3(localRadius); - // Expand bounds by player radius - actualMin -= glm::vec3(playerRadius); - actualMax += glm::vec3(playerRadius); - - // Check if player position is inside expanded bounds (XY only for walking) - if (adjustedPos.x >= actualMin.x && adjustedPos.x <= actualMax.x && - adjustedPos.y >= actualMin.y && adjustedPos.y <= actualMax.y && - adjustedPos.z >= actualMin.z && adjustedPos.z <= actualMax.z) { - - // Push player out of the object - // Find the shortest push direction (XY only) - float pushLeft = adjustedPos.x - actualMin.x; - float pushRight = actualMax.x - adjustedPos.x; - float pushBack = adjustedPos.y - actualMin.y; - float pushFront = actualMax.y - adjustedPos.y; - - float minPush = std::min({pushLeft, pushRight, pushBack, pushFront}); - - if (minPush == pushLeft) { - adjustedPos.x = actualMin.x - 0.01f; - } else if (minPush == pushRight) { - adjustedPos.x = actualMax.x + 0.01f; - } else if (minPush == pushBack) { - adjustedPos.y = actualMin.y - 0.01f; - } else { - adjustedPos.y = actualMax.y + 0.01f; - } - - collided = true; + // Feet-based vertical overlap test: ignore objects fully above/below us. + constexpr float PLAYER_HEIGHT = 2.0f; + if (localPos.z + PLAYER_HEIGHT < localMin.z || localPos.z > localMax.z) { + continue; } + + if (localPos.x < localMin.x || localPos.x > localMax.x || + localPos.y < localMin.y || localPos.y > localMax.y) { + continue; + } + + float pushLeft = localPos.x - localMin.x; + float pushRight = localMax.x - localPos.x; + float pushBack = localPos.y - localMin.y; + float pushFront = localMax.y - localPos.y; + + float minPush = std::min({pushLeft, pushRight, pushBack, pushFront}); + // Soft pushback to avoid large snapping when grazing doodads. + float pushAmount = std::min(0.05f, std::max(0.0f, minPush * 0.30f)); + if (pushAmount <= 0.0f) { + continue; + } + glm::vec3 localPush(0.0f); + if (minPush == pushLeft) { + localPush.x = -pushAmount; + } else if (minPush == pushRight) { + localPush.x = pushAmount; + } else if (minPush == pushBack) { + localPush.y = -pushAmount; + } else { + localPush.y = pushAmount; + } + + glm::vec3 worldPush = glm::vec3(instance.modelMatrix * glm::vec4(localPush, 0.0f)); + adjustedPos.x += worldPush.x; + adjustedPos.y += worldPush.y; + collided = true; } return collided; @@ -595,33 +668,36 @@ float M2Renderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3& if (it == models.end()) continue; const M2ModelGPU& model = it->second; + glm::vec3 localMin, localMax; + getTightCollisionBounds(model, localMin, localMax); + // Skip tiny doodads for camera occlusion; they cause jitter and false hits. + glm::vec3 extents = (localMax - localMin) * instance.scale; + if (glm::length(extents) < 0.75f) continue; - // Transform model bounds to world space (approximate with scaled AABB) - glm::vec3 worldMin = instance.position + model.boundMin * instance.scale; - glm::vec3 worldMax = instance.position + model.boundMax * instance.scale; + glm::mat4 invModel = glm::inverse(instance.modelMatrix); + glm::vec3 localOrigin = glm::vec3(invModel * glm::vec4(origin, 1.0f)); + glm::vec3 localDir = glm::normalize(glm::vec3(invModel * glm::vec4(direction, 0.0f))); + if (!std::isfinite(localDir.x) || !std::isfinite(localDir.y) || !std::isfinite(localDir.z)) { + continue; + } - // Ensure min/max are correct - glm::vec3 actualMin = glm::min(worldMin, worldMax); - glm::vec3 actualMax = glm::max(worldMin, worldMax); - - // Ray-AABB intersection (slab method) - glm::vec3 invDir = 1.0f / direction; - glm::vec3 tMin = (actualMin - origin) * invDir; - glm::vec3 tMax = (actualMax - origin) * invDir; - - // Handle negative direction components + // Local-space AABB slab intersection. + glm::vec3 invDir = 1.0f / localDir; + glm::vec3 tMin = (localMin - localOrigin) * invDir; + glm::vec3 tMax = (localMax - localOrigin) * invDir; glm::vec3 t1 = glm::min(tMin, tMax); glm::vec3 t2 = glm::max(tMin, tMax); float tNear = std::max({t1.x, t1.y, t1.z}); float tFar = std::min({t2.x, t2.y, t2.z}); + if (tNear > tFar || tFar <= 0.0f) continue; - // Check if ray intersects the box - if (tNear <= tFar && tFar > 0.0f) { - float hitDist = tNear > 0.0f ? tNear : tFar; - if (hitDist > 0.0f && hitDist < closestHit) { - closestHit = hitDist; - } + float tHit = tNear > 0.0f ? tNear : tFar; + glm::vec3 localHit = localOrigin + localDir * tHit; + glm::vec3 worldHit = glm::vec3(instance.modelMatrix * glm::vec4(localHit, 1.0f)); + float worldDist = glm::length(worldHit - origin); + if (worldDist > 0.0f && worldDist < closestHit) { + closestHit = worldDist; } } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 5cd8a7f0..188ae6ea 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -884,6 +884,9 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, // Push player away from wall (horizontal only) float pushDist = PLAYER_RADIUS - absPlaneDist; if (pushDist > 0.0f) { + // Soft pushback avoids hard side-snaps when skimming walls. + pushDist = std::min(0.06f, pushDist * 0.30f); + if (pushDist <= 0.0f) continue; float sign = planeDist > 0.0f ? 1.0f : -1.0f; glm::vec3 pushLocal = normal * sign * pushDist; @@ -944,22 +947,31 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3 glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(direction, 0.0f))); for (const auto& group : model.groups) { - // Ray-AABB intersection (slab method) - glm::vec3 tMin = (group.boundingBoxMin - localOrigin) / localDir; - glm::vec3 tMax = (group.boundingBoxMax - localOrigin) / localDir; + // Broad-phase cull with local AABB first. + if (!rayIntersectsAABB(localOrigin, localDir, group.boundingBoxMin, group.boundingBoxMax)) { + continue; + } - // Handle negative direction components - glm::vec3 t1 = glm::min(tMin, tMax); - glm::vec3 t2 = glm::max(tMin, tMax); + // Narrow-phase: triangle raycast for accurate camera collision. + const auto& verts = group.collisionVertices; + const auto& indices = group.collisionIndices; + for (size_t i = 0; i + 2 < indices.size(); i += 3) { + const glm::vec3& v0 = verts[indices[i]]; + const glm::vec3& v1 = verts[indices[i + 1]]; + const glm::vec3& v2 = verts[indices[i + 2]]; - float tNear = std::max({t1.x, t1.y, t1.z}); - float tFar = std::min({t2.x, t2.y, t2.z}); + float t = rayTriangleIntersect(localOrigin, localDir, v0, v1, v2); + if (t <= 0.0f) { + // Two-sided collision. + t = rayTriangleIntersect(localOrigin, localDir, v0, v2, v1); + } + if (t <= 0.0f) continue; - // Check if ray intersects the box - if (tNear <= tFar && tFar > 0.0f) { - float hitDist = tNear > 0.0f ? tNear : tFar; - if (hitDist > 0.0f && hitDist < closestHit) { - closestHit = hitDist; + glm::vec3 localHit = localOrigin + localDir * t; + glm::vec3 worldHit = glm::vec3(instance.modelMatrix * glm::vec4(localHit, 1.0f)); + float worldDist = glm::length(worldHit - origin); + if (worldDist > 0.0f && worldDist < closestHit && worldDist <= maxDistance) { + closestHit = worldDist; } } }