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.
This commit is contained in:
Kelsi 2026-02-08 17:38:30 -08:00
parent ef54f62df0
commit f8aba30f2d
4 changed files with 425 additions and 253 deletions

View file

@ -161,6 +161,11 @@ private:
int insideWMOCheckCounter = 0; int insideWMOCheckCounter = 0;
glm::vec3 lastInsideWMOCheckPos = glm::vec3(0.0f); 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<float> cachedCamWmoFloor;
bool hasCachedCamFloor = false;
// Swimming // Swimming
bool swimming = false; bool swimming = false;
bool wasSwimming = false; bool wasSwimming = false;

View file

@ -190,9 +190,11 @@ public:
void renderShadow(const glm::mat4& lightView, const glm::mat4& lightProj, Shader& shadowShader); 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<float> getFloorHeight(float glX, float glY, float glZ) const; std::optional<float> getFloorHeight(float glX, float glY, float glZ, float* outNormalZ = nullptr) const;
/** /**
* Check wall collision and adjust position * Check wall collision and adjust position
@ -235,6 +237,12 @@ public:
double getQueryTimeMs() const { return queryTimeMs; } double getQueryTimeMs() const { return queryTimeMs; }
uint32_t getQueryCallCount() const { return queryCallCount; } 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) // Floor cache persistence (zone-specific files)
void setMapName(const std::string& name) { mapName_ = name; } void setMapName(const std::string& name) { mapName_ = name; }
const std::string& getMapName() const { return mapName_; } const std::string& getMapName() const { return mapName_; }
@ -293,6 +301,14 @@ private:
// cellTriangles[cellY * gridCellsX + cellX] = list of triangle start indices // cellTriangles[cellY * gridCellsX + cellX] = list of triangle start indices
std::vector<std::vector<uint32_t>> cellTriangles; std::vector<std::vector<uint32_t>> cellTriangles;
// Pre-classified triangle lists per cell (built at load time)
std::vector<std::vector<uint32_t>> cellFloorTriangles; // abs(normal.z) >= 0.45
std::vector<std::vector<uint32_t>> 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> triBounds; // indexed by triStart/3
// Build the spatial grid from collision geometry // Build the spatial grid from collision geometry
void buildCollisionGrid(); void buildCollisionGrid();
@ -302,6 +318,12 @@ private:
// Get triangle indices for a local-space XY range (for wall collision) // Get triangle indices for a local-space XY range (for wall collision)
void getTrianglesInRange(float minX, float minY, float maxX, float maxY, void getTrianglesInRange(float minX, float minY, float maxX, float maxY,
std::vector<uint32_t>& out) const; std::vector<uint32_t>& out) const;
// Get pre-classified floor/wall triangles in range
void getFloorTrianglesInRange(float minX, float minY, float maxX, float maxY,
std::vector<uint32_t>& out) const;
void getWallTrianglesInRange(float minX, float minY, float maxX, float maxY,
std::vector<uint32_t>& out) const;
}; };
/** /**
@ -563,6 +585,50 @@ private:
// Compute floor height for a single cell (expensive, done at load time) // Compute floor height for a single cell (expensive, done at load time)
std::optional<float> computeFloorHeightSlow(float x, float y, float refZ) const; std::optional<float> 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<uint32_t> 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<int32_t>(std::floor(x * 2.0f));
int32_t iy = static_cast<int32_t>(std::floor(y * 2.0f));
return (static_cast<uint64_t>(static_cast<uint32_t>(ix)) << 32) |
static_cast<uint64_t>(static_cast<uint32_t>(iy));
}
std::optional<float> 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 } // namespace rendering

View file

@ -26,18 +26,10 @@ std::optional<float> selectReachableFloor(const std::optional<float>& terrainH,
if (terrainH && *terrainH <= refZ + maxStepUp) reachTerrain = terrainH; if (terrainH && *terrainH <= refZ + maxStepUp) reachTerrain = terrainH;
if (wmoH && *wmoH <= refZ + maxStepUp) reachWmo = wmoH; 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) { if (reachTerrain && reachWmo) {
// Both available: prefer the one closest to the player's feet. // Prefer the highest surface — prevents clipping through
// This prevents tunnels/caves from snapping the player up to the // WMO floors that sit above terrain.
// terrain surface above, while still working on top of buildings. return (*reachWmo >= *reachTerrain) ? reachWmo : reachTerrain;
float distTerrain = std::abs(*reachTerrain - refZ);
float distWmo = std::abs(*reachWmo - refZ);
return (distWmo <= distTerrain) ? reachWmo : reachTerrain;
} }
if (reachWmo) return reachWmo; if (reachWmo) return reachWmo;
if (reachTerrain) return reachTerrain; if (reachTerrain) return reachTerrain;
@ -394,6 +386,7 @@ void CameraController::update(float deltaTime) {
if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) {
candidate.x = adjusted.x; candidate.x = adjusted.x;
candidate.y = adjusted.y; candidate.y = adjusted.y;
candidate.z = std::max(candidate.z, adjusted.z);
} }
} }
@ -467,29 +460,10 @@ void CameraController::update(float deltaTime) {
if (wmoRenderer) { if (wmoRenderer) {
glm::vec3 adjusted; glm::vec3 adjusted;
if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) {
// Before blocking, check if there's a walkable floor at the candidate.x = adjusted.x;
// destination (stair step-up or ramp continuation). candidate.y = adjusted.y;
float feetZ = stepPos.z; // Accept upward Z correction (ramps), reject downward
float probeZ = feetZ + 2.5f; candidate.z = std::max(candidate.z, adjusted.z);
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;
}
} }
} }
@ -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<float> 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<float> terrainH;
std::optional<float> wmoH;
std::optional<float> 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 // Ground the character to terrain or WMO floor
// Skip entirely while swimming — the swim floor clamp handles vertical bounds. // Skip entirely while swimming — the swim floor clamp handles vertical bounds.
if (!swimming) { if (!swimming) {
@ -656,35 +526,31 @@ void CameraController::update(float deltaTime) {
if (groundH) { if (groundH) {
hasRealGround_ = true; hasRealGround_ = true;
noGroundTimer_ = 0.0f; noGroundTimer_ = 0.0f;
float groundDiff = *groundH - lastGroundZ; float feetZ = targetPos.z;
if (groundDiff > 2.0f) { float stepUp = 1.0f;
// Landing on a higher ledge - snap up float fallCatch = 3.0f;
lastGroundZ = *groundH; float dz = *groundH - feetZ;
} 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);
}
if (targetPos.z <= lastGroundZ + 0.1f && verticalVelocity <= 0.0f) { // WoW-style: snap to floor if within step-up or fall-catch range,
targetPos.z = lastGroundZ; // but only when not moving upward (jumping)
if (dz <= stepUp && dz >= -fallCatch &&
(verticalVelocity <= 0.0f || *groundH > feetZ)) {
targetPos.z = *groundH;
verticalVelocity = 0.0f; verticalVelocity = 0.0f;
grounded = true; grounded = true;
lastGroundZ = *groundH;
} else { } else {
grounded = false; grounded = false;
lastGroundZ = *groundH;
} }
} else { } else {
hasRealGround_ = false; hasRealGround_ = false;
noGroundTimer_ += deltaTime; noGroundTimer_ += deltaTime;
if (noGroundTimer_ < NO_GROUND_GRACE) { if (noGroundTimer_ < NO_GROUND_GRACE) {
// Brief grace period for terrain streaming — hold position
targetPos.z = lastGroundZ; targetPos.z = lastGroundZ;
verticalVelocity = 0.0f; verticalVelocity = 0.0f;
grounded = true; grounded = true;
} else { } else {
// No geometry found for too long — let player fall
grounded = false; grounded = false;
} }
} }
@ -734,6 +600,7 @@ void CameraController::update(float deltaTime) {
float distFromLastCheck = glm::length(targetPos - lastInsideWMOCheckPos); float distFromLastCheck = glm::length(targetPos - lastInsideWMOCheckPos);
if (++insideWMOCheckCounter >= 10 || distFromLastCheck > 2.0f) { if (++insideWMOCheckCounter >= 10 || distFromLastCheck > 2.0f) {
cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr); cachedInsideWMO = wmoRenderer->isInsideWMO(targetPos.x, targetPos.y, targetPos.z + 1.0f, nullptr);
wmoRenderer->updateActiveGroup(targetPos.x, targetPos.y, targetPos.z + 1.0f);
insideWMOCheckCounter = 0; insideWMOCheckCounter = 0;
lastInsideWMOCheckPos = targetPos; lastInsideWMOCheckPos = targetPos;
} }
@ -781,8 +648,18 @@ void CameraController::update(float deltaTime) {
auto camTerrainH = getTerrainFloorAt(smoothedCamPos.x, smoothedCamPos.y); auto camTerrainH = getTerrainFloorAt(smoothedCamPos.x, smoothedCamPos.y);
std::optional<float> camWmoH; std::optional<float> camWmoH;
if (wmoRenderer) { if (wmoRenderer) {
camWmoH = wmoRenderer->getFloorHeight( // Skip expensive WMO floor query if camera barely moved
smoothedCamPos.x, smoothedCamPos.y, smoothedCamPos.z + 3.0f); 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( auto camFloorH = selectReachableFloor(
camTerrainH, camWmoH, smoothedCamPos.z, 3.0f); camTerrainH, camWmoH, smoothedCamPos.z, 3.0f);
@ -925,7 +802,9 @@ void CameraController::update(float deltaTime) {
glm::vec3 candidate = stepPos + stepDelta; glm::vec3 candidate = stepPos + stepDelta;
glm::vec3 adjusted; glm::vec3 adjusted;
if (wmoRenderer->checkWallCollision(stepPos, candidate, 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; stepPos = candidate;
} }
@ -963,26 +842,24 @@ void CameraController::update(float deltaTime) {
std::optional<float> groundH = sampleGround(newPos.x, newPos.y); std::optional<float> groundH = sampleGround(newPos.x, newPos.y);
if (groundH) { if (groundH) {
float groundDiff = *groundH - lastGroundZ; float feetZ = newPos.z - eyeHeight;
if (groundDiff > 2.0f) { float stepUp = 1.0f;
lastGroundZ = *groundH; float fallCatch = 3.0f;
} else { float dz = *groundH - feetZ;
float rate = (groundDiff > -2.0f) ? 15.0f : 6.0f;
lastGroundZ += groundDiff * std::min(1.0f, deltaTime * rate);
}
float groundZ = lastGroundZ + eyeHeight; if (dz <= stepUp && dz >= -fallCatch &&
if (newPos.z <= groundZ) { (verticalVelocity <= 0.0f || *groundH > feetZ)) {
newPos.z = groundZ; newPos.z = *groundH + eyeHeight;
verticalVelocity = 0.0f; verticalVelocity = 0.0f;
grounded = true; grounded = true;
swimming = false; // Touching ground = wading lastGroundZ = *groundH;
swimming = false;
} else if (!swimming) { } else if (!swimming) {
grounded = false; grounded = false;
lastGroundZ = *groundH;
} }
} else if (!swimming) { } else if (!swimming) {
float groundZ = lastGroundZ + eyeHeight; newPos.z = lastGroundZ + eyeHeight;
newPos.z = groundZ;
verticalVelocity = 0.0f; verticalVelocity = 0.0f;
grounded = true; grounded = true;
} }
@ -1332,6 +1209,11 @@ void CameraController::teleportTo(const glm::vec3& pos) {
autoUnstuckFired_ = false; autoUnstuckFired_ = false;
continuousFallTime_ = 0.0f; 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) { if (thirdPerson && followTarget) {
*followTarget = pos; *followTarget = pos;
camera->setRotation(yaw, pitch); camera->setRotation(yaw, pitch);

View file

@ -1572,7 +1572,13 @@ void WMORenderer::GroupResources::buildCollisionGrid() {
if (gridCellsX > 64) gridCellsX = 64; if (gridCellsX > 64) gridCellsX = 64;
if (gridCellsY > 64) gridCellsY = 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 invCellW = gridCellsX / std::max(0.01f, extentX);
float invCellH = gridCellsY / std::max(0.01f, extentY); 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 triMaxX = std::max({v0.x, v1.x, v2.x});
float triMaxY = std::max({v0.y, v1.y, v2.y}); 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<int>((triMinX - gridOrigin.x) * invCellW)); int cellMinX = std::max(0, static_cast<int>((triMinX - gridOrigin.x) * invCellW));
int cellMinY = std::max(0, static_cast<int>((triMinY - gridOrigin.y) * invCellH)); int cellMinY = std::max(0, static_cast<int>((triMinY - gridOrigin.y) * invCellH));
int cellMaxX = std::min(gridCellsX - 1, static_cast<int>((triMaxX - gridOrigin.x) * invCellW)); int cellMaxX = std::min(gridCellsX - 1, static_cast<int>((triMaxX - gridOrigin.x) * invCellW));
@ -1596,7 +1618,10 @@ void WMORenderer::GroupResources::buildCollisionGrid() {
uint32_t triIdx = static_cast<uint32_t>(i); uint32_t triIdx = static_cast<uint32_t>(i);
for (int cy = cellMinY; cy <= cellMaxY; ++cy) { for (int cy = cellMinY; cy <= cellMaxY; ++cy) {
for (int cx = cellMinX; cx <= cellMaxX; ++cx) { 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<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ) const { void WMORenderer::GroupResources::getFloorTrianglesInRange(
float minX, float minY, float maxX, float maxY,
std::vector<uint32_t>& 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<int>((minX - gridOrigin.x) * invCellW));
int cellMinY = std::max(0, static_cast<int>((minY - gridOrigin.y) * invCellH));
int cellMaxX = std::min(gridCellsX - 1, static_cast<int>((maxX - gridOrigin.x) * invCellW));
int cellMaxY = std::min(gridCellsY - 1, static_cast<int>((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<uint32_t>& 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<int>((minX - gridOrigin.x) * invCellW));
int cellMinY = std::max(0, static_cast<int>((minY - gridOrigin.y) * invCellH));
int cellMaxX = std::min(gridCellsX - 1, static_cast<int>((maxX - gridOrigin.x) * invCellW));
int cellMaxY = std::min(gridCellsY - 1, static_cast<int>((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<float> 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) // Check persistent grid cache first (computed lazily, never expires)
uint64_t gridKey = floorGridKey(glX, glY); uint64_t gridKey = floorGridKey(glX, glY);
auto gridIt = precomputedFloorGrid.find(gridKey); auto gridIt = precomputedFloorGrid.find(gridKey);
@ -1661,18 +1752,77 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
// For multi-story buildings, the cache has the top floor - if we're on a // 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. // lower floor, skip cache and do full raycast to find actual floor.
if (cachedHeight <= glZ + 2.0f && cachedHeight >= glZ - 4.0f) { 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; return cachedHeight;
} }
} }
QueryTimer timer(&queryTimeMs, &queryCallCount); QueryTimer timer(&queryTimeMs, &queryCallCount);
std::optional<float> bestFloor; std::optional<float> bestFloor;
float bestNormalZ = 1.0f;
bool bestFromLowPlatform = false; bool bestFromLowPlatform = false;
// World-space ray: from high above, pointing straight down // World-space ray: from high above, pointing straight down
glm::vec3 worldOrigin(glX, glY, glZ + 500.0f); glm::vec3 worldOrigin(glX, glY, glZ + 500.0f);
glm::vec3 worldDir(0.0f, 0.0f, -1.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 queryMin(glX - 2.0f, glY - 2.0f, glZ - 8.0f);
glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f); glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 10.0f);
gatherCandidates(queryMin, queryMax, candidateScratch); gatherCandidates(queryMin, queryMax, candidateScratch);
@ -1733,41 +1883,7 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
continue; continue;
} }
const auto& verts = group.collisionVertices; testGroupFloor(instance, model, group, localOrigin, localDir);
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;
}
}
}
}
}
} }
} }
@ -1781,6 +1897,11 @@ std::optional<float> WMORenderer::getFloorHeight(float glX, float glY, float glZ
} }
} }
if (bestFloor) {
if (outNormalZ) *outNormalZ = bestNormalZ;
frameFloorCache_.put(glX, glY, *bestFloor, bestNormalZ, currentFrameId);
}
return bestFloor; return bestFloor;
} }
@ -1790,13 +1911,13 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
bool blocked = false; bool blocked = false;
glm::vec3 moveDir = to - from; glm::vec3 moveDir = to - from;
float moveDistXY = glm::length(glm::vec2(moveDir.x, moveDir.y)); float moveDist = glm::length(moveDir);
if (moveDistXY < 0.001f) return false; if (moveDist < 0.001f) return false;
// Player collision parameters // Player collision parameters — WoW-style horizontal cylinder
const float PLAYER_RADIUS = 0.70f; // Wider radius for better wall collision const float PLAYER_RADIUS = 0.50f; // Horizontal cylinder radius
const float PLAYER_HEIGHT = 2.0f; // Player height for wall checks const float PLAYER_HEIGHT = 2.0f; // Cylinder height for Z bounds
const float MAX_STEP_HEIGHT = 1.0f; // Allow stepping up stairs/ramps 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 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); 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 rangeMinY = std::min(localFrom.y, localTo.y) - PLAYER_RADIUS - 1.5f;
float rangeMaxX = std::max(localFrom.x, localTo.x) + 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; 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) { 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& v0 = verts[indices[triStart]];
const glm::vec3& v1 = verts[indices[triStart + 1]]; const glm::vec3& v1 = verts[indices[triStart + 1]];
const glm::vec3& v2 = verts[indices[triStart + 2]]; 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 edge1 = v1 - v0;
glm::vec3 edge2 = v2 - v0; glm::vec3 edge2 = v2 - v0;
glm::vec3 normal = glm::cross(edge1, edge2); 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; if (normalLen < 0.001f) continue;
normal /= normalLen; normal /= normalLen;
// Skip near-horizontal triangles (floors/ceilings/ramps). // Recompute plane distances with current (possibly pushed) localTo
// 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
float fromDist = glm::dot(localFrom - v0, normal); float fromDist = glm::dot(localFrom - v0, normal);
float toDist = glm::dot(localTo - 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) || if ((fromDist > PLAYER_RADIUS && toDist < -PLAYER_RADIUS) ||
(fromDist < -PLAYER_RADIUS && toDist > PLAYER_RADIUS)) { (fromDist < -PLAYER_RADIUS && toDist > PLAYER_RADIUS)) {
float denom = (fromDist - toDist); 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) { if (hitErrSq <= 0.15f * 0.15f) {
float side = fromDist > 0.0f ? 1.0f : -1.0f; float side = fromDist > 0.0f ? 1.0f : -1.0f;
glm::vec3 safeLocal = hitPoint + normal * side * (PLAYER_RADIUS + 0.05f); glm::vec3 safeLocal = hitPoint + normal * side * (PLAYER_RADIUS + 0.05f);
glm::vec3 safeWorld = glm::vec3(instance.modelMatrix * glm::vec4(safeLocal, 1.0f)); glm::vec3 pushLocal(safeLocal.x - localTo.x, safeLocal.y - localTo.y, 0.0f);
adjustedPos.x = safeWorld.x; localTo.x = safeLocal.x;
adjustedPos.y = safeWorld.y; localTo.y = safeLocal.y;
// Update localTo for subsequent triangle checks glm::vec3 pushWorld = glm::vec3(instance.modelMatrix * glm::vec4(pushLocal, 0.0f));
localTo = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); adjustedPos.x += pushWorld.x;
adjustedPos.y += pushWorld.y;
blocked = true; blocked = true;
continue; 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 closest = closestPointOnTriangle(localTo, v0, v1, v2);
glm::vec3 delta = localTo - closest; glm::vec3 delta = localTo - closest;
float horizDist = glm::length(glm::vec2(delta.x, delta.y)); float horizDist = glm::length(glm::vec2(delta.x, delta.y));
if (horizDist <= PLAYER_RADIUS) { 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; float pushDist = PLAYER_RADIUS - horizDist + 0.02f;
glm::vec2 pushDir2; glm::vec2 pushDir2;
if (horizDist > 1e-4f) { if (horizDist > 1e-4f) {
pushDir2 = glm::normalize(glm::vec2(delta.x, delta.y)); pushDir2 = glm::normalize(glm::vec2(delta.x, delta.y));
} else { } else {
glm::vec2 n2(normal.x, normal.y); glm::vec2 n2(normal.x, normal.y);
if (glm::length(n2) < 1e-4f) continue; float n2Len = glm::length(n2);
pushDir2 = glm::normalize(n2); if (n2Len < 1e-4f) continue;
pushDir2 = n2 / n2Len;
} }
glm::vec3 pushLocal(pushDir2.x * pushDist, pushDir2.y * pushDist, 0.0f); glm::vec3 pushLocal(pushDir2.x * pushDist, pushDir2.y * pushDist, 0.0f);
// Update localTo so subsequent triangles use corrected position
localTo.x += pushLocal.x; localTo.x += pushLocal.x;
localTo.y += pushLocal.y; localTo.y += pushLocal.y;
glm::vec3 pushWorld = glm::vec3(instance.modelMatrix * glm::vec4(pushLocal, 0.0f)); 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; 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<size_t>(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<int32_t>(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<uint32_t>(idx);
activeGroup_.modelId = instance.modelId;
activeGroup_.groupIdx = gi;
// Build neighbor list from portal refs
activeGroup_.neighborGroups.clear();
uint32_t groupIdx = static_cast<uint32_t>(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 { bool WMORenderer::isInsideWMO(float glX, float glY, float glZ, uint32_t* outModelId) const {
QueryTimer timer(&queryTimeMs, &queryCallCount); QueryTimer timer(&queryTimeMs, &queryCallCount);
glm::vec3 queryMin(glX - 0.5f, glY - 0.5f, glZ - 0.5f); 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); QueryTimer timer(&queryTimeMs, &queryCallCount);
float closestHit = maxDistance; float closestHit = maxDistance;
// Camera collision should primarily react to walls. // Camera collision should primarily react to walls.
// Treat near-horizontal triangles as floor/ceiling and ignore them here so // Wall list pre-filters at abs(normal.z) < 0.55, but for camera raycast we want
// ramps/stairs don't constantly pull the camera in and clip the floor view. // a stricter threshold to avoid ramp/stair geometry pulling the camera in.
constexpr float MAX_WALKABLE_ABS_NORMAL_Z = 0.20f; constexpr float MAX_WALKABLE_ABS_NORMAL_Z = 0.20f;
constexpr float MAX_HIT_BELOW_ORIGIN = 0.90f; constexpr float MAX_HIT_BELOW_ORIGIN = 0.90f;
constexpr float MAX_HIT_ABOVE_ORIGIN = 0.80f; constexpr float MAX_HIT_ABOVE_ORIGIN = 0.80f;
@ -2118,7 +2336,7 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3
continue; continue;
} }
// Narrow-phase: triangle raycast using spatial grid. // Narrow-phase: triangle raycast using spatial grid (wall-only).
const auto& verts = group.collisionVertices; const auto& verts = group.collisionVertices;
const auto& indices = group.collisionIndices; 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 rMinY = std::min(localOrigin.y, localEnd.y) - 1.0f;
float rMaxX = std::max(localOrigin.x, localEnd.x) + 1.0f; float rMaxX = std::max(localOrigin.x, localEnd.x) + 1.0f;
float rMaxY = std::max(localOrigin.y, localEnd.y) + 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) { for (uint32_t triStart : wallTriScratch) {
const glm::vec3& v0 = verts[indices[triStart]]; const glm::vec3& v0 = verts[indices[triStart]];
@ -2141,6 +2359,7 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3
continue; continue;
} }
triNormal /= std::sqrt(normalLenSq); 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) { if (std::abs(triNormal.z) > MAX_WALKABLE_ABS_NORMAL_Z) {
continue; continue;
} }