Tune collision behavior for ramps, props, and structural walls

This commit is contained in:
Kelsi 2026-02-03 19:10:22 -08:00
parent f00d13bfc0
commit 871172d63e
4 changed files with 186 additions and 24 deletions

View file

@ -45,6 +45,8 @@ struct M2ModelGPU {
bool collisionSteppedFountain = false; bool collisionSteppedFountain = false;
bool collisionSteppedLowPlatform = false; bool collisionSteppedLowPlatform = false;
bool collisionPlanter = false; bool collisionPlanter = false;
bool collisionSmallSolidProp = false;
bool collisionNarrowVerticalProp = false;
bool collisionNoBlock = false; bool collisionNoBlock = false;
std::string name; std::string name;

View file

@ -295,9 +295,10 @@ void CameraController::update(float deltaTime) {
if (m2Renderer) { if (m2Renderer) {
m2H = m2Renderer->getFloorHeight(x, y, targetPos.z); m2H = m2Renderer->getFloorHeight(x, y, targetPos.z);
} }
auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f); float stepUpBudget = grounded ? 1.6f : 1.2f;
auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget);
bool fromM2 = false; bool fromM2 = false;
if (m2H && *m2H <= targetPos.z + 1.0f && (!base || *m2H > *base)) { if (m2H && *m2H <= targetPos.z + stepUpBudget && (!base || *m2H > *base)) {
base = m2H; base = m2H;
fromM2 = true; fromM2 = true;
} }
@ -382,8 +383,9 @@ void CameraController::update(float deltaTime) {
if (m2Renderer) { if (m2Renderer) {
m2H = m2Renderer->getFloorHeight(x, y, targetPos.z); m2H = m2Renderer->getFloorHeight(x, y, targetPos.z);
} }
auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f); float stepUpBudget = grounded ? 1.6f : 1.2f;
if (m2H && *m2H <= targetPos.z + 1.0f && (!base || *m2H > *base)) { auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, stepUpBudget);
if (m2H && *m2H <= targetPos.z + stepUpBudget && (!base || *m2H > *base)) {
base = m2H; base = m2H;
} }
return base; return base;
@ -393,7 +395,9 @@ void CameraController::update(float deltaTime) {
std::optional<float> groundH; std::optional<float> groundH;
constexpr float FOOTPRINT = 0.28f; constexpr float FOOTPRINT = 0.28f;
const glm::vec2 offsets[] = { const glm::vec2 offsets[] = {
{0.0f, 0.0f}, {FOOTPRINT, 0.0f}, {0.0f, FOOTPRINT} {0.0f, 0.0f},
{FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f},
{0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT}
}; };
for (const auto& o : offsets) { for (const auto& o : offsets) {
auto h = sampleGround(targetPos.x + o.x, targetPos.y + o.y); auto h = sampleGround(targetPos.x + o.x, targetPos.y + o.y);

View file

@ -24,11 +24,23 @@ void getTightCollisionBounds(const M2ModelGPU& model, glm::vec3& outMin, glm::ve
glm::vec3 half = (model.boundMax - model.boundMin) * 0.5f; glm::vec3 half = (model.boundMax - model.boundMin) * 0.5f;
// Per-shape collision fitting: // Per-shape collision fitting:
// - small solid props (boxes/crates/chests): tighter than full mesh, but
// larger than default to prevent walk-through on narrow objects
// - default: tighter fit (avoid oversized blockers) // - default: tighter fit (avoid oversized blockers)
// - stepped low platforms (tree curbs/planters): wider XY + lower Z // - stepped low platforms (tree curbs/planters): wider XY + lower Z
if (model.collisionSteppedLowPlatform) { if (model.collisionNarrowVerticalProp) {
half.x *= 0.92f; // Tall thin props (lamps/posts): keep passable gaps near walls.
half.y *= 0.92f; half.x *= 0.30f;
half.y *= 0.30f;
half.z *= 0.96f;
} else if (model.collisionSmallSolidProp) {
// Keep full tight mesh bounds for small solid props to avoid clip-through.
half.x *= 1.00f;
half.y *= 1.00f;
half.z *= 1.00f;
} else if (model.collisionSteppedLowPlatform) {
half.x *= 0.98f;
half.y *= 0.98f;
half.z *= 0.52f; half.z *= 0.52f;
} else { } else {
half.x *= 0.66f; half.x *= 0.66f;
@ -352,6 +364,11 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
bool isPlanter = (lowerName.find("planter") != std::string::npos); bool isPlanter = (lowerName.find("planter") != std::string::npos);
gpuModel.collisionPlanter = isPlanter; gpuModel.collisionPlanter = isPlanter;
bool smallSolidPropName =
(lowerName.find("crate") != std::string::npos) ||
(lowerName.find("box") != std::string::npos) ||
(lowerName.find("chest") != std::string::npos) ||
(lowerName.find("barrel") != std::string::npos);
bool foliageName = bool foliageName =
(lowerName.find("bush") != std::string::npos) || (lowerName.find("bush") != std::string::npos) ||
(lowerName.find("grass") != std::string::npos) || (lowerName.find("grass") != std::string::npos) ||
@ -373,6 +390,33 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
bool smallSoftShape = (horiz < 2.2f && vert < 2.4f); bool smallSoftShape = (horiz < 2.2f && vert < 2.4f);
bool mediumFoliageShape = (horiz < 4.5f && vert < 4.5f); bool mediumFoliageShape = (horiz < 4.5f && vert < 4.5f);
bool forceSolidCurb = gpuModel.collisionSteppedLowPlatform || knownStormwindPlanter || likelyCurbName || gpuModel.collisionPlanter; bool forceSolidCurb = gpuModel.collisionSteppedLowPlatform || knownStormwindPlanter || likelyCurbName || gpuModel.collisionPlanter;
bool narrowVerticalName =
(lowerName.find("lamp") != std::string::npos) ||
(lowerName.find("lantern") != std::string::npos) ||
(lowerName.find("post") != std::string::npos) ||
(lowerName.find("pole") != std::string::npos);
bool narrowVerticalShape =
(horiz > 0.12f && horiz < 2.0f && vert > 2.2f && vert > horiz * 1.8f);
gpuModel.collisionNarrowVerticalProp =
!gpuModel.collisionSteppedFountain &&
!gpuModel.collisionSteppedLowPlatform &&
(narrowVerticalName || narrowVerticalShape);
bool genericSolidPropShape =
(horiz > 0.6f && horiz < 6.0f && vert > 0.30f && vert < 4.0f && vert > horiz * 0.16f);
bool curbLikeName =
(lowerName.find("curb") != std::string::npos) ||
(lowerName.find("planter") != std::string::npos) ||
(lowerName.find("ring") != std::string::npos) ||
(lowerName.find("well") != std::string::npos) ||
(lowerName.find("base") != std::string::npos);
bool lowPlatformLikeShape = lowWideShape || lowPlatformShape;
gpuModel.collisionSmallSolidProp =
!gpuModel.collisionSteppedFountain &&
!gpuModel.collisionSteppedLowPlatform &&
!gpuModel.collisionNarrowVerticalProp &&
!curbLikeName &&
!lowPlatformLikeShape &&
(smallSolidPropName || (genericSolidPropShape && !foliageName && !softTree));
gpuModel.collisionNoBlock = ((((foliageName && smallSoftShape) || (foliageName && mediumFoliageShape)) || softTree) && gpuModel.collisionNoBlock = ((((foliageName && smallSoftShape) || (foliageName && mediumFoliageShape)) || softTree) &&
!forceSolidCurb); !forceSolidCurb);
} }
@ -881,8 +925,13 @@ std::optional<float> M2Renderer::getFloorHeight(float glX, float glY, float glZ)
glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f));
// Must be within doodad footprint in local XY. // Must be within doodad footprint in local XY.
if (localPos.x < localMin.x || localPos.x > localMax.x || // Stepped low platforms get a small pad so walk-up snapping catches edges.
localPos.y < localMin.y || localPos.y > localMax.y) { float footprintPad = 0.0f;
if (model.collisionSteppedLowPlatform) {
footprintPad = model.collisionPlanter ? 0.22f : 0.16f;
}
if (localPos.x < localMin.x - footprintPad || localPos.x > localMax.x + footprintPad ||
localPos.y < localMin.y - footprintPad || localPos.y > localMax.y + footprintPad) {
continue; continue;
} }
@ -892,7 +941,12 @@ std::optional<float> M2Renderer::getFloorHeight(float glX, float glY, float glZ)
glm::vec3 worldTop = glm::vec3(instance.modelMatrix * glm::vec4(localTop, 1.0f)); glm::vec3 worldTop = glm::vec3(instance.modelMatrix * glm::vec4(localTop, 1.0f));
// Reachability filter: allow a bit more climb for stepped low platforms. // Reachability filter: allow a bit more climb for stepped low platforms.
float maxStepUp = model.collisionSteppedLowPlatform ? (model.collisionPlanter ? 2.2f : 1.8f) : 1.0f; float maxStepUp = 1.0f;
if (model.collisionSmallSolidProp) {
maxStepUp = 2.0f;
} else if (model.collisionSteppedLowPlatform) {
maxStepUp = model.collisionPlanter ? 3.0f : 2.4f;
}
if (worldTop.z > glZ + maxStepUp) continue; if (worldTop.z > glZ + maxStepUp) continue;
if (!bestFloor || worldTop.z > *bestFloor) { if (!bestFloor || worldTop.z > *bestFloor) {
@ -938,13 +992,17 @@ bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to,
glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f));
glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f));
float localRadius = playerRadius / instance.scale; float radiusScale = model.collisionNarrowVerticalProp ? 0.45f : 1.0f;
float localRadius = (playerRadius * radiusScale) / instance.scale;
glm::vec3 rawMin, rawMax; glm::vec3 rawMin, rawMax;
getTightCollisionBounds(model, rawMin, rawMax); getTightCollisionBounds(model, rawMin, rawMax);
glm::vec3 localMin = rawMin - glm::vec3(localRadius); glm::vec3 localMin = rawMin - glm::vec3(localRadius);
glm::vec3 localMax = rawMax + glm::vec3(localRadius); glm::vec3 localMax = rawMax + glm::vec3(localRadius);
float effectiveTop = getEffectiveCollisionTopLocal(model, localPos, rawMin, rawMax) + localRadius; float effectiveTop = getEffectiveCollisionTopLocal(model, localPos, rawMin, rawMax) + localRadius;
glm::vec2 localCenter((localMin.x + localMax.x) * 0.5f, (localMin.y + localMax.y) * 0.5f);
float fromR = glm::length(glm::vec2(localFrom.x, localFrom.y) - localCenter);
float toR = glm::length(glm::vec2(localPos.x, localPos.y) - localCenter);
// Feet-based vertical overlap test: ignore objects fully above/below us. // Feet-based vertical overlap test: ignore objects fully above/below us.
constexpr float PLAYER_HEIGHT = 2.0f; constexpr float PLAYER_HEIGHT = 2.0f;
@ -952,15 +1010,38 @@ bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to,
continue; continue;
} }
bool fromInsideXY =
(localFrom.x >= localMin.x && localFrom.x <= localMax.x &&
localFrom.y >= localMin.y && localFrom.y <= localMax.y);
bool fromInsideZ = (localFrom.z + PLAYER_HEIGHT >= localMin.z && localFrom.z <= effectiveTop);
bool escapingOverlap = (fromInsideXY && fromInsideZ && (toR > fromR + 1e-4f));
bool allowEscapeRelax = escapingOverlap && !model.collisionSmallSolidProp;
// Swept hard clamp for taller blockers only. // Swept hard clamp for taller blockers only.
// Low/stepable objects should be climbable and not "shove" the player off. // Low/stepable objects should be climbable and not "shove" the player off.
float maxStepUp = model.collisionSteppedLowPlatform ? (model.collisionPlanter ? 2.8f : 2.4f) : 1.20f; float maxStepUp = 1.20f;
if (model.collisionSmallSolidProp) {
// Keep box/crate-class props hard-solid to prevent phase-through.
maxStepUp = 0.75f;
} else if (model.collisionSteppedLowPlatform) {
maxStepUp = model.collisionPlanter ? 2.8f : 2.4f;
}
bool stepableLowObject = (effectiveTop <= localFrom.z + maxStepUp); bool stepableLowObject = (effectiveTop <= localFrom.z + maxStepUp);
bool climbingAttempt = (localPos.z > localFrom.z + 0.18f); bool climbingAttempt = (localPos.z > localFrom.z + 0.18f);
bool nearTop = (localFrom.z >= effectiveTop - 0.30f); bool nearTop = (localFrom.z >= effectiveTop - 0.30f);
bool climbingTowardTop = climbingAttempt && (localFrom.z + (model.collisionPlanter ? 0.95f : 0.60f) >= effectiveTop); float climbAllowance = model.collisionPlanter ? 0.95f : 0.60f;
bool forceHardLateral = model.collisionSteppedLowPlatform && !model.collisionPlanter && !nearTop && !climbingTowardTop; if (model.collisionSteppedLowPlatform && !model.collisionPlanter) {
if (!stepableLowObject || forceHardLateral) { // Let low curb/planter blocks be stepable without sticky side shoves.
climbAllowance = 1.00f;
}
if (model.collisionSmallSolidProp) {
climbAllowance = 1.05f;
}
bool climbingTowardTop = climbingAttempt && (localFrom.z + climbAllowance >= effectiveTop);
bool forceHardLateral =
model.collisionSmallSolidProp &&
!nearTop && !climbingTowardTop;
if ((!stepableLowObject || forceHardLateral) && !allowEscapeRelax) {
float tEnter = 0.0f; float tEnter = 0.0f;
glm::vec3 sweepMax = localMax; glm::vec3 sweepMax = localMax;
sweepMax.z = std::min(sweepMax.z, effectiveTop); sweepMax.z = std::min(sweepMax.z, effectiveTop);
@ -986,14 +1067,19 @@ bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to,
float pushFront = localMax.y - localPos.y; float pushFront = localMax.y - localPos.y;
float minPush = std::min({pushLeft, pushRight, pushBack, pushFront}); float minPush = std::min({pushLeft, pushRight, pushBack, pushFront});
if (model.collisionSteppedLowPlatform && nearTop && stepableLowObject) { if (allowEscapeRelax) {
continue;
}
if (model.collisionSteppedLowPlatform && stepableLowObject) {
// Already on/near top surface: don't apply lateral push that ejects // Already on/near top surface: don't apply lateral push that ejects
// the player from the curb when landing. // the player from the object when landing.
continue; continue;
} }
// Gentle fallback push for overlapping cases. // Gentle fallback push for overlapping cases.
float pushAmount; float pushAmount;
if (model.collisionSteppedLowPlatform) { if (model.collisionNarrowVerticalProp) {
pushAmount = std::clamp(minPush * 0.10f, 0.001f, 0.010f);
} else if (model.collisionSteppedLowPlatform) {
if (model.collisionPlanter && stepableLowObject) { if (model.collisionPlanter && stepableLowObject) {
pushAmount = std::clamp(minPush * 0.06f, 0.001f, 0.006f); pushAmount = std::clamp(minPush * 0.06f, 0.001f, 0.006f);
} else { } else {

View file

@ -1067,6 +1067,10 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f));
glm::vec3 localTo = glm::vec3(instance.invModelMatrix * glm::vec4(to, 1.0f)); glm::vec3 localTo = glm::vec3(instance.invModelMatrix * glm::vec4(to, 1.0f));
float localFeetZ = localTo.z; float localFeetZ = localTo.z;
// Use a tiny Z threshold so ramp-side logic still triggers with
// smoothed ground snapping and small per-step vertical deltas.
bool steppingUp = (localTo.z > localFrom.z + 0.005f);
bool steppingDown = (localTo.z < localFrom.z - 0.005f);
for (const auto& group : model.groups) { for (const auto& group : model.groups) {
// Quick bounding box check // Quick bounding box check
@ -1100,6 +1104,10 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
// Get triangle Z range // Get triangle Z range
float triMinZ = std::min({v0.z, v1.z, v2.z}); float triMinZ = std::min({v0.z, v1.z, v2.z});
float triMaxZ = std::max({v0.z, v1.z, v2.z}); float triMaxZ = std::max({v0.z, v1.z, v2.z});
float fromDist = glm::dot(localFrom - v0, normal);
float toDist = glm::dot(localTo - v0, normal);
bool towardWallMotion = (std::abs(toDist) + 1e-4f < std::abs(fromDist));
bool awayFromWallMotion = !towardWallMotion;
// Only collide with walls in player's vertical range // Only collide with walls in player's vertical range
if (triMaxZ < localFeetZ + 0.3f) continue; if (triMaxZ < localFeetZ + 0.3f) continue;
@ -1112,17 +1120,56 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
// Ignore short near-vertical side strips around ramps/edges. // Ignore short near-vertical side strips around ramps/edges.
// These commonly act like invisible side guard rails. // These commonly act like invisible side guard rails.
float triHeight = triMaxZ - triMinZ; float triHeight = triMaxZ - triMinZ;
bool likelyRealWall =
(std::abs(normal.z) < 0.20f) &&
(triHeight > 2.4f || triMaxZ > localFeetZ + 2.6f);
bool structuralWall =
(triHeight > 1.6f) &&
(triMaxZ > localFeetZ + 1.8f);
if (std::abs(normal.z) < 0.25f && if (std::abs(normal.z) < 0.25f &&
triHeight < 1.8f && triHeight < 1.8f &&
triMaxZ <= localFeetZ + 1.4f) { triMaxZ <= localFeetZ + 1.4f) {
continue; continue;
} }
// Motion-aware permissive ramp side strips:
// keeps side entry/exit from behaving like invisible rails.
bool rampSideStrip = false;
if (steppingUp) {
rampSideStrip =
(std::abs(normal.z) > 0.02f && std::abs(normal.z) < 0.45f) &&
triMinZ <= localFeetZ + 0.30f &&
triHeight < 3.6f &&
triMaxZ <= localFeetZ + 2.8f;
} else if (steppingDown) {
rampSideStrip =
(std::abs(normal.z) > 0.02f && std::abs(normal.z) < 0.65f) &&
triMinZ <= localFeetZ + 0.45f &&
triHeight < 4.5f &&
triMaxZ <= localFeetZ + 3.8f;
}
// High on ramps, side triangles can span very tall strips and
// still behave like side rails. If we're stepping down and
// moving away from the wall, don't let them trap movement.
if (!rampSideStrip &&
steppingDown &&
awayFromWallMotion &&
std::abs(normal.z) > 0.02f && std::abs(normal.z) < 0.70f &&
triMinZ <= localFeetZ + 0.60f &&
triMaxZ <= localFeetZ + 4.5f &&
localFeetZ >= triMinZ + 0.80f) {
rampSideStrip = true;
}
if (rampSideStrip && !likelyRealWall && !structuralWall) {
continue;
}
// Let players run off ramp sides: ignore lower side-wall strips // Let players run off ramp sides: ignore lower side-wall strips
// that sit around foot height and are not true tall building walls. // that sit around foot height and are not true tall building walls.
if (std::abs(normal.z) < 0.45f && if (std::abs(normal.z) < 0.45f &&
std::abs(normal.z) > 0.05f &&
triMinZ <= localFeetZ + 0.20f && triMinZ <= localFeetZ + 0.20f &&
triHeight < 4.0f && triHeight < 4.0f &&
triMaxZ <= localFeetZ + 4.0f) { triMaxZ <= localFeetZ + 4.0f &&
!likelyRealWall && !structuralWall) {
continue; continue;
} }
@ -1130,10 +1177,22 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
if (triMaxZ <= localFeetZ + stepHeightLimit) continue; // Treat as step-up, not hard wall if (triMaxZ <= localFeetZ + stepHeightLimit) continue; // Treat as step-up, not hard wall
// Swept test: prevent tunneling when crossing a wall between frames. // Swept test: prevent tunneling when crossing a wall between frames.
float fromDist = glm::dot(localFrom - v0, normal); bool shortRampEdgeStrip =
float toDist = glm::dot(localTo - v0, normal); (steppingUp || steppingDown) &&
(std::abs(normal.z) > 0.01f && std::abs(normal.z) < (steppingDown ? 0.50f : 0.30f)) &&
triMinZ <= localFeetZ + (steppingDown ? 0.45f : 0.35f) &&
triHeight < (steppingDown ? 4.2f : 3.0f) &&
triMaxZ <= localFeetZ + (steppingDown ? 3.8f : 3.2f);
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)) {
// For true wall-like faces, always block segment crossing.
// Motion-direction heuristics are only for ramp-side stickiness.
if (!towardWallMotion && !likelyRealWall && !structuralWall) {
continue;
}
if (shortRampEdgeStrip && !likelyRealWall && !structuralWall) {
continue;
}
float denom = (fromDist - toDist); float denom = (fromDist - toDist);
if (std::abs(denom) > 1e-6f) { if (std::abs(denom) > 1e-6f) {
float tHit = fromDist / denom; // Segment param [0,1] float tHit = fromDist / denom; // Segment param [0,1]
@ -1172,8 +1231,19 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
pushDir2 = glm::normalize(n2); pushDir2 = glm::normalize(n2);
} }
// Soft pushback avoids hard side-snaps when skimming walls. // Softer push when stepping up near ramp side edges.
pushDist = std::min(0.06f, pushDist * 0.35f); bool rampEdgeLike = (std::abs(normal.z) < 0.45f && triHeight < 4.0f);
if (!towardWallMotion && !likelyRealWall && !structuralWall) continue;
if (shortRampEdgeStrip &&
!likelyRealWall && !structuralWall &&
std::abs(toDist) >= std::abs(fromDist) - PLAYER_RADIUS * 0.25f) continue;
float pushScale = 0.35f;
float pushCap = 0.06f;
if (rampEdgeLike && (steppingUp || steppingDown)) {
pushScale = steppingDown ? 0.08f : 0.12f;
pushCap = steppingDown ? 0.015f : 0.022f;
}
pushDist = std::min(pushCap, pushDist * pushScale);
if (pushDist <= 0.0f) continue; if (pushDist <= 0.0f) continue;
glm::vec3 pushLocal(pushDir2.x * pushDist, pushDir2.y * pushDist, 0.0f); glm::vec3 pushLocal(pushDir2.x * pushDist, pushDir2.y * pushDist, 0.0f);