#include "worldcollision.hh" #include #include // One-shot collision diagnostics /** * worldcollision.cpp - Player-vs-World Triangle Collision * * ALL math is 20.12 fixed-point. Intermediate products use int64_t * to avoid overflow (20.12 * 20.12 = 40.24, shift >>12 back to 20.12). * * Performance budget: ~256 triangle tests per frame on 33MHz MIPS. */ namespace psxsplash { // ============================================================================ // Fixed-point helpers (20.12) // ============================================================================ static constexpr int FRAC_BITS = 12; static constexpr int32_t FP_ONE = 1 << FRAC_BITS; // 4096 // Multiply two 20.12 values → 20.12 static inline int32_t fpmul(int32_t a, int32_t b) { return (int32_t)(((int64_t)a * b) >> FRAC_BITS); } // Fixed-point division: returns (a << 12) / b using only 32-bit DIV. // Uses remainder theorem: (a * 4096) / b = (a/b)*4096 + ((a%b)*4096)/b static inline int32_t fpdiv(int32_t a, int32_t b) { if (b == 0) return 0; int32_t q = a / b; int32_t r = a - q * b; // r * FP_ONE is safe when |r| < 524288 (which covers most game values) return q * FP_ONE + (r << FRAC_BITS) / b; } // Dot product of two 3-vectors in 20.12 static inline int32_t dot3(int32_t ax, int32_t ay, int32_t az, int32_t bx, int32_t by, int32_t bz) { return (int32_t)((((int64_t)ax * bx) + ((int64_t)ay * by) + ((int64_t)az * bz)) >> FRAC_BITS); } // Cross product components (each result is 20.12) static inline void cross3(int32_t ax, int32_t ay, int32_t az, int32_t bx, int32_t by, int32_t bz, int32_t& rx, int32_t& ry, int32_t& rz) { rx = (int32_t)(((int64_t)ay * bz - (int64_t)az * by) >> FRAC_BITS); ry = (int32_t)(((int64_t)az * bx - (int64_t)ax * bz) >> FRAC_BITS); rz = (int32_t)(((int64_t)ax * by - (int64_t)ay * bx) >> FRAC_BITS); } // Square root approximation via Newton's method (for 20.12 input) static int32_t fpsqrt(int32_t x) { if (x <= 0) return 0; // Initial guess: shift right by 6 (half of 12 fractional bits, then adjust) int32_t guess = x; // Rough initial guess if (x > FP_ONE * 16) guess = x >> 4; else if (x > FP_ONE) guess = x >> 2; else guess = FP_ONE; // Newton iterations: guess = (guess + x/guess) / 2 in fixed-point for (int i = 0; i < 8; i++) { if (guess == 0) return 0; int32_t div = fpdiv(x, guess); guess = (guess + div) >> 1; } return guess; } // Length squared of vector (result in 20.12, but represents a squared quantity) static inline int32_t lengthSq(int32_t x, int32_t y, int32_t z) { return dot3(x, y, z, x, y, z); } // Clamp value to [lo, hi] static inline int32_t fpclamp(int32_t val, int32_t lo, int32_t hi) { if (val < lo) return lo; if (val > hi) return hi; return val; } // ============================================================================ // Initialization // ============================================================================ const uint8_t* WorldCollision::initializeFromData(const uint8_t* data) { // Read header const auto* hdr = reinterpret_cast(data); m_header = *hdr; data += sizeof(CollisionDataHeader); // Mesh headers m_meshes = reinterpret_cast(data); data += m_header.meshCount * sizeof(CollisionMeshHeader); // Triangles m_triangles = reinterpret_cast(data); data += m_header.triangleCount * sizeof(CollisionTri); // Spatial chunks (exterior only) if (m_header.chunkGridW > 0 && m_header.chunkGridH > 0) { m_chunks = reinterpret_cast(data); data += m_header.chunkGridW * m_header.chunkGridH * sizeof(CollisionChunk); } else { m_chunks = nullptr; } return data; } // ============================================================================ // Broad phase: gather candidate meshes // ============================================================================ int WorldCollision::gatherCandidateMeshes(int32_t posX, int32_t posZ, uint8_t currentRoom, uint16_t* outIndices, int maxIndices) const { int count = 0; if (m_chunks && m_header.chunkGridW > 0) { // Exterior: spatial grid lookup // dividing two 20.12 values gives integer grid coords directly int cx = 0, cz = 0; if (m_header.chunkSize > 0) { cx = (posX - m_header.chunkOriginX) / m_header.chunkSize; cz = (posZ - m_header.chunkOriginZ) / m_header.chunkSize; } // Check 3x3 neighborhood for robustness for (int dz = -1; dz <= 1 && count < maxIndices; dz++) { for (int dx = -1; dx <= 1 && count < maxIndices; dx++) { int gx = cx + dx; int gz = cz + dz; if (gx < 0 || gx >= m_header.chunkGridW || gz < 0 || gz >= m_header.chunkGridH) continue; const auto& chunk = m_chunks[gz * m_header.chunkGridW + gx]; for (int i = 0; i < chunk.meshCount && count < maxIndices; i++) { uint16_t mi = chunk.firstMeshIndex + i; if (mi < m_header.meshCount) { // Deduplicate: check if already added bool dup = false; for (int k = 0; k < count; k++) { if (outIndices[k] == mi) { dup = true; break; } } if (!dup) { outIndices[count++] = mi; } } } } } } else { // Interior: filter by room index, or test all if no room system for (uint16_t i = 0; i < m_header.meshCount && count < maxIndices; i++) { if (currentRoom == 0xFF || m_meshes[i].roomIndex == currentRoom || m_meshes[i].roomIndex == 0xFF) { outIndices[count++] = i; } } } return count; } // ============================================================================ // AABB helpers // ============================================================================ bool WorldCollision::aabbOverlap(int32_t aMinX, int32_t aMinY, int32_t aMinZ, int32_t aMaxX, int32_t aMaxY, int32_t aMaxZ, int32_t bMinX, int32_t bMinY, int32_t bMinZ, int32_t bMaxX, int32_t bMaxY, int32_t bMaxZ) { return aMinX <= bMaxX && aMaxX >= bMinX && aMinY <= bMaxY && aMaxY >= bMinY && aMinZ <= bMaxZ && aMaxZ >= bMinZ; } void WorldCollision::sphereToAABB(int32_t cx, int32_t cy, int32_t cz, int32_t r, int32_t& minX, int32_t& minY, int32_t& minZ, int32_t& maxX, int32_t& maxY, int32_t& maxZ) { minX = cx - r; minY = cy - r; minZ = cz - r; maxX = cx + r; maxY = cy + r; maxZ = cz + r; } // ============================================================================ // Sphere vs Triangle (closest point approach) // ============================================================================ int32_t WorldCollision::sphereVsTriangle(int32_t cx, int32_t cy, int32_t cz, int32_t radius, const CollisionTri& tri, int32_t& outNx, int32_t& outNy, int32_t& outNz) const { // Compute vector from v0 to sphere center int32_t px = cx - tri.v0x; int32_t py = cy - tri.v0y; int32_t pz = cz - tri.v0z; // Project onto triangle plane using precomputed normal int32_t dist = dot3(px, py, pz, tri.nx, tri.ny, tri.nz); // Quick reject if too far from plane int32_t absDist = dist >= 0 ? dist : -dist; if (absDist > radius) return 0; // Find closest point on triangle to sphere center // Use barycentric coordinates via edge projections // Precompute edge dot products for barycentric coords int32_t d00 = dot3(tri.e1x, tri.e1y, tri.e1z, tri.e1x, tri.e1y, tri.e1z); int32_t d01 = dot3(tri.e1x, tri.e1y, tri.e1z, tri.e2x, tri.e2y, tri.e2z); int32_t d11 = dot3(tri.e2x, tri.e2y, tri.e2z, tri.e2x, tri.e2y, tri.e2z); int32_t d20 = dot3(px, py, pz, tri.e1x, tri.e1y, tri.e1z); int32_t d21 = dot3(px, py, pz, tri.e2x, tri.e2y, tri.e2z); // Barycentric denom using fpmul (stays in 32-bit) int32_t denom = fpmul(d00, d11) - fpmul(d01, d01); if (denom == 0) return 0; // Degenerate triangle // Barycentric numerators (32-bit via fpmul) int32_t uNum = fpmul(d11, d20) - fpmul(d01, d21); int32_t vNum = fpmul(d00, d21) - fpmul(d01, d20); // u, v in 20.12 using 32-bit division only int32_t u = fpdiv(uNum, denom); int32_t v = fpdiv(vNum, denom); // Clamp to triangle int32_t w = FP_ONE - u - v; int32_t closestX, closestY, closestZ; if (u >= 0 && v >= 0 && w >= 0) { // Point is inside triangle — closest point is the plane projection closestX = cx - fpmul(dist, tri.nx); closestY = cy - fpmul(dist, tri.ny); closestZ = cz - fpmul(dist, tri.nz); } else { // Point is outside triangle — find closest point on edges/vertices // Check all 3 edges and pick the closest point // v1 = v0 + e1, v2 = v0 + e2 int32_t v1x = tri.v0x + tri.e1x; int32_t v1y = tri.v0y + tri.e1y; int32_t v1z = tri.v0z + tri.e1z; int32_t v2x = tri.v0x + tri.e2x; int32_t v2y = tri.v0y + tri.e2y; int32_t v2z = tri.v0z + tri.e2z; int32_t bestDistSq = 0x7FFFFFFF; closestX = tri.v0x; closestY = tri.v0y; closestZ = tri.v0z; // Helper lambda: closest point on segment [A, B] to point P auto closestOnSeg = [&](int32_t ax, int32_t ay, int32_t az, int32_t bx, int32_t by, int32_t bz, int32_t& ox, int32_t& oy, int32_t& oz) { int32_t abx = bx - ax, aby = by - ay, abz = bz - az; int32_t apx = cx - ax, apy = cy - ay, apz = cz - az; int32_t abLen = dot3(abx, aby, abz, abx, aby, abz); if (abLen == 0) { ox = ax; oy = ay; oz = az; return; } int32_t dotAP = dot3(apx, apy, apz, abx, aby, abz); int32_t t = fpclamp(fpdiv(dotAP, abLen), 0, FP_ONE); ox = ax + fpmul(t, abx); oy = ay + fpmul(t, aby); oz = az + fpmul(t, abz); }; // Edge v0→v1 int32_t ex, ey, ez; closestOnSeg(tri.v0x, tri.v0y, tri.v0z, v1x, v1y, v1z, ex, ey, ez); int32_t dx = cx - ex, dy = cy - ey, dz = cz - ez; int32_t dsq = lengthSq(dx, dy, dz); if (dsq < bestDistSq) { bestDistSq = dsq; closestX = ex; closestY = ey; closestZ = ez; } // Edge v0→v2 closestOnSeg(tri.v0x, tri.v0y, tri.v0z, v2x, v2y, v2z, ex, ey, ez); dx = cx - ex; dy = cy - ey; dz = cz - ez; dsq = lengthSq(dx, dy, dz); if (dsq < bestDistSq) { bestDistSq = dsq; closestX = ex; closestY = ey; closestZ = ez; } // Edge v1→v2 closestOnSeg(v1x, v1y, v1z, v2x, v2y, v2z, ex, ey, ez); dx = cx - ex; dy = cy - ey; dz = cz - ez; dsq = lengthSq(dx, dy, dz); if (dsq < bestDistSq) { bestDistSq = dsq; closestX = ex; closestY = ey; closestZ = ez; } } // Compute vector from closest point to sphere center int32_t nx = cx - closestX; int32_t ny = cy - closestY; int32_t nz = cz - closestZ; // Use 64-bit for distance-squared comparison to avoid 20.12 underflow. // With small radii (e.g. radius=12 for 0.3m at GTE100), fpmul(12,12)=0 // because 144>>12=0. This caused ALL collisions to silently fail. // Both sides are in the same raw scale (no shift needed for comparison). int64_t rawDistSq = (int64_t)nx * nx + (int64_t)ny * ny + (int64_t)nz * nz; int64_t rawRadSq = (int64_t)radius * radius; if (rawDistSq >= rawRadSq || rawDistSq == 0) return 0; // For the actual distance value, use fpsqrt on the 20.12 representation. // If the 20.12 value underflows to 0, estimate from 64-bit. int32_t distSq32 = (int32_t)(rawDistSq >> FRAC_BITS); int32_t distance; if (distSq32 > 0) { distance = fpsqrt(distSq32); } else { // Very close collision - distance is sub-unit in 20.12. // Use triangle normal as push direction, penetration = radius. outNx = tri.nx; outNy = tri.ny; outNz = tri.nz; return radius; } if (distance == 0) { outNx = tri.nx; outNy = tri.ny; outNz = tri.nz; return radius; } // Normalize push direction using 32-bit division only outNx = fpdiv(nx, distance); outNy = fpdiv(ny, distance); outNz = fpdiv(nz, distance); return radius - distance; // Penetration depth } // ============================================================================ // Ray vs Triangle (Möller-Trumbore, fixed-point) // ============================================================================ int32_t WorldCollision::rayVsTriangle(int32_t ox, int32_t oy, int32_t oz, int32_t dx, int32_t dy, int32_t dz, const CollisionTri& tri) const { // h = cross(D, e2) int32_t hx, hy, hz; cross3(dx, dy, dz, tri.e2x, tri.e2y, tri.e2z, hx, hy, hz); // a = dot(e1, h) int32_t a = dot3(tri.e1x, tri.e1y, tri.e1z, hx, hy, hz); if (a > -COLLISION_EPSILON && a < COLLISION_EPSILON) return -1; // Ray parallel to triangle // f = 1/a — we'll defer the division by working with a as denominator // s = O - v0 int32_t sx = ox - tri.v0x; int32_t sy = oy - tri.v0y; int32_t sz = oz - tri.v0z; // u = f * dot(s, h) = dot(s, h) / a int32_t sh = dot3(sx, sy, sz, hx, hy, hz); // Check u in [0, 1]: sh/a must be in [0, a] if a > 0, or [a, 0] if a < 0 if (a > 0) { if (sh < 0 || sh > a) return -1; } else { if (sh > 0 || sh < a) return -1; } // q = cross(s, e1) int32_t qx, qy, qz; cross3(sx, sy, sz, tri.e1x, tri.e1y, tri.e1z, qx, qy, qz); // v = f * dot(D, q) = dot(D, q) / a int32_t dq = dot3(dx, dy, dz, qx, qy, qz); if (a > 0) { if (dq < 0 || sh + dq > a) return -1; } else { if (dq > 0 || sh + dq < a) return -1; } // t = f * dot(e2, q) = dot(e2, q) / a int32_t eq = dot3(tri.e2x, tri.e2y, tri.e2z, qx, qy, qz); // t in 20.12 using 32-bit division only int32_t t = fpdiv(eq, a); if (t < COLLISION_EPSILON) return -1; // Behind ray origin return t; } // ============================================================================ // High-level: moveAndSlide // ============================================================================ psyqo::Vec3 WorldCollision::moveAndSlide(const psyqo::Vec3& oldPos, const psyqo::Vec3& newPos, int32_t radius, uint8_t currentRoom) const { if (!isLoaded()) return newPos; int32_t posX = newPos.x.raw(); int32_t posY = newPos.y.raw(); int32_t posZ = newPos.z.raw(); // Gather candidate meshes uint16_t meshIndices[32]; int meshCount = gatherCandidateMeshes(posX, posZ, currentRoom, meshIndices, 32); // Sphere AABB for broad phase int32_t sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ; sphereToAABB(posX, posY, posZ, radius + COLLISION_EPSILON, sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ); int triTests = 0; int totalCollisions = 0; for (int iter = 0; iter < MAX_COLLISION_ITERATIONS; iter++) { bool collided = false; for (int mi = 0; mi < meshCount && triTests < MAX_TRI_TESTS_PER_FRAME; mi++) { const auto& mesh = m_meshes[meshIndices[mi]]; // Broad phase: sphere AABB vs mesh AABB if (!aabbOverlap(sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ, mesh.aabbMinX, mesh.aabbMinY, mesh.aabbMinZ, mesh.aabbMaxX, mesh.aabbMaxY, mesh.aabbMaxZ)) { continue; } for (int ti = 0; ti < mesh.triangleCount && triTests < MAX_TRI_TESTS_PER_FRAME; ti++) { const auto& tri = m_triangles[mesh.firstTriangle + ti]; triTests++; // Skip trigger surfaces if (tri.flags & SURFACE_TRIGGER) continue; // Skip floor and ceiling triangles — Y is resolved by nav regions. // In PS1 space (Y-down): floor normals have ny < 0, ceiling ny > 0. // If |ny| > walkable slope threshold, it's a floor/ceiling, not a wall. int32_t absNy = tri.ny >= 0 ? tri.ny : -tri.ny; if (absNy > WALKABLE_SLOPE_COS) continue; int32_t nx, ny, nz; int32_t pen = sphereVsTriangle(posX, posY, posZ, radius, tri, nx, ny, nz); if (pen > 0) { totalCollisions++; // Push out along normal posX += fpmul(pen + COLLISION_EPSILON, nx); posY += fpmul(pen + COLLISION_EPSILON, ny); posZ += fpmul(pen + COLLISION_EPSILON, nz); // Update sphere AABB sphereToAABB(posX, posY, posZ, radius + COLLISION_EPSILON, sMinX, sMinY, sMinZ, sMaxX, sMaxY, sMaxZ); collided = true; } } } if (!collided) break; } psyqo::Vec3 result; result.x.value = posX; result.y.value = posY; result.z.value = posZ; return result; } // ============================================================================ // High-level: groundTrace // ============================================================================ bool WorldCollision::groundTrace(const psyqo::Vec3& pos, int32_t maxDist, int32_t& groundY, int32_t& groundNormalY, uint8_t& surfaceFlags, uint8_t currentRoom) const { if (!isLoaded()) return false; int32_t ox = pos.x.raw(); int32_t oy = pos.y.raw(); int32_t oz = pos.z.raw(); // Ray direction: straight down (positive Y in PS1 space = down) int32_t dx = 0, dy = FP_ONE, dz = 0; uint16_t meshIndices[32]; int meshCount = gatherCandidateMeshes(ox, oz, currentRoom, meshIndices, 32); int32_t bestDist = maxDist; bool hit = false; for (int mi = 0; mi < meshCount; mi++) { const auto& mesh = m_meshes[meshIndices[mi]]; // Quick reject: check if mesh is below us if (mesh.aabbMinY > oy + maxDist) continue; if (mesh.aabbMaxY < oy) continue; if (ox < mesh.aabbMinX || ox > mesh.aabbMaxX) continue; if (oz < mesh.aabbMinZ || oz > mesh.aabbMaxZ) continue; for (int ti = 0; ti < mesh.triangleCount; ti++) { const auto& tri = m_triangles[mesh.firstTriangle + ti]; if (tri.flags & SURFACE_TRIGGER) continue; int32_t t = rayVsTriangle(ox, oy, oz, dx, dy, dz, tri); if (t >= 0 && t < bestDist) { bestDist = t; groundY = oy + t; // Hit point Y groundNormalY = tri.ny; surfaceFlags = tri.flags; hit = true; } } } return hit; } // ============================================================================ // High-level: ceilingTrace // ============================================================================ bool WorldCollision::ceilingTrace(const psyqo::Vec3& pos, int32_t playerHeight, int32_t& ceilingY, uint8_t currentRoom) const { if (!isLoaded()) return false; int32_t ox = pos.x.raw(); int32_t oy = pos.y.raw(); int32_t oz = pos.z.raw(); // Ray direction: straight up (negative Y in PS1 space) int32_t dx = 0, dy = -FP_ONE, dz = 0; uint16_t meshIndices[32]; int meshCount = gatherCandidateMeshes(ox, oz, currentRoom, meshIndices, 32); int32_t bestDist = playerHeight; bool hit = false; for (int mi = 0; mi < meshCount; mi++) { const auto& mesh = m_meshes[meshIndices[mi]]; if (mesh.aabbMaxY > oy) continue; if (mesh.aabbMinY < oy - playerHeight) continue; if (ox < mesh.aabbMinX || ox > mesh.aabbMaxX) continue; if (oz < mesh.aabbMinZ || oz > mesh.aabbMaxZ) continue; for (int ti = 0; ti < mesh.triangleCount; ti++) { const auto& tri = m_triangles[mesh.firstTriangle + ti]; if (tri.flags & SURFACE_TRIGGER) continue; int32_t t = rayVsTriangle(ox, oy, oz, dx, dy, dz, tri); if (t >= 0 && t < bestDist) { bestDist = t; ceilingY = oy - t; hit = true; } } } return hit; } // ============================================================================ // High-level: raycast (general purpose) // ============================================================================ bool WorldCollision::raycast(int32_t ox, int32_t oy, int32_t oz, int32_t dx, int32_t dy, int32_t dz, int32_t maxDist, CollisionHit& hit, uint8_t currentRoom) const { if (!isLoaded()) return false; uint16_t meshIndices[32]; int meshCount = gatherCandidateMeshes(ox, oz, currentRoom, meshIndices, 32); int32_t bestDist = maxDist; bool found = false; for (int mi = 0; mi < meshCount; mi++) { const auto& mesh = m_meshes[meshIndices[mi]]; for (uint16_t ti = 0; ti < mesh.triangleCount; ti++) { uint16_t triIdx = mesh.firstTriangle + ti; const auto& tri = m_triangles[triIdx]; int32_t t = rayVsTriangle(ox, oy, oz, dx, dy, dz, tri); if (t >= 0 && t < bestDist) { bestDist = t; hit.pointX = ox + fpmul(t, dx); hit.pointY = oy + fpmul(t, dy); hit.pointZ = oz + fpmul(t, dz); hit.normalX = tri.nx; hit.normalY = tri.ny; hit.normalZ = tri.nz; hit.distance = t; hit.triangleIndex = triIdx; hit.surfaceFlags = tri.flags; found = true; } } } return found; } } // namespace psxsplash