From 64879b8aab5392285793650daf08209c23d2ecde Mon Sep 17 00:00:00 2001 From: Kelsi Date: Wed, 25 Feb 2026 11:56:58 -0800 Subject: [PATCH] Overhaul WMO collision: precompute normals, fix floor selection, optimize queries - Precompute triangle normals in buildCollisionGrid, eliminating per-query cross+normalize in getFloorHeight, checkWallCollision, and raycastBoundingBoxes - Fix floor selection: remove redundant allowAbove (callers already elevate probeZ by stepUpBudget), preventing upper-story snap at doorway transitions - Align wall classification threshold (absNz < 0.35) with runtime skip check, eliminating ~30% wasted wall triangle fetches - Replace O(n log n) sort+unique dedup in range queries with O(n) visited bitset - Rename wallTriScratch to triScratch_, fix stale threshold comments --- include/rendering/wmo_renderer.hpp | 12 +- src/rendering/wmo_renderer.cpp | 179 +++++++++++++++++------------ 2 files changed, 117 insertions(+), 74 deletions(-) diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 9d4a62a6..1b1526c6 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -390,13 +390,19 @@ private: 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 + std::vector> cellFloorTriangles; // abs(normal.z) >= 0.35 + std::vector> cellWallTriangles; // abs(normal.z) < 0.35 // Pre-computed per-triangle Z bounds for fast vertical reject struct TriBounds { float minZ; float maxZ; }; std::vector triBounds; // indexed by triStart/3 + // Pre-computed per-triangle normals (unit length, indexed by triStart/3) + std::vector triNormals; + + // Scratch bitset for deduplicating triangle queries (sized to numTriangles) + mutable std::vector triVisited; + // Build the spatial grid from collision geometry void buildCollisionGrid(); @@ -675,7 +681,7 @@ private: std::unordered_map, GridCellHash> spatialGrid; std::unordered_map instanceIndexById; mutable std::vector candidateScratch; - mutable std::vector wallTriScratch; // Scratch for wall collision grid queries + mutable std::vector triScratch_; // Scratch for collision grid queries mutable std::unordered_set candidateIdScratch; // Parallel visibility culling diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 8a3bd00e..145128a2 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1263,6 +1263,8 @@ void WMORenderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& q } void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + ++currentFrameId; + if (!opaquePipeline_ || instances.empty()) { lastDrawCalls = 0; return; @@ -2474,6 +2476,8 @@ void WMORenderer::GroupResources::buildCollisionGrid() { size_t numTriangles = collisionIndices.size() / 3; triBounds.resize(numTriangles); + triNormals.resize(numTriangles); + triVisited.resize(numTriangles, 0); float invCellW = gridCellsX / std::max(0.01f, extentX); float invCellH = gridCellsY / std::max(0.01f, extentY); @@ -2494,16 +2498,23 @@ void WMORenderer::GroupResources::buildCollisionGrid() { 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. + // Precompute and store unit normal 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; + if (normalLen > 0.001f) { + normal /= normalLen; + } else { + normal = glm::vec3(0.0f, 0.0f, 1.0f); + } + triNormals[i / 3] = normal; + + // Classify floor vs wall by normal. + // Wall threshold matches the runtime skip in checkWallCollision (absNz >= 0.35). + float absNz = std::abs(normal.z); bool isFloor = (absNz >= 0.35f); // ~70° max slope (relaxed for steep stairs) - bool isWall = (absNz < 0.65f); // Matches walkable slope threshold + bool isWall = (absNz < 0.35f); // Matches checkWallCollision skip threshold int cellMinX = std::max(0, static_cast((triMinX - gridOrigin.x) * invCellW)); int cellMinY = std::max(0, static_cast((triMinY - gridOrigin.y) * invCellH)); @@ -2556,18 +2567,30 @@ void WMORenderer::GroupResources::getTrianglesInRange( if (cellMinX > cellMaxX || cellMinY > cellMaxY) return; - // Collect unique triangle indices from all overlapping cells - for (int cy = cellMinY; cy <= cellMaxY; ++cy) { - for (int cx = cellMinX; cx <= cellMaxX; ++cx) { - const auto& cell = cellTriangles[cy * gridCellsX + cx]; - out.insert(out.end(), cell.begin(), cell.end()); + // Collect unique triangle indices using visited bitset (O(n) dedup) + bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY); + if (multiCell && !triVisited.empty()) { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellTriangles[cy * gridCellsX + cx]; + for (uint32_t tri : cell) { + uint32_t idx = tri / 3; + if (!triVisited[idx]) { + triVisited[idx] = 1; + out.push_back(tri); + } + } + } + } + // Clear visited bits + for (uint32_t tri : out) triVisited[tri / 3] = 0; + } else { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellTriangles[cy * gridCellsX + cx]; + out.insert(out.end(), cell.begin(), cell.end()); + } } - } - - // Remove duplicates (triangles spanning multiple cells) - if (cellMinX != cellMaxX || cellMinY != cellMaxY) { - std::sort(out.begin(), out.end()); - out.erase(std::unique(out.begin(), out.end()), out.end()); } } @@ -2589,16 +2612,28 @@ void WMORenderer::GroupResources::getFloorTrianglesInRange( 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()); + bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY); + if (multiCell && !triVisited.empty()) { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellFloorTriangles[cy * gridCellsX + cx]; + for (uint32_t tri : cell) { + uint32_t idx = tri / 3; + if (!triVisited[idx]) { + triVisited[idx] = 1; + out.push_back(tri); + } + } + } + } + for (uint32_t tri : out) triVisited[tri / 3] = 0; + } else { + 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()); } } @@ -2620,22 +2655,35 @@ void WMORenderer::GroupResources::getWallTrianglesInRange( 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()); + bool multiCell = (cellMinX != cellMaxX || cellMinY != cellMaxY); + if (multiCell && !triVisited.empty()) { + for (int cy = cellMinY; cy <= cellMaxY; ++cy) { + for (int cx = cellMinX; cx <= cellMaxX; ++cx) { + const auto& cell = cellWallTriangles[cy * gridCellsX + cx]; + for (uint32_t tri : cell) { + uint32_t idx = tri / 3; + if (!triVisited[idx]) { + triVisited[idx] = 1; + out.push_back(tri); + } + } + } + } + for (uint32_t tri : out) triVisited[tri / 3] = 0; + } else { + 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 { - // All floor caching disabled - even per-frame cache can return stale results - // when player Z changes between queries, causing fall-through at stairs. + // Per-frame cache disabled: camera and player query the same (x,y) at + // different Z within a single frame. The allowAbove filter depends on glZ, + // so caching by (x,y) alone returns wrong floors across Z contexts. QueryTimer timer(&queryTimeMs, &queryCallCount); std::optional bestFloor; @@ -2660,9 +2708,9 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ group.getTrianglesInRange( localOrigin.x - 1.0f, localOrigin.y - 1.0f, localOrigin.x + 1.0f, localOrigin.y + 1.0f, - wallTriScratch); + triScratch_); - for (uint32_t triStart : wallTriScratch) { + for (uint32_t triStart : triScratch_) { const glm::vec3& v0 = verts[indices[triStart]]; const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v2 = verts[indices[triStart + 2]]; @@ -2676,23 +2724,20 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ 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) { + // Accept floors at or below glZ (the caller already elevates + // glZ by stepUpBudget to handle step-up range). Among those, + // pick the highest (closest to feet). + if (hitWorld.z <= glZ) { 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); - } + // Use precomputed normal, ensure upward, transform to world + glm::vec3 localNormal = group.triNormals[triStart / 3]; + 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); } } } @@ -2735,8 +2780,8 @@ std::optional WMORenderer::getFloorHeight(float glX, float glY, float glZ } } - // Full scan: test all instances (active group fast path removed to fix - // bridge clipping where early-return missed other WMO instances) + // Full scan: test all instances (active group result above is not + // early-returned because overlapping WMO instances need full coverage). 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); @@ -2898,9 +2943,9 @@ 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.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, wallTriScratch); + group.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, triScratch_); - for (uint32_t triStart : wallTriScratch) { + for (uint32_t triStart : triScratch_) { // Use pre-computed Z bounds for fast vertical reject const auto& tb = group.triBounds[triStart / 3]; @@ -2919,13 +2964,9 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v2 = verts[indices[triStart + 2]]; - // 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); - float normalLen = glm::length(normal); - if (normalLen < 0.001f) continue; - normal /= normalLen; + // Use precomputed normal for swept test and push fallback + glm::vec3 normal = group.triNormals[triStart / 3]; + if (glm::dot(normal, normal) < 0.5f) continue; // degenerate // Recompute plane distances with current (possibly pushed) localTo float fromDist = glm::dot(localFrom - v0, normal); @@ -3268,19 +3309,15 @@ 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.getWallTrianglesInRange(rMinX, rMinY, rMaxX, rMaxY, wallTriScratch); + group.getWallTrianglesInRange(rMinX, rMinY, rMaxX, rMaxY, triScratch_); - for (uint32_t triStart : wallTriScratch) { + for (uint32_t triStart : triScratch_) { const glm::vec3& v0 = verts[indices[triStart]]; const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v2 = verts[indices[triStart + 2]]; - glm::vec3 triNormal = glm::cross(v1 - v0, v2 - v0); - float normalLenSq = glm::dot(triNormal, triNormal); - if (normalLenSq < 1e-8f) { - continue; - } - triNormal /= std::sqrt(normalLenSq); - // Wall list pre-filters at 0.55; apply stricter camera threshold + glm::vec3 triNormal = group.triNormals[triStart / 3]; + if (glm::dot(triNormal, triNormal) < 0.5f) continue; // degenerate + // Wall list pre-filters at 0.35; apply stricter camera threshold if (std::abs(triNormal.z) > MAX_WALKABLE_ABS_NORMAL_Z) { continue; }