From f8aba30f2dc704da81180a110c3ca04f39e5b021 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 8 Feb 2026 17:38:30 -0800 Subject: [PATCH] Fix WMO ramp/stair clipping with WoW-style floor snap and collision fixes Remove active group fast path from getFloorHeight to fix bridge clipping. Replace ground smoothing with immediate step-up snap (WoW-style: snap up, smooth down). Accept upward Z from wall collision at all call sites. Skip floor-like surfaces (absNz >= 0.45) in wall collision to prevent false wall hits on ramps. Increase getFloorHeight allowAbove from 0.5 to 2.0 for ramp reacquisition. Prefer highest reachable surface in floor selection. --- include/rendering/camera_controller.hpp | 5 + include/rendering/wmo_renderer.hpp | 70 ++++- src/rendering/camera_controller.cpp | 220 ++++---------- src/rendering/wmo_renderer.cpp | 383 +++++++++++++++++++----- 4 files changed, 425 insertions(+), 253 deletions(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 3d05c1b3..d0312137 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -161,6 +161,11 @@ private: int insideWMOCheckCounter = 0; glm::vec3 lastInsideWMOCheckPos = glm::vec3(0.0f); + // Cached camera WMO floor query (skip if camera moved < 0.3 units) + glm::vec3 lastCamFloorQueryPos = glm::vec3(0.0f); + std::optional cachedCamWmoFloor; + bool hasCachedCamFloor = false; + // Swimming bool swimming = false; bool wasSwimming = false; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index d176423b..3e5d7e88 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -190,9 +190,11 @@ public: void renderShadow(const glm::mat4& lightView, const glm::mat4& lightProj, Shader& shadowShader); /** - * Get floor height at a GL position via ray-triangle intersection + * Get floor height at a GL position via ray-triangle intersection. + * @param outNormalZ If not null, receives the Z component of the floor surface normal + * (1.0 = flat, 0.0 = vertical). Useful for slope walkability checks. */ - std::optional getFloorHeight(float glX, float glY, float glZ) const; + std::optional getFloorHeight(float glX, float glY, float glZ, float* outNormalZ = nullptr) const; /** * Check wall collision and adjust position @@ -235,6 +237,12 @@ public: double getQueryTimeMs() const { return queryTimeMs; } uint32_t getQueryCallCount() const { return queryCallCount; } + /** + * Update the tracked active WMO group based on player position. + * Called at low frequency (every ~10 frames or on significant movement). + */ + void updateActiveGroup(float glX, float glY, float glZ); + // Floor cache persistence (zone-specific files) void setMapName(const std::string& name) { mapName_ = name; } const std::string& getMapName() const { return mapName_; } @@ -293,6 +301,14 @@ private: // cellTriangles[cellY * gridCellsX + cellX] = list of triangle start indices std::vector> cellTriangles; + // Pre-classified triangle lists per cell (built at load time) + std::vector> cellFloorTriangles; // abs(normal.z) >= 0.45 + std::vector> cellWallTriangles; // abs(normal.z) < 0.55 + + // Pre-computed per-triangle Z bounds for fast vertical reject + struct TriBounds { float minZ; float maxZ; }; + std::vector triBounds; // indexed by triStart/3 + // Build the spatial grid from collision geometry void buildCollisionGrid(); @@ -302,6 +318,12 @@ private: // Get triangle indices for a local-space XY range (for wall collision) void getTrianglesInRange(float minX, float minY, float maxX, float maxY, std::vector& out) const; + + // Get pre-classified floor/wall triangles in range + void getFloorTrianglesInRange(float minX, float minY, float maxX, float maxY, + std::vector& out) const; + void getWallTrianglesInRange(float minX, float minY, float maxX, float maxY, + std::vector& out) const; }; /** @@ -563,6 +585,50 @@ private: // Compute floor height for a single cell (expensive, done at load time) std::optional computeFloorHeightSlow(float x, float y, float refZ) const; + + // Active WMO group tracking — reduces per-query group iteration + struct ActiveGroupInfo { + uint32_t instanceIdx = UINT32_MAX; + uint32_t modelId = 0; + int32_t groupIdx = -1; + std::vector neighborGroups; // portal-connected groups + bool isValid() const { return instanceIdx != UINT32_MAX && groupIdx >= 0; } + void invalidate() { instanceIdx = UINT32_MAX; groupIdx = -1; neighborGroups.clear(); } + }; + mutable ActiveGroupInfo activeGroup_; + + // Per-frame floor height dedup cache (same XY queried 3-5x per frame) + struct FrameFloorCache { + static constexpr size_t CAPACITY = 16; + struct Entry { uint64_t key; float resultZ; float normalZ; uint32_t frameId; }; + Entry entries[CAPACITY] = {}; + + uint64_t makeKey(float x, float y) const { + // 0.5-unit quantized grid + int32_t ix = static_cast(std::floor(x * 2.0f)); + int32_t iy = static_cast(std::floor(y * 2.0f)); + return (static_cast(static_cast(ix)) << 32) | + static_cast(static_cast(iy)); + } + + std::optional get(float x, float y, uint32_t frame, float* outNormalZ = nullptr) const { + uint64_t k = makeKey(x, y); + size_t slot = k % CAPACITY; + const auto& e = entries[slot]; + if (e.frameId == frame && e.key == k) { + if (outNormalZ) *outNormalZ = e.normalZ; + return e.resultZ; + } + return std::nullopt; + } + + void put(float x, float y, float result, float normalZ, uint32_t frame) { + uint64_t k = makeKey(x, y); + size_t slot = k % CAPACITY; + entries[slot] = { k, result, normalZ, frame }; + } + }; + mutable FrameFloorCache frameFloorCache_; }; } // namespace rendering diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 0c436119..7e61851b 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -26,18 +26,10 @@ std::optional selectReachableFloor(const std::optional& terrainH, if (terrainH && *terrainH <= refZ + maxStepUp) reachTerrain = terrainH; if (wmoH && *wmoH <= refZ + maxStepUp) reachWmo = wmoH; - // Avoid snapping up to higher WMO floors when entering buildings. - if (reachTerrain && reachWmo && *reachWmo > refZ + 3.5f) { - return reachTerrain; - } - if (reachTerrain && reachWmo) { - // Both available: prefer the one closest to the player's feet. - // This prevents tunnels/caves from snapping the player up to the - // terrain surface above, while still working on top of buildings. - float distTerrain = std::abs(*reachTerrain - refZ); - float distWmo = std::abs(*reachWmo - refZ); - return (distWmo <= distTerrain) ? reachWmo : reachTerrain; + // Prefer the highest surface — prevents clipping through + // WMO floors that sit above terrain. + return (*reachWmo >= *reachTerrain) ? reachWmo : reachTerrain; } if (reachWmo) return reachWmo; if (reachTerrain) return reachTerrain; @@ -394,6 +386,7 @@ void CameraController::update(float deltaTime) { if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { candidate.x = adjusted.x; candidate.y = adjusted.y; + candidate.z = std::max(candidate.z, adjusted.z); } } @@ -467,29 +460,10 @@ void CameraController::update(float deltaTime) { if (wmoRenderer) { glm::vec3 adjusted; if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { - // Before blocking, check if there's a walkable floor at the - // destination (stair step-up or ramp continuation). - float feetZ = stepPos.z; - float probeZ = feetZ + 2.5f; - auto floorH = wmoRenderer->getFloorHeight( - candidate.x, candidate.y, probeZ); - bool walkable = floorH && - *floorH >= feetZ - 0.5f && - *floorH <= feetZ + 1.6f; - if (!walkable) { - candidate.x = adjusted.x; - candidate.y = adjusted.y; - // Snap Z to floor at adjusted position to prevent fall-through - auto adjFloor = wmoRenderer->getFloorHeight(adjusted.x, adjusted.y, feetZ + 2.5f); - if (adjFloor && *adjFloor >= feetZ - 0.3f && *adjFloor <= feetZ + 1.6f) { - candidate.z = *adjFloor; - } - } else if (floorH && *floorH > candidate.z) { - // Snap Z to ramp surface so subsequent sweep - // steps measure feetZ from the ramp, not the - // starting position. - candidate.z = *floorH; - } + candidate.x = adjusted.x; + candidate.y = adjusted.y; + // Accept upward Z correction (ramps), reject downward + candidate.z = std::max(candidate.z, adjusted.z); } } @@ -508,110 +482,6 @@ void CameraController::update(float deltaTime) { } } - // WoW-style slope limiting (50 degrees, with sliding) - // dot(normal, up) >= 0.64 is walkable, otherwise slide - constexpr bool ENABLE_SLOPE_SLIDE = false; - constexpr float MAX_WALK_SLOPE_DOT = 0.6428f; // cos(50°) - constexpr float SAMPLE_DIST = 0.3f; // Distance to sample for normal calculation - if (ENABLE_SLOPE_SLIDE) { - glm::vec3 oldPos = *followTarget; - float moveXY = glm::length(glm::vec2(targetPos.x - oldPos.x, targetPos.y - oldPos.y)); - if (moveXY >= 0.03f) { - struct GroundSample { - std::optional height; - bool fromM2 = false; - }; - // Helper to get ground height at a position and whether M2 provided the top floor. - auto getGroundAt = [&](float x, float y) -> GroundSample { - std::optional terrainH; - std::optional wmoH; - std::optional m2H; - if (terrainManager) { - terrainH = terrainManager->getHeightAt(x, y); - } - float stepUpBudget = grounded ? 1.6f : 1.2f; - if (wmoRenderer) { - wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + stepUpBudget + 0.5f); - } - if (m2Renderer) { - m2H = m2Renderer->getFloorHeight(x, y, targetPos.z); - } - bool firstPerson = (!thirdPerson) || (currentDistance < 0.6f); - if (firstPerson) { - wmoH.reset(); - } - auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget); - bool fromM2 = false; - if (m2H && *m2H <= targetPos.z + stepUpBudget && (!base || *m2H > *base)) { - base = m2H; - fromM2 = true; - } - return GroundSample{base, fromM2}; - }; - - // Get ground height at target position - auto center = getGroundAt(targetPos.x, targetPos.y); - bool skipSlopeCheck = center.height && center.fromM2; - if (center.height && !skipSlopeCheck) { - - // Calculate ground normal using height samples - auto hPosX = getGroundAt(targetPos.x + SAMPLE_DIST, targetPos.y); - auto hNegX = getGroundAt(targetPos.x - SAMPLE_DIST, targetPos.y); - auto hPosY = getGroundAt(targetPos.x, targetPos.y + SAMPLE_DIST); - auto hNegY = getGroundAt(targetPos.x, targetPos.y - SAMPLE_DIST); - - // Estimate partial derivatives - float dzdx = 0.0f, dzdy = 0.0f; - if (hPosX.height && hNegX.height) { - dzdx = (*hPosX.height - *hNegX.height) / (2.0f * SAMPLE_DIST); - } else if (hPosX.height) { - dzdx = (*hPosX.height - *center.height) / SAMPLE_DIST; - } else if (hNegX.height) { - dzdx = (*center.height - *hNegX.height) / SAMPLE_DIST; - } - - if (hPosY.height && hNegY.height) { - dzdy = (*hPosY.height - *hNegY.height) / (2.0f * SAMPLE_DIST); - } else if (hPosY.height) { - dzdy = (*hPosY.height - *center.height) / SAMPLE_DIST; - } else if (hNegY.height) { - dzdy = (*center.height - *hNegY.height) / SAMPLE_DIST; - } - - // Ground normal = normalize(cross(tangentX, tangentY)) - // tangentX = (1, 0, dzdx), tangentY = (0, 1, dzdy) - // cross = (-dzdx, -dzdy, 1) - glm::vec3 groundNormal = glm::normalize(glm::vec3(-dzdx, -dzdy, 1.0f)); - float slopeDot = groundNormal.z; // dot(normal, up) where up = (0,0,1) - - // Check if slope is too steep - if (slopeDot < MAX_WALK_SLOPE_DOT) { - // Slope too steep - slide instead of walk - // Calculate slide direction (downhill, horizontal only) - glm::vec2 slideDir = glm::normalize(glm::vec2(-groundNormal.x, -groundNormal.y)); - - // Only block uphill movement, allow downhill/across - glm::vec2 moveDir = glm::vec2(targetPos.x - oldPos.x, targetPos.y - oldPos.y); - float moveDist = glm::length(moveDir); - - if (moveDist > 0.001f) { - glm::vec2 moveDirNorm = moveDir / moveDist; - - // How much are we trying to go uphill? - float uphillAmount = -glm::dot(moveDirNorm, slideDir); - - if (uphillAmount > 0.0f) { - // Trying to go uphill on steep slope - slide back - float slideStrength = (1.0f - slopeDot / MAX_WALK_SLOPE_DOT); - targetPos.x = oldPos.x + slideDir.x * moveDist * slideStrength * 0.5f; - targetPos.y = oldPos.y + slideDir.y * moveDist * slideStrength * 0.5f; - } - } - } - } - } - } - // Ground the character to terrain or WMO floor // Skip entirely while swimming — the swim floor clamp handles vertical bounds. if (!swimming) { @@ -656,35 +526,31 @@ void CameraController::update(float deltaTime) { if (groundH) { hasRealGround_ = true; noGroundTimer_ = 0.0f; - float groundDiff = *groundH - lastGroundZ; - if (groundDiff > 2.0f) { - // Landing on a higher ledge - snap up - lastGroundZ = *groundH; - } else { - // Smooth toward detected ground. Use a slower rate for large - // drops so multi-story buildings don't snap to the wrong floor, - // but always converge so walking off a fountain works. - float rate = (groundDiff > -2.0f) ? 15.0f : 6.0f; - lastGroundZ += groundDiff * std::min(1.0f, deltaTime * rate); - } + float feetZ = targetPos.z; + float stepUp = 1.0f; + float fallCatch = 3.0f; + float dz = *groundH - feetZ; - if (targetPos.z <= lastGroundZ + 0.1f && verticalVelocity <= 0.0f) { - targetPos.z = lastGroundZ; + // WoW-style: snap to floor if within step-up or fall-catch range, + // but only when not moving upward (jumping) + if (dz <= stepUp && dz >= -fallCatch && + (verticalVelocity <= 0.0f || *groundH > feetZ)) { + targetPos.z = *groundH; verticalVelocity = 0.0f; grounded = true; + lastGroundZ = *groundH; } else { grounded = false; + lastGroundZ = *groundH; } } else { hasRealGround_ = false; noGroundTimer_ += deltaTime; if (noGroundTimer_ < NO_GROUND_GRACE) { - // Brief grace period for terrain streaming — hold position targetPos.z = lastGroundZ; verticalVelocity = 0.0f; grounded = true; } else { - // No geometry found for too long — let player fall grounded = false; } } @@ -734,6 +600,7 @@ void CameraController::update(float deltaTime) { 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; } @@ -781,8 +648,18 @@ void CameraController::update(float deltaTime) { auto camTerrainH = getTerrainFloorAt(smoothedCamPos.x, smoothedCamPos.y); std::optional camWmoH; if (wmoRenderer) { - camWmoH = wmoRenderer->getFloorHeight( - smoothedCamPos.x, smoothedCamPos.y, smoothedCamPos.z + 3.0f); + // Skip expensive WMO floor query if camera barely moved + float camDelta = glm::length(glm::vec2(smoothedCamPos.x - lastCamFloorQueryPos.x, + smoothedCamPos.y - lastCamFloorQueryPos.y)); + if (camDelta < 0.3f && hasCachedCamFloor) { + camWmoH = cachedCamWmoFloor; + } else { + camWmoH = wmoRenderer->getFloorHeight( + smoothedCamPos.x, smoothedCamPos.y, smoothedCamPos.z + 3.0f); + cachedCamWmoFloor = camWmoH; + hasCachedCamFloor = true; + lastCamFloorQueryPos = smoothedCamPos; + } } auto camFloorH = selectReachableFloor( camTerrainH, camWmoH, smoothedCamPos.z, 3.0f); @@ -925,7 +802,9 @@ void CameraController::update(float deltaTime) { glm::vec3 candidate = stepPos + stepDelta; glm::vec3 adjusted; if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { - candidate = adjusted; + candidate.x = adjusted.x; + candidate.y = adjusted.y; + candidate.z = std::max(candidate.z, adjusted.z); } stepPos = candidate; } @@ -963,26 +842,24 @@ void CameraController::update(float deltaTime) { std::optional groundH = sampleGround(newPos.x, newPos.y); if (groundH) { - float groundDiff = *groundH - lastGroundZ; - if (groundDiff > 2.0f) { - lastGroundZ = *groundH; - } else { - float rate = (groundDiff > -2.0f) ? 15.0f : 6.0f; - lastGroundZ += groundDiff * std::min(1.0f, deltaTime * rate); - } + float feetZ = newPos.z - eyeHeight; + float stepUp = 1.0f; + float fallCatch = 3.0f; + float dz = *groundH - feetZ; - float groundZ = lastGroundZ + eyeHeight; - if (newPos.z <= groundZ) { - newPos.z = groundZ; + if (dz <= stepUp && dz >= -fallCatch && + (verticalVelocity <= 0.0f || *groundH > feetZ)) { + newPos.z = *groundH + eyeHeight; verticalVelocity = 0.0f; grounded = true; - swimming = false; // Touching ground = wading + lastGroundZ = *groundH; + swimming = false; } else if (!swimming) { grounded = false; + lastGroundZ = *groundH; } } else if (!swimming) { - float groundZ = lastGroundZ + eyeHeight; - newPos.z = groundZ; + newPos.z = lastGroundZ + eyeHeight; verticalVelocity = 0.0f; grounded = true; } @@ -1332,6 +1209,11 @@ void CameraController::teleportTo(const glm::vec3& pos) { autoUnstuckFired_ = false; continuousFallTime_ = 0.0f; + // Invalidate active WMO group so it's re-detected at new position + if (wmoRenderer) { + wmoRenderer->updateActiveGroup(pos.x, pos.y, pos.z + 1.0f); + } + if (thirdPerson && followTarget) { *followTarget = pos; camera->setRotation(yaw, pitch); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index be9b726d..e3508390 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1572,7 +1572,13 @@ void WMORenderer::GroupResources::buildCollisionGrid() { if (gridCellsX > 64) gridCellsX = 64; if (gridCellsY > 64) gridCellsY = 64; - cellTriangles.resize(gridCellsX * gridCellsY); + size_t totalCells = gridCellsX * gridCellsY; + cellTriangles.resize(totalCells); + cellFloorTriangles.resize(totalCells); + cellWallTriangles.resize(totalCells); + + size_t numTriangles = collisionIndices.size() / 3; + triBounds.resize(numTriangles); float invCellW = gridCellsX / std::max(0.01f, extentX); float invCellH = gridCellsY / std::max(0.01f, extentY); @@ -1588,6 +1594,22 @@ void WMORenderer::GroupResources::buildCollisionGrid() { float triMaxX = std::max({v0.x, v1.x, v2.x}); float triMaxY = std::max({v0.y, v1.y, v2.y}); + // Per-triangle Z bounds + float triMinZ = std::min({v0.z, v1.z, v2.z}); + float triMaxZ = std::max({v0.z, v1.z, v2.z}); + triBounds[i / 3] = { triMinZ, triMaxZ }; + + // Classify floor vs wall by normal. + // Wall threshold matches MAX_WALK_SLOPE_DOT (cos 50° ≈ 0.6428) so that + // surfaces too steep to walk on are always tested for wall collision. + glm::vec3 edge1 = v1 - v0; + glm::vec3 edge2 = v2 - v0; + glm::vec3 normal = glm::cross(edge1, edge2); + float normalLen = glm::length(normal); + float absNz = (normalLen > 0.001f) ? std::abs(normal.z / normalLen) : 0.0f; + bool isFloor = (absNz >= 0.45f); + bool isWall = (absNz < 0.65f); // Matches walkable slope threshold + int cellMinX = std::max(0, static_cast((triMinX - gridOrigin.x) * invCellW)); int cellMinY = std::max(0, static_cast((triMinY - gridOrigin.y) * invCellH)); int cellMaxX = std::min(gridCellsX - 1, static_cast((triMaxX - gridOrigin.x) * invCellW)); @@ -1596,7 +1618,10 @@ void WMORenderer::GroupResources::buildCollisionGrid() { uint32_t triIdx = static_cast(i); for (int cy = cellMinY; cy <= cellMaxY; ++cy) { for (int cx = cellMinX; cx <= cellMaxX; ++cx) { - cellTriangles[cy * gridCellsX + cx].push_back(triIdx); + int cellIdx = cy * gridCellsX + cx; + cellTriangles[cellIdx].push_back(triIdx); + if (isFloor) cellFloorTriangles[cellIdx].push_back(triIdx); + if (isWall) cellWallTriangles[cellIdx].push_back(triIdx); } } } @@ -1651,7 +1676,73 @@ void WMORenderer::GroupResources::getTrianglesInRange( } } -std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ) const { +void WMORenderer::GroupResources::getFloorTrianglesInRange( + float minX, float minY, float maxX, float maxY, + std::vector& out) const { + out.clear(); + if (gridCellsX == 0 || gridCellsY == 0 || cellFloorTriangles.empty()) return; + + float extentX = boundingBoxMax.x - boundingBoxMin.x; + float extentY = boundingBoxMax.y - boundingBoxMin.y; + float invCellW = gridCellsX / std::max(0.01f, extentX); + float invCellH = gridCellsY / std::max(0.01f, extentY); + + int cellMinX = std::max(0, static_cast((minX - gridOrigin.x) * invCellW)); + int cellMinY = std::max(0, static_cast((minY - gridOrigin.y) * invCellH)); + int cellMaxX = std::min(gridCellsX - 1, static_cast((maxX - gridOrigin.x) * invCellW)); + int cellMaxY = std::min(gridCellsY - 1, static_cast((maxY - gridOrigin.y) * invCellH)); + + if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; + + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellFloorTriangles[cy * gridCellsX + cx]; + out.insert(out.end(), cell.begin(), cell.end()); + } + } + + if (cellMinX != cellMaxX || cellMinY != cellMaxY) { + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + } +} + +void WMORenderer::GroupResources::getWallTrianglesInRange( + float minX, float minY, float maxX, float maxY, + std::vector& out) const { + out.clear(); + if (gridCellsX == 0 || gridCellsY == 0 || cellWallTriangles.empty()) return; + + float extentX = boundingBoxMax.x - boundingBoxMin.x; + float extentY = boundingBoxMax.y - boundingBoxMin.y; + float invCellW = gridCellsX / std::max(0.01f, extentX); + float invCellH = gridCellsY / std::max(0.01f, extentY); + + int cellMinX = std::max(0, static_cast((minX - gridOrigin.x) * invCellW)); + int cellMinY = std::max(0, static_cast((minY - gridOrigin.y) * invCellH)); + int cellMaxX = std::min(gridCellsX - 1, static_cast((maxX - gridOrigin.x) * invCellW)); + int cellMaxY = std::min(gridCellsY - 1, static_cast((maxY - gridOrigin.y) * invCellH)); + + if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; + + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellWallTriangles[cy * gridCellsX + cx]; + out.insert(out.end(), cell.begin(), cell.end()); + } + } + + if (cellMinX != cellMaxX || cellMinY != cellMaxY) { + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + } +} + +std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ, float* outNormalZ) const { + // Per-frame dedup cache: same (x,y) queried 3-5x per frame + auto frameCached = frameFloorCache_.get(glX, glY, currentFrameId, outNormalZ); + if (frameCached) return *frameCached; + // Check persistent grid cache first (computed lazily, never expires) uint64_t gridKey = floorGridKey(glX, glY); auto gridIt = precomputedFloorGrid.find(gridKey); @@ -1661,18 +1752,77 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ // For multi-story buildings, the cache has the top floor - if we're on a // lower floor, skip cache and do full raycast to find actual floor. if (cachedHeight <= glZ + 2.0f && cachedHeight >= glZ - 4.0f) { + // Persistent cache doesn't store normal — report as flat + if (outNormalZ) *outNormalZ = 1.0f; + frameFloorCache_.put(glX, glY, cachedHeight, 1.0f, currentFrameId); return cachedHeight; } } QueryTimer timer(&queryTimeMs, &queryCallCount); std::optional bestFloor; + float bestNormalZ = 1.0f; bool bestFromLowPlatform = false; // World-space ray: from high above, pointing straight down glm::vec3 worldOrigin(glX, glY, glZ + 500.0f); glm::vec3 worldDir(0.0f, 0.0f, -1.0f); + // Lambda to test a single group for floor hits + auto testGroupFloor = [&](const WMOInstance& instance, const ModelData& model, + const GroupResources& group, + const glm::vec3& localOrigin, const glm::vec3& localDir) { + const auto& verts = group.collisionVertices; + const auto& indices = group.collisionIndices; + + // Use unfiltered triangle list: a vertical ray naturally misses vertical + // geometry via ray-triangle intersection, so pre-filtering by normal is + // unnecessary and risks excluding legitimate floor geometry (steep ramps, + // stair treads with non-trivial normals). + group.getTrianglesInRange( + localOrigin.x - 1.0f, localOrigin.y - 1.0f, + localOrigin.x + 1.0f, localOrigin.y + 1.0f, + wallTriScratch); + + for (uint32_t triStart : wallTriScratch) { + const glm::vec3& v0 = verts[indices[triStart]]; + const glm::vec3& v1 = verts[indices[triStart + 1]]; + const glm::vec3& v2 = verts[indices[triStart + 2]]; + + float t = rayTriangleIntersect(localOrigin, localDir, v0, v1, v2); + if (t <= 0.0f) { + t = rayTriangleIntersect(localOrigin, localDir, v0, v2, v1); + } + + if (t > 0.0f) { + glm::vec3 hitLocal = localOrigin + localDir * t; + glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f)); + + float allowAbove = model.isLowPlatform ? 12.0f : 2.0f; + if (hitWorld.z <= glZ + allowAbove) { + if (!bestFloor || hitWorld.z > *bestFloor) { + bestFloor = hitWorld.z; + bestFromLowPlatform = model.isLowPlatform; + + // Compute local normal and transform to world space + glm::vec3 localNormal = glm::cross(v1 - v0, v2 - v0); + float len = glm::length(localNormal); + if (len > 0.001f) { + localNormal /= len; + // Ensure normal points upward + if (localNormal.z < 0.0f) localNormal = -localNormal; + glm::vec3 worldNormal = glm::normalize( + glm::vec3(instance.modelMatrix * glm::vec4(localNormal, 0.0f))); + bestNormalZ = std::abs(worldNormal.z); + } + } + } + } + } + }; + + // Full scan: test all instances (active group fast path removed to fix + // bridge clipping where early-return missed other WMO instances) glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f); glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f); gatherCandidates(queryMin, queryMax, candidateScratch); @@ -1733,41 +1883,7 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ continue; } - const auto& verts = group.collisionVertices; - const auto& indices = group.collisionIndices; - - // Use spatial grid to test triangles near the query XY. - // Query a small range (±1 unit) to catch floor triangles at cell boundaries - // (bridges, narrow walkways whose triangles may sit in adjacent cells). - group.getTrianglesInRange( - localOrigin.x - 1.0f, localOrigin.y - 1.0f, - localOrigin.x + 1.0f, localOrigin.y + 1.0f, - wallTriScratch); - { - for (uint32_t triStart : wallTriScratch) { - const glm::vec3& v0 = verts[indices[triStart]]; - const glm::vec3& v1 = verts[indices[triStart + 1]]; - const glm::vec3& v2 = verts[indices[triStart + 2]]; - - float t = rayTriangleIntersect(localOrigin, localDir, v0, v1, v2); - if (t <= 0.0f) { - t = rayTriangleIntersect(localOrigin, localDir, v0, v2, v1); - } - - if (t > 0.0f) { - glm::vec3 hitLocal = localOrigin + localDir * t; - glm::vec3 hitWorld = glm::vec3(instance.modelMatrix * glm::vec4(hitLocal, 1.0f)); - - float allowAbove = model.isLowPlatform ? 12.0f : 0.5f; - if (hitWorld.z <= glZ + allowAbove) { - if (!bestFloor || hitWorld.z > *bestFloor) { - bestFloor = hitWorld.z; - bestFromLowPlatform = model.isLowPlatform; - } - } - } - } - } + testGroupFloor(instance, model, group, localOrigin, localDir); } } @@ -1781,6 +1897,11 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ } } + if (bestFloor) { + if (outNormalZ) *outNormalZ = bestNormalZ; + frameFloorCache_.put(glX, glY, *bestFloor, bestNormalZ, currentFrameId); + } + return bestFloor; } @@ -1790,13 +1911,13 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, bool blocked = false; glm::vec3 moveDir = to - from; - float moveDistXY = glm::length(glm::vec2(moveDir.x, moveDir.y)); - if (moveDistXY < 0.001f) return false; + float moveDist = glm::length(moveDir); + if (moveDist < 0.001f) return false; - // Player collision parameters - const float PLAYER_RADIUS = 0.70f; // Wider radius for better wall collision - const float PLAYER_HEIGHT = 2.0f; // Player height for wall checks - const float MAX_STEP_HEIGHT = 1.0f; // Allow stepping up stairs/ramps + // Player collision parameters — WoW-style horizontal cylinder + const float PLAYER_RADIUS = 0.50f; // Horizontal cylinder radius + const float PLAYER_HEIGHT = 2.0f; // Cylinder height for Z bounds + const float MAX_STEP_HEIGHT = 1.0f; // Step-up threshold glm::vec3 queryMin = glm::min(from, to) - glm::vec3(8.0f, 8.0f, 5.0f); glm::vec3 queryMax = glm::max(from, to) + glm::vec3(8.0f, 8.0f, 5.0f); @@ -1868,14 +1989,28 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, float rangeMinY = std::min(localFrom.y, localTo.y) - PLAYER_RADIUS - 1.5f; float rangeMaxX = std::max(localFrom.x, localTo.x) + PLAYER_RADIUS + 1.5f; float rangeMaxY = std::max(localFrom.y, localTo.y) + PLAYER_RADIUS + 1.5f; - group.getTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, wallTriScratch); + group.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, wallTriScratch); for (uint32_t triStart : wallTriScratch) { + // Use pre-computed Z bounds for fast vertical reject + const auto& tb = group.triBounds[triStart / 3]; + + // Only collide with walls in player's vertical range + if (tb.maxZ < localFeetZ + 0.3f) continue; + if (tb.minZ > localFeetZ + PLAYER_HEIGHT) continue; + + // Skip low geometry that can be stepped over + if (tb.maxZ <= localFeetZ + MAX_STEP_HEIGHT) continue; + + // Skip very short vertical surfaces (stair risers) + float triHeight = tb.maxZ - tb.minZ; + if (triHeight < 1.0f && tb.maxZ <= localFeetZ + 1.2f) continue; + const glm::vec3& v0 = verts[indices[triStart]]; const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v2 = verts[indices[triStart + 2]]; - // Get triangle normal + // Triangle normal for swept test and push fallback glm::vec3 edge1 = v1 - v0; glm::vec3 edge2 = v2 - v0; glm::vec3 normal = glm::cross(edge1, edge2); @@ -1883,32 +2018,11 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, if (normalLen < 0.001f) continue; normal /= normalLen; - // Skip near-horizontal triangles (floors/ceilings/ramps). - // Anything more horizontal than ~55° from vertical is walkable. - if (std::abs(normal.z) > 0.55f) continue; - - // Get triangle Z range - float triMinZ = std::min({v0.z, v1.z, v2.z}); - float triMaxZ = std::max({v0.z, v1.z, v2.z}); - - // Only collide with walls in player's vertical range - if (triMaxZ < localFeetZ + 0.3f) continue; - if (triMinZ > localFeetZ + PLAYER_HEIGHT) continue; - - // Simplified wall detection: any vertical-ish triangle above step height is a wall. - float triHeight = triMaxZ - triMinZ; - - // Skip low geometry that can be stepped over - if (triMaxZ <= localFeetZ + MAX_STEP_HEIGHT) continue; - - // Skip very short vertical surfaces (stair risers) - if (triHeight < 1.0f && triMaxZ <= localFeetZ + 1.2f) continue; - - // Recompute distances with current (possibly pushed) localTo + // Recompute plane distances with current (possibly pushed) localTo float fromDist = glm::dot(localFrom - v0, normal); float toDist = glm::dot(localTo - v0, normal); - // Swept test: prevent tunneling when crossing a wall between frames. + // Swept test: prevent tunneling when crossing a wall between frames if ((fromDist > PLAYER_RADIUS && toDist < -PLAYER_RADIUS) || (fromDist < -PLAYER_RADIUS && toDist > PLAYER_RADIUS)) { float denom = (fromDist - toDist); @@ -1921,11 +2035,12 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, if (hitErrSq <= 0.15f * 0.15f) { float side = fromDist > 0.0f ? 1.0f : -1.0f; glm::vec3 safeLocal = hitPoint + normal * side * (PLAYER_RADIUS + 0.05f); - glm::vec3 safeWorld = glm::vec3(instance.modelMatrix * glm::vec4(safeLocal, 1.0f)); - adjustedPos.x = safeWorld.x; - adjustedPos.y = safeWorld.y; - // Update localTo for subsequent triangle checks - localTo = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); + glm::vec3 pushLocal(safeLocal.x - localTo.x, safeLocal.y - localTo.y, 0.0f); + localTo.x = safeLocal.x; + localTo.y = safeLocal.y; + glm::vec3 pushWorld = glm::vec3(instance.modelMatrix * glm::vec4(pushLocal, 0.0f)); + adjustedPos.x += pushWorld.x; + adjustedPos.y += pushWorld.y; blocked = true; continue; } @@ -1933,23 +2048,28 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, } } - // Distance-based collision: push player out of walls + // Horizontal cylinder collision: closest point + horizontal distance glm::vec3 closest = closestPointOnTriangle(localTo, v0, v1, v2); glm::vec3 delta = localTo - closest; float horizDist = glm::length(glm::vec2(delta.x, delta.y)); + if (horizDist <= PLAYER_RADIUS) { + // Skip floor-like surfaces — grounding handles them, not wall collision + float absNz = std::abs(normal.z); + if (absNz >= 0.45f) continue; + float pushDist = PLAYER_RADIUS - horizDist + 0.02f; glm::vec2 pushDir2; if (horizDist > 1e-4f) { pushDir2 = glm::normalize(glm::vec2(delta.x, delta.y)); } else { glm::vec2 n2(normal.x, normal.y); - if (glm::length(n2) < 1e-4f) continue; - pushDir2 = glm::normalize(n2); + float n2Len = glm::length(n2); + if (n2Len < 1e-4f) continue; + pushDir2 = n2 / n2Len; } glm::vec3 pushLocal(pushDir2.x * pushDist, pushDir2.y * pushDist, 0.0f); - // Update localTo so subsequent triangles use corrected position localTo.x += pushLocal.x; localTo.y += pushLocal.y; glm::vec3 pushWorld = glm::vec3(instance.modelMatrix * glm::vec4(pushLocal, 0.0f)); @@ -1964,6 +2084,104 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, return blocked; } +void WMORenderer::updateActiveGroup(float glX, float glY, float glZ) { + // If active group is still valid, check if player is still inside it + if (activeGroup_.isValid() && activeGroup_.instanceIdx < instances.size()) { + const auto& instance = instances[activeGroup_.instanceIdx]; + if (instance.modelId == activeGroup_.modelId) { + auto it = loadedModels.find(instance.modelId); + if (it != loadedModels.end()) { + const ModelData& model = it->second; + glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); + + // Still inside active group? + if (activeGroup_.groupIdx >= 0 && static_cast(activeGroup_.groupIdx) < model.groups.size()) { + const auto& group = model.groups[activeGroup_.groupIdx]; + if (localPos.x >= group.boundingBoxMin.x && localPos.x <= group.boundingBoxMax.x && + localPos.y >= group.boundingBoxMin.y && localPos.y <= group.boundingBoxMax.y && + localPos.z >= group.boundingBoxMin.z && localPos.z <= group.boundingBoxMax.z) { + return; // Still in same group + } + } + + // Check portal-neighbor groups + for (uint32_t ngi : activeGroup_.neighborGroups) { + if (ngi < model.groups.size()) { + const auto& group = model.groups[ngi]; + if (localPos.x >= group.boundingBoxMin.x && localPos.x <= group.boundingBoxMax.x && + localPos.y >= group.boundingBoxMin.y && localPos.y <= group.boundingBoxMax.y && + localPos.z >= group.boundingBoxMin.z && localPos.z <= group.boundingBoxMax.z) { + // Moved to a neighbor group — update + activeGroup_.groupIdx = static_cast(ngi); + // Rebuild neighbors for new group + activeGroup_.neighborGroups.clear(); + if (ngi < model.groupPortalRefs.size()) { + auto [portalStart, portalCount] = model.groupPortalRefs[ngi]; + for (uint16_t pi = 0; pi < portalCount; pi++) { + uint16_t refIdx = portalStart + pi; + if (refIdx < model.portalRefs.size()) { + uint32_t tgt = model.portalRefs[refIdx].groupIndex; + if (tgt < model.groups.size()) { + activeGroup_.neighborGroups.push_back(tgt); + } + } + } + } + return; + } + } + } + } + } + } + + // Full scan: find which instance/group contains the player + activeGroup_.invalidate(); + + glm::vec3 queryMin(glX - 0.5f, glY - 0.5f, glZ - 0.5f); + glm::vec3 queryMax(glX + 0.5f, glY + 0.5f, glZ + 0.5f); + gatherCandidates(queryMin, queryMax, candidateScratch); + + for (size_t idx : candidateScratch) { + const auto& instance = instances[idx]; + if (glX < instance.worldBoundsMin.x || glX > instance.worldBoundsMax.x || + glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y || + glZ < instance.worldBoundsMin.z || glZ > instance.worldBoundsMax.z) { + continue; + } + + auto it = loadedModels.find(instance.modelId); + if (it == loadedModels.end()) continue; + + const ModelData& model = it->second; + glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); + + int gi = findContainingGroup(model, localPos); + if (gi >= 0) { + activeGroup_.instanceIdx = static_cast(idx); + activeGroup_.modelId = instance.modelId; + activeGroup_.groupIdx = gi; + + // Build neighbor list from portal refs + activeGroup_.neighborGroups.clear(); + uint32_t groupIdx = static_cast(gi); + if (groupIdx < model.groupPortalRefs.size()) { + auto [portalStart, portalCount] = model.groupPortalRefs[groupIdx]; + for (uint16_t pi = 0; pi < portalCount; pi++) { + uint16_t refIdx = portalStart + pi; + if (refIdx < model.portalRefs.size()) { + uint32_t tgt = model.portalRefs[refIdx].groupIndex; + if (tgt < model.groups.size()) { + activeGroup_.neighborGroups.push_back(tgt); + } + } + } + } + return; + } + } +} + bool WMORenderer::isInsideWMO(float glX, float glY, float glZ, uint32_t* outModelId) const { QueryTimer timer(&queryTimeMs, &queryCallCount); glm::vec3 queryMin(glX - 0.5f, glY - 0.5f, glZ - 0.5f); @@ -2063,8 +2281,8 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3 QueryTimer timer(&queryTimeMs, &queryCallCount); float closestHit = maxDistance; // Camera collision should primarily react to walls. - // Treat near-horizontal triangles as floor/ceiling and ignore them here so - // ramps/stairs don't constantly pull the camera in and clip the floor view. + // Wall list pre-filters at abs(normal.z) < 0.55, but for camera raycast we want + // a stricter threshold to avoid ramp/stair geometry pulling the camera in. constexpr float MAX_WALKABLE_ABS_NORMAL_Z = 0.20f; constexpr float MAX_HIT_BELOW_ORIGIN = 0.90f; constexpr float MAX_HIT_ABOVE_ORIGIN = 0.80f; @@ -2118,7 +2336,7 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3 continue; } - // Narrow-phase: triangle raycast using spatial grid. + // Narrow-phase: triangle raycast using spatial grid (wall-only). const auto& verts = group.collisionVertices; const auto& indices = group.collisionIndices; @@ -2129,7 +2347,7 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3 float rMinY = std::min(localOrigin.y, localEnd.y) - 1.0f; float rMaxX = std::max(localOrigin.x, localEnd.x) + 1.0f; float rMaxY = std::max(localOrigin.y, localEnd.y) + 1.0f; - group.getTrianglesInRange(rMinX, rMinY, rMaxX, rMaxY, wallTriScratch); + group.getWallTrianglesInRange(rMinX, rMinY, rMaxX, rMaxY, wallTriScratch); for (uint32_t triStart : wallTriScratch) { const glm::vec3& v0 = verts[indices[triStart]]; @@ -2141,6 +2359,7 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3 continue; } triNormal /= std::sqrt(normalLenSq); + // Wall list pre-filters at 0.55; apply stricter camera threshold if (std::abs(triNormal.z) > MAX_WALKABLE_ABS_NORMAL_Z) { continue; }