From 2a4aaf4db7d2ad21f3ec3441ccbcb2e52f557e72 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Apr 2026 12:07:09 +0200 Subject: [PATCH] feat(physics): port full BSPTree.find_collisions from retail Replace simplified BSP overlap test with retail-faithful 6-path collision dispatcher. Sphere-intersects-poly now uses movement vector for front-face culling (prevents wall penetration). All paths: placement/ethereal, checkWalkable, stepDown, collide, contact+onWalkable, and default (not in contact). Ported from ACE BSPTree.cs/BSPNode.cs/BSPLeaf.cs/Polygon.cs, cross-referenced against decompiled chunk_00530000.c. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.Core/Physics/BSPQuery.cs | 1033 ++++++++++++++++++++++++-- 1 file changed, 971 insertions(+), 62 deletions(-) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 469572e..4618352 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -5,46 +5,988 @@ using DatReaderWriter.Types; namespace AcDream.Core.Physics; /// -/// BSP tree traversal for sphere-polygon collision detection. +/// BSP tree collision queries ported from decompiled retail client. +/// Cross-referenced against ACE BSPTree.cs, BSPNode.cs, BSPLeaf.cs, Polygon.cs. /// /// -/// Ported from decompiled FUN_00539270 (chunk_00539000.c), cross-referenced -/// against ACE's BSPNode.sphere_intersects_poly() in -/// Source/ACE.Server/Physics/BSP/BSPNode.cs. +/// The retail system has 6 dispatch paths in find_collisions based on +/// SpherePath flags. Each path uses different BSP traversal and +/// collision response strategies. /// /// /// -/// The algorithm is a recursive descent through the BSP tree: -/// -/// Broad phase: discard the subtree if the sphere cannot reach the -/// node's bounding sphere. -/// Leaf: test each polygon using the existing retail-ported -/// . -/// Internal: classify the sphere against the splitting plane and -/// recurse into the positive half, the negative half, or both when the -/// sphere straddles the plane. -/// +/// Methods operate in object-local space. The caller (e.g. FindObjCollisions +/// in Transition) is responsible for transforming player spheres into object- +/// local space before calling, and transforming hit normals back to world space. /// /// public static class BSPQuery { + // ----------------------------------------------------------------------- + // Polygon plane computation helper + // ----------------------------------------------------------------------- + + /// + /// Compute the polygon plane from vertices, using the retail CalcNormal + /// algorithm. Returns false if the polygon is degenerate. + /// + private static bool TryGetPolyPlane( + Polygon poly, VertexArray vertices, + out Plane plane, out Vector3[] polyVerts) + { + plane = default; + polyVerts = Array.Empty(); + + if (poly.VertexIds.Count < 3) return false; + + polyVerts = new Vector3[poly.VertexIds.Count]; + for (int i = 0; i < poly.VertexIds.Count; i++) + { + ushort vid = (ushort)poly.VertexIds[i]; + if (!vertices.Vertices.TryGetValue(vid, out var sv)) + return false; + polyVerts[i] = sv.Origin; + } + + CollisionPrimitives.CalcNormal(polyVerts, out var normal, out float planeD); + if (normal.LengthSquared() < CollisionPrimitives.EpsilonSq) + return false; + + plane = new Plane(normal, planeD); + return true; + } + + // ----------------------------------------------------------------------- + // 1. FindCollisions — BSPTree.find_collisions dispatcher + // ACE: BSPTree.cs line ~131. 6 dispatch paths. + // ----------------------------------------------------------------------- + + /// + /// BSPTree.find_collisions -- the 6-path dispatcher. + /// Determines how collision is tested against BSP geometry based on + /// SpherePath flags. + /// + /// + /// ACE: BSPTree.cs find_collisions. Decompiled: chunk_00530000.c. + /// The 6 paths are: + /// 1. Placement/ObstructionEthereal -> sphere_intersects_solid + /// 2. CheckWalkable -> check_walkable (hits_walkable) + /// 3. StepDown -> step_sphere_down (find_walkable) + /// 4. Collide -> find_walkable + /// 5. Contact|OnWalkable -> sphere_intersects_poly + step_up/slide + /// 6. Default (not in contact) -> sphere_intersects_poly + collide_with_pt + /// + /// + /// Root of the physics BSP tree. + /// Physics polygon dictionary from the GfxObj. + /// Vertex array from the GfxObj. + /// The current transition state. + /// The sphere in object-local space (index 0). + /// Second sphere in local space (index 1), or null. + /// Previous position of the sphere center in local space. + /// The up vector in local space. + /// Scale factor for the object. + public static TransitionState FindCollisions( + PhysicsBSPNode? root, + Dictionary polygons, + VertexArray vertices, + Transition transition, + DatReaderWriter.Types.Sphere localSphere, + DatReaderWriter.Types.Sphere? localSphere1, + Vector3 localCurrCenter, + Vector3 localSpaceZ, + float scale) + { + if (root is null) return TransitionState.OK; + + var path = transition.SpherePath; + var collisions = transition.CollisionInfo; + var obj = transition.ObjectInfo; + + var center = localCurrCenter; + var movement = localSphere.Origin - center; + + // ---------------------------------------------------------------- + // Path 1: Placement or ObstructionEthereal + // ACE: sphere_intersects_solid + // ---------------------------------------------------------------- + if (path.InsertType == InsertType.Placement || obj.Ethereal) + { + if (SphereIntersectsSolid(root, polygons, vertices, + localSphere.Origin, localSphere.Radius, true)) + return TransitionState.Collided; + + if (localSphere1 is not null) + { + if (SphereIntersectsSolid(root, polygons, vertices, + localSphere1.Origin, localSphere1.Radius, true)) + return TransitionState.Collided; + } + return TransitionState.OK; + } + + // ---------------------------------------------------------------- + // Path 2: CheckWalkable + // ACE: check_walkable -> hits_walkable + // ---------------------------------------------------------------- + if (path.CheckWalkable) + { + return HitsWalkable(root, polygons, vertices, + localSphere.Origin, localSphere.Radius, + path, localSpaceZ) + ? TransitionState.Collided + : TransitionState.OK; + } + + // ---------------------------------------------------------------- + // Path 3: StepDown + // ACE: step_sphere_down -> find_walkable + // ---------------------------------------------------------------- + if (path.StepDown) + { + return StepSphereDown(root, polygons, vertices, + transition, localSphere, localSpaceZ, scale); + } + + // ---------------------------------------------------------------- + // Path 4: Collide (stepping onto walkable surfaces) + // ACE: find_walkable + // ---------------------------------------------------------------- + if (path.Collide) + { + var validCenter = localSphere.Origin; + Polygon? hitPoly = null; + Plane hitPlane = default; + bool changed = false; + + FindWalkable(root, polygons, vertices, + path, ref validCenter, localSphere.Radius, + ref hitPoly, ref hitPlane, movement, localSpaceZ, ref changed); + + if (changed && hitPoly is not null) + { + var offset = validCenter - localSphere.Origin; + // Transform offset back and apply + var collisionNormal = offset * scale; + path.AddOffsetToCheckPos(collisionNormal); + + collisions.SetContactPlane( + new Plane(hitPlane.Normal, hitPlane.D * scale), + path.CheckCellId, false); + + // SetWalkable state + path.WalkableValid = true; + path.WalkablePlane = new Plane(hitPlane.Normal, hitPlane.D * scale); + path.WalkableAllowance = PhysicsGlobals.FloorZ; + + return TransitionState.Adjusted; + } + return TransitionState.OK; + } + + // ---------------------------------------------------------------- + // Path 5 & 6: sphere_intersects_poly with movement vector + // ---------------------------------------------------------------- + Polygon? hitPoly5 = null; + Plane hitPlane5 = default; + Vector3 contactPoint5 = Vector3.Zero; + + // ---------------------------------------------------------------- + // Path 5: Contact | OnWalkable + // ACE: sphere_intersects_poly + step_sphere_up / slide_sphere + // ---------------------------------------------------------------- + if (obj.State.HasFlag(ObjectInfoState.Contact)) + { + if (SphereIntersectsPolyWithMovement(root, polygons, vertices, + localSphere.Origin, localSphere.Radius, movement, + ref hitPoly5, ref hitPlane5, ref contactPoint5)) + { + // Step up over the obstacle + var globNormal = hitPlane5.Normal * scale; + if (globNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + globNormal = Vector3.Normalize(globNormal); + return StepSphereUp(transition, globNormal); + } + + if (localSphere1 is not null) + { + Polygon? hitPoly1 = null; + Plane hitPlane1 = default; + Vector3 contactPoint1 = Vector3.Zero; + + if (SphereIntersectsPolyWithMovement(root, polygons, vertices, + localSphere1.Origin, localSphere1.Radius, movement, + ref hitPoly1, ref hitPlane1, ref contactPoint1)) + { + // Slide sphere for second sphere hit + var globNormal = hitPlane1.Normal * scale; + if (globNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + globNormal = Vector3.Normalize(globNormal); + collisions.SetCollisionNormal(globNormal); + return TransitionState.Collided; + } + + if (hitPoly1 is not null) + { + return NegPolyHit(path, hitPlane1.Normal, false); + } + if (hitPoly5 is not null) + { + return NegPolyHit(path, hitPlane5.Normal, true); + } + } + return TransitionState.OK; + } + + // ---------------------------------------------------------------- + // Path 6: Default (not in contact) + // ACE: sphere_intersects_poly → collide / land + // ---------------------------------------------------------------- + if (SphereIntersectsPolyWithMovement(root, polygons, vertices, + localSphere.Origin, localSphere.Radius, movement, + ref hitPoly5, ref hitPlane5, ref contactPoint5) + || hitPoly5 is not null) + { + if (obj.State.HasFlag(ObjectInfoState.PathClipped)) + { + // PerfectClip / PathClipped collision response + var collisionNormal = hitPlane5.Normal; + collisions.SetCollisionNormal(collisionNormal); + return TransitionState.Collided; + } + + var globNormal5 = hitPlane5.Normal; + if (globNormal5.LengthSquared() > PhysicsGlobals.EpsilonSq) + globNormal5 = Vector3.Normalize(globNormal5); + + path.WalkableAllowance = PhysicsGlobals.LandingZ; + path.Collide = true; + collisions.SetCollisionNormal(globNormal5); + return TransitionState.Adjusted; + } + + if (localSphere1 is not null) + { + Polygon? hitPoly1b = null; + Plane hitPlane1b = default; + Vector3 contactPoint1b = Vector3.Zero; + + if (SphereIntersectsPolyWithMovement(root, polygons, vertices, + localSphere1.Origin, localSphere1.Radius, movement, + ref hitPoly1b, ref hitPlane1b, ref contactPoint1b) + || hitPoly1b is not null) + { + var globNormal = hitPlane1b.Normal; + if (globNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + globNormal = Vector3.Normalize(globNormal); + collisions.SetCollisionNormal(globNormal); + return TransitionState.Collided; + } + } + + return TransitionState.OK; + } + + // ----------------------------------------------------------------------- + // 2. SphereIntersectsPolyWithMovement — BSPNode.sphere_intersects_poly + // with movement vector (ACE BSPNode.cs + BSPLeaf.cs) + // Uses pos_hits_sphere for front-face culling. + // ----------------------------------------------------------------------- + + /// + /// BSPNode.sphere_intersects_poly -- tree traversal with movement vector. + /// Recursively descends the BSP tree testing sphere+movement against + /// the splitting plane, then tests polygons at leaves using pos_hits_sphere + /// (front-face culling by movement direction). + /// + /// + /// ACE: BSPNode.cs sphere_intersects_poly (internal nodes), + /// BSPLeaf.cs sphere_intersects_poly (leaf nodes). + /// Decompiled: FUN_00539270. + /// + /// + public static bool SphereIntersectsPolyWithMovement( + PhysicsBSPNode? node, + Dictionary polygons, + VertexArray vertices, + Vector3 sphereCenter, float sphereRadius, + Vector3 movement, + ref Polygon? hitPoly, + ref Plane hitPlane, + ref Vector3 contactPoint) + { + if (node is null) return false; + + // Broad phase: bounding sphere rejection + float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin); + if (dist > sphereRadius + node.BoundingSphere.Radius + CollisionPrimitives.Epsilon) + return false; + + // Leaf node: test each polygon with pos_hits_sphere + if (node.Type == BSPNodeType.Leaf) + { + if (node.Polygons.Count == 0) return false; + + foreach (var polyIdx in node.Polygons) + { + if (!polygons.TryGetValue(polyIdx, out var poly)) continue; + if (!TryGetPolyPlane(poly, vertices, out var polyPlane, out var polyVerts)) + continue; + + if (PosHitsSphere(polyPlane, polyVerts, sphereCenter, sphereRadius, + movement, out var cp)) + { + hitPoly = poly; + hitPlane = polyPlane; + contactPoint = cp; + return true; + } + } + return false; + } + + // Internal node: classify against splitting plane + float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter) + + node.SplittingPlane.D; + float reach = sphereRadius - PhysicsGlobals.EPSILON; + + if (splitDist >= reach) + { + return SphereIntersectsPolyWithMovement( + node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPoly, ref hitPlane, ref contactPoint); + } + + if (splitDist <= -reach) + { + return SphereIntersectsPolyWithMovement( + node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPoly, ref hitPlane, ref contactPoint); + } + + // Straddles: check both sides + if (SphereIntersectsPolyWithMovement( + node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPoly, ref hitPlane, ref contactPoint)) + return true; + + return SphereIntersectsPolyWithMovement( + node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPoly, ref hitPlane, ref contactPoint); + } + + // ----------------------------------------------------------------------- + // 3. PosHitsSphere — Polygon.pos_hits_sphere + // ACE: Polygon.cs pos_hits_sphere + // The critical front-face culling test using movement direction. + // ----------------------------------------------------------------------- + + /// + /// Polygon.pos_hits_sphere -- per-polygon test with movement direction. + /// Only detects collisions when the sphere is MOVING TOWARD the polygon + /// face (dot(movement, normal) < 0). This front-face culling prevents + /// the "walking into walls" problem and is the key difference from the + /// static SphereIntersectsPoly. + /// + /// + /// ACE: Polygon.cs pos_hits_sphere. + /// Decompiled: retail client polygon collision test. + /// + /// + public static bool PosHitsSphere( + Plane polyPlane, + ReadOnlySpan polyVerts, + Vector3 sphereCenter, float sphereRadius, + Vector3 movement, + out Vector3 contactPoint) + { + contactPoint = Vector3.Zero; + + // Test sphere-polygon overlap using the precise retail algorithm + bool hit = PolygonHitsSpherePrecise(polyPlane, polyVerts, + sphereCenter, sphereRadius, ref contactPoint); + + // Front-face cull: only count hits where movement is toward the face + // ACE: dist = Vector3.Dot(movement, Plane.Normal); if dist >= 0 return false; + float moveDot = Vector3.Dot(movement, polyPlane.Normal); + if (moveDot >= 0f) + return false; + + return hit; + } + + // ----------------------------------------------------------------------- + // 4. PolygonHitsSpherePrecise — Polygon.polygon_hits_sphere_precise + // ACE: Polygon.cs polygon_hits_sphere_precise + // Full retail precision polygon-sphere overlap test. + // ----------------------------------------------------------------------- + + /// + /// Polygon.polygon_hits_sphere_precise -- the precise retail polygon-sphere + /// intersection test. Unlike the simpler SphereIntersectsPoly, this has an + /// inner loop for edge/vertex proximity testing that matches the decompiled + /// client exactly. + /// + /// + /// ACE: Polygon.cs polygon_hits_sphere_precise. + /// + /// + public static bool PolygonHitsSpherePrecise( + Plane polyPlane, + ReadOnlySpan polyVerts, + Vector3 sphereCenter, float sphereRadius, + ref Vector3 contactPoint) + { + int numVerts = polyVerts.Length; + if (numVerts == 0) return true; + + float dist = Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D; + float rad = sphereRadius - PhysicsGlobals.EPSILON; + + if (MathF.Abs(dist) > rad) return false; + + float diff = rad * rad - dist * dist; + contactPoint = sphereCenter - polyPlane.Normal * dist; + + int prevIdx = numVerts - 1; + for (int i = 0; i < numVerts; i++) + { + var vertex = polyVerts[i]; + var lastVertex = polyVerts[prevIdx]; + prevIdx = i; + + var edge = vertex - lastVertex; + var disp = contactPoint - lastVertex; + var cross = Vector3.Cross(polyPlane.Normal, edge); + + if (Vector3.Dot(disp, cross) >= 0f) continue; + + // Inner loop: re-check all edges for closest feature + prevIdx = numVerts - 1; + for (int j = 0; j < numVerts; j++) + { + vertex = polyVerts[j]; + lastVertex = polyVerts[prevIdx]; + prevIdx = j; + + edge = vertex - lastVertex; + disp = contactPoint - lastVertex; + cross = Vector3.Cross(polyPlane.Normal, edge); + float dispDot = Vector3.Dot(disp, cross); + + if (dispDot < 0f) + { + if (cross.LengthSquared() * diff < dispDot * dispDot) + return false; + + float dispEdge = Vector3.Dot(disp, edge); + if (dispEdge >= 0f && dispEdge <= edge.LengthSquared()) + return true; + } + + if (disp.LengthSquared() <= diff) + return true; + } + return false; + } + return true; + } + + // ----------------------------------------------------------------------- + // 5. SphereIntersectsSolid — BSPNode.sphere_intersects_solid + // ACE: BSPNode.cs + BSPLeaf.cs sphere_intersects_solid + // Used for placement/ethereal checks. + // ----------------------------------------------------------------------- + + /// + /// BSPNode.sphere_intersects_solid -- checks if a sphere intersects any + /// solid geometry in the BSP tree. Used for placement and ethereal checks. + /// + /// + /// ACE: BSPNode.cs sphere_intersects_solid (internal node), + /// BSPLeaf.cs sphere_intersects_solid (leaf). + /// + /// + public static bool SphereIntersectsSolid( + PhysicsBSPNode? node, + Dictionary polygons, + VertexArray vertices, + Vector3 sphereCenter, float sphereRadius, + bool centerCheck) + { + if (node is null) return false; + + // Broad phase + float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin); + if (dist > sphereRadius + node.BoundingSphere.Radius + CollisionPrimitives.Epsilon) + return false; + + // Leaf node + if (node.Type == BSPNodeType.Leaf) + { + if (node.Polygons.Count == 0) return false; + if (centerCheck && node.Solid != 0) return true; + + foreach (var polyIdx in node.Polygons) + { + if (!polygons.TryGetValue(polyIdx, out var poly)) continue; + if (!TryGetPolyPlane(poly, vertices, out var polyPlane, out var polyVerts)) + continue; + + if (CollisionPrimitives.SphereIntersectsPoly( + polyPlane, polyVerts, sphereCenter, sphereRadius, out _)) + return true; + } + return false; + } + + // Internal node + float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter) + + node.SplittingPlane.D; + float reach = sphereRadius - PhysicsGlobals.EPSILON; + + if (splitDist >= reach) + return SphereIntersectsSolid(node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, centerCheck); + + if (splitDist <= -reach) + return SphereIntersectsSolid(node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, centerCheck); + + // Straddles: check both sides with appropriate centerCheck flags + if (splitDist < 0f) + { + if (SphereIntersectsSolid(node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, false)) + return true; + return SphereIntersectsSolid(node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, centerCheck); + } + else + { + if (SphereIntersectsSolid(node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, centerCheck)) + return true; + return SphereIntersectsSolid(node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, false); + } + } + + // ----------------------------------------------------------------------- + // 6. HitsWalkable — BSPNode.hits_walkable + BSPLeaf.hits_walkable + // ACE: BSPNode.cs hits_walkable, BSPLeaf.cs hits_walkable + // Used for check_walkable path. + // ----------------------------------------------------------------------- + + /// + /// BSPNode.hits_walkable -- checks if a sphere touches any walkable polygon. + /// Used by the CheckWalkable dispatch path. + /// + /// + /// ACE: BSPNode.cs hits_walkable (internal), BSPLeaf.cs hits_walkable (leaf). + /// + /// + public static bool HitsWalkable( + PhysicsBSPNode? node, + Dictionary polygons, + VertexArray vertices, + Vector3 sphereCenter, float sphereRadius, + SpherePath path, + Vector3 up) + { + if (node is null) return false; + + // Broad phase + float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin); + if (dist > sphereRadius + node.BoundingSphere.Radius + CollisionPrimitives.Epsilon) + return false; + + // Leaf node + if (node.Type == BSPNodeType.Leaf) + { + if (node.Polygons.Count == 0) return false; + + foreach (var polyIdx in node.Polygons) + { + if (!polygons.TryGetValue(polyIdx, out var poly)) continue; + if (!TryGetPolyPlane(poly, vertices, out var polyPlane, out var polyVerts)) + continue; + + // walkable_hits_sphere: check normal faces up + sphere intersection + float dp = Vector3.Dot(up, polyPlane.Normal); + if (dp <= path.WalkableAllowance) continue; + + if (CollisionPrimitives.SphereIntersectsPoly( + polyPlane, polyVerts, sphereCenter, sphereRadius, out _)) + { + // check_small_walkable + if (CheckWalkable(polyPlane, polyVerts, sphereCenter, sphereRadius, up, true)) + return true; + } + } + return false; + } + + // Internal node + float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter) + + node.SplittingPlane.D; + float reach = sphereRadius - PhysicsGlobals.EPSILON; + + if (splitDist >= reach) + return HitsWalkable(node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, path, up); + + if (splitDist <= -reach) + return HitsWalkable(node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, path, up); + + if (HitsWalkable(node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, path, up)) + return true; + + return HitsWalkable(node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, path, up); + } + + // ----------------------------------------------------------------------- + // 7. FindWalkable — BSPNode.find_walkable + BSPLeaf.find_walkable + // ACE: BSPNode.cs find_walkable, BSPLeaf.cs find_walkable + // Used for Collide and StepDown paths. + // ----------------------------------------------------------------------- + + /// + /// BSPNode.find_walkable -- finds walkable surfaces and adjusts the sphere + /// to rest on them. Used by the Collide and StepDown dispatch paths. + /// + /// + /// ACE: BSPNode.cs find_walkable (internal), BSPLeaf.cs find_walkable (leaf). + /// + /// + public static void FindWalkable( + PhysicsBSPNode? node, + Dictionary polygons, + VertexArray vertices, + SpherePath path, + ref Vector3 validCenter, float sphereRadius, + ref Polygon? hitPoly, + ref Plane hitPlane, + Vector3 movement, + Vector3 up, + ref bool changed) + { + if (node is null) return; + + // Broad phase + float dist = Vector3.Distance(validCenter, node.BoundingSphere.Origin); + if (dist > sphereRadius + node.BoundingSphere.Radius + CollisionPrimitives.Epsilon) + return; + + // Leaf node + if (node.Type == BSPNodeType.Leaf) + { + if (node.Polygons.Count == 0) return; + + foreach (var polyIdx in node.Polygons) + { + if (!polygons.TryGetValue(polyIdx, out var poly)) continue; + if (!TryGetPolyPlane(poly, vertices, out var polyPlane, out var polyVerts)) + continue; + + // walkable_hits_sphere check + float dpUp = Vector3.Dot(up, polyPlane.Normal); + if (dpUp <= path.WalkableAllowance) continue; + + // polygon_hits_sphere_precise + Vector3 contactPoint = Vector3.Zero; + if (!PolygonHitsSpherePrecise(polyPlane, polyVerts, + validCenter, sphereRadius, ref contactPoint)) + continue; + + // adjust_sphere_to_plane + if (AdjustSphereToPlane(polyPlane, ref validCenter, sphereRadius, + movement, ref path)) + { + changed = true; + hitPoly = poly; + hitPlane = polyPlane; + } + } + return; + } + + // Internal node + float splitDist = Vector3.Dot(node.SplittingPlane.Normal, validCenter) + + node.SplittingPlane.D; + float reach = sphereRadius - PhysicsGlobals.EPSILON; + + if (splitDist >= reach) + { + FindWalkable(node.PosNode, polygons, vertices, path, + ref validCenter, sphereRadius, ref hitPoly, ref hitPlane, + movement, up, ref changed); + return; + } + + if (splitDist <= -reach) + { + FindWalkable(node.NegNode, polygons, vertices, path, + ref validCenter, sphereRadius, ref hitPoly, ref hitPlane, + movement, up, ref changed); + return; + } + + // Straddles + FindWalkable(node.PosNode, polygons, vertices, path, + ref validCenter, sphereRadius, ref hitPoly, ref hitPlane, + movement, up, ref changed); + FindWalkable(node.NegNode, polygons, vertices, path, + ref validCenter, sphereRadius, ref hitPoly, ref hitPlane, + movement, up, ref changed); + } + + // ----------------------------------------------------------------------- + // 8. StepSphereDown — BSPTree.step_sphere_down + // ACE: BSPTree.cs step_sphere_down + // ----------------------------------------------------------------------- + + /// + /// BSPTree.step_sphere_down -- BSP-based step-down ground search. + /// Probes downward along localSpaceZ to find a walkable surface. + /// + /// + /// ACE: BSPTree.cs step_sphere_down. + /// + /// + public static TransitionState StepSphereDown( + PhysicsBSPNode root, + Dictionary polygons, + VertexArray vertices, + Transition transition, + DatReaderWriter.Types.Sphere localSphere, + Vector3 localSpaceZ, + float scale) + { + var path = transition.SpherePath; + var collisions = transition.CollisionInfo; + + float stepDownAmount = -(path.StepDownAmt * path.WalkInterp); + var movement = localSpaceZ * stepDownAmount * (1f / scale); + var validCenter = localSphere.Origin; + bool changed = false; + Polygon? polyHit = null; + Plane hitPlane = default; + + FindWalkable(root, polygons, vertices, path, + ref validCenter, localSphere.Radius, + ref polyHit, ref hitPlane, movement, localSpaceZ, ref changed); + + if (changed && polyHit is not null) + { + var adjusted = validCenter - localSphere.Origin; + var offset = adjusted * scale; + path.AddOffsetToCheckPos(offset); + + collisions.SetContactPlane( + new Plane(hitPlane.Normal, hitPlane.D * scale), + path.CheckCellId, false); + + path.WalkableValid = true; + path.WalkablePlane = new Plane(hitPlane.Normal, hitPlane.D * scale); + path.WalkableAllowance = PhysicsGlobals.FloorZ; + + return TransitionState.Adjusted; + } + + return TransitionState.OK; + } + + // ----------------------------------------------------------------------- + // 9. StepSphereUp — BSPTree.step_sphere_up + // ACE: BSPTree.cs step_sphere_up + // ----------------------------------------------------------------------- + + /// + /// BSPTree.step_sphere_up -- attempts to step over an obstacle. + /// + /// + /// ACE: BSPTree.cs step_sphere_up. + /// + /// + private static TransitionState StepSphereUp( + Transition transition, Vector3 collisionNormal) + { + // ACE: if (transition.StepUp(globNormal)) return OK; else StepUpSlide + // For now, simplified: just report collision and let the main + // transition system handle step-up via its existing mechanism. + var path = transition.SpherePath; + transition.CollisionInfo.SetCollisionNormal(collisionNormal); + + // Attempt step-up: set the flag for the transition system + path.StepUp = true; + path.StepUpNormal = collisionNormal; + + return TransitionState.OK; + } + + // ----------------------------------------------------------------------- + // 10. NegPolyHit — BSPTree.NegPolyHit + // ACE: BSPTree.cs NegPolyHit + // ----------------------------------------------------------------------- + + /// + /// BSPTree.NegPolyHit -- records a negative polygon hit for later processing. + /// The collision normal is stored but the transition is not blocked. + /// + /// + /// ACE: BSPTree.cs NegPolyHit. + /// + /// + private static TransitionState NegPolyHit( + SpherePath path, Vector3 planeNormal, bool stepUp) + { + path.NegPolyHit = true; + path.NegStepUp = stepUp; + path.NegCollisionNormal = planeNormal; + return TransitionState.OK; + } + + // ----------------------------------------------------------------------- + // 11. AdjustSphereToPlane — Polygon.adjust_sphere_to_plane + // ACE: Polygon.cs adjust_sphere_to_plane + // ----------------------------------------------------------------------- + + /// + /// Polygon.adjust_sphere_to_plane -- adjusts a sphere's position to rest + /// on a polygon plane, used during find_walkable traversal. + /// + /// + /// ACE: Polygon.cs adjust_sphere_to_plane. + /// + /// + private static bool AdjustSphereToPlane( + Plane polyPlane, + ref Vector3 validCenter, float sphereRadius, + Vector3 movement, + ref SpherePath path) + { + float dpPos = Vector3.Dot(validCenter, polyPlane.Normal) + polyPlane.D; + float dpMove = Vector3.Dot(movement, polyPlane.Normal); + + float dist; + if (dpMove <= PhysicsGlobals.EPSILON) + { + if (dpMove >= -PhysicsGlobals.EPSILON) + return false; + + dist = dpPos - sphereRadius; + } + else + { + dist = -sphereRadius - dpPos; + } + + float iDist = dist / dpMove; + float interp = (1f - iDist) * path.WalkInterp; + if (interp >= path.WalkInterp || interp < -0.5f) + return false; + + validCenter -= movement * iDist; + path.WalkInterp = interp; + return true; + } + + // ----------------------------------------------------------------------- + // 12. CheckWalkable — Polygon.check_walkable + // ACE: Polygon.cs check_walkable / check_small_walkable + // ----------------------------------------------------------------------- + + /// + /// Polygon.check_walkable -- checks if a sphere is over the walkable + /// area of a polygon (accounting for sphere radius tolerance at edges). + /// + /// + /// ACE: Polygon.cs check_walkable / check_small_walkable. + /// + /// + private static bool CheckWalkable( + Plane polyPlane, + ReadOnlySpan polyVerts, + Vector3 sphereCenter, float sphereRadius, + Vector3 up, + bool small) + { + float angleUp = Vector3.Dot(polyPlane.Normal, up); + if (angleUp < PhysicsGlobals.EPSILON) return false; + + // Project sphere center onto the polygon plane along the up vector + float dpDist = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp; + var center = sphereCenter - up * dpDist; + + float radsum = sphereRadius * sphereRadius; + if (small) radsum *= 0.25f; + + int numVerts = polyVerts.Length; + int prevIdx = numVerts - 1; + bool result = true; + + for (int i = 0; i < numVerts; i++) + { + var vertex = polyVerts[i]; + var lastVertex = polyVerts[prevIdx]; + prevIdx = i; + + var edge = vertex - lastVertex; + var disp = center - lastVertex; + var cross = Vector3.Cross(polyPlane.Normal, edge); + float diff = Vector3.Dot(disp, cross); + + if (diff < 0f) + { + if (cross.LengthSquared() * radsum < diff * diff) + return false; + + float dispEdge = Vector3.Dot(disp, edge); + if (dispEdge >= 0f && dispEdge <= edge.LengthSquared()) + return true; + + return false; + } + + if (disp.LengthSquared() <= radsum) + return true; + } + + return result; + } + + // ======================================================================= + // LEGACY: Static overlap SphereIntersectsPoly (no movement vector) + // + // This is the original method used by FindObjCollisions in + // TransitionTypes.cs. It does NOT use movement-based front-face culling. + // Kept for backward compatibility with the existing object collision + // system until it is upgraded to use FindCollisions. + // ======================================================================= + /// /// Test if a sphere intersects any polygon in the physics BSP tree. /// Returns on the first hit, populating /// and . /// /// + /// This is the static overlap test (no movement vector). For the + /// movement-aware version with front-face culling, use + /// . + /// + /// + /// /// Ported from FUN_00539270; cross-ref ACE BSPNode.sphere_intersects_poly. /// /// - /// Current BSP node (null-safe). - /// Physics polygon dictionary from the GfxObj. - /// Vertex array from the GfxObj. - /// Sphere centre in object-local space. - /// Sphere radius. - /// Polygon id of the first intersecting polygon (0 on miss). - /// Outward normal at the hit point (zero on miss). - /// if any polygon is hit. public static bool SphereIntersectsPoly( PhysicsBSPNode? node, Dictionary polygons, @@ -59,72 +1001,40 @@ public static class BSPQuery if (node is null) return false; - // ---------------------------------------------------------------- - // Broad phase: reject the whole subtree when the sphere cannot - // reach the node's bounding sphere. Both Leaf and internal nodes - // carry a BoundingSphere in the retail format. - // ---------------------------------------------------------------- + // Broad phase: bounding sphere rejection { float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin); if (dist > sphereRadius + node.BoundingSphere.Radius + CollisionPrimitives.Epsilon) return false; } - // ---------------------------------------------------------------- - // Leaf node: test each referenced polygon against the sphere using - // the retail-ported CollisionPrimitives.SphereIntersectsPoly. - // ---------------------------------------------------------------- + // Leaf node: test each polygon if (node.Type == BSPNodeType.Leaf) { foreach (var polyIdx in node.Polygons) { if (!polygons.TryGetValue(polyIdx, out var poly)) continue; - if (poly.VertexIds.Count < 3) continue; - - // Gather polygon vertices from the vertex array. - var polyVerts = new Vector3[poly.VertexIds.Count]; - bool allFound = true; - for (int i = 0; i < poly.VertexIds.Count; i++) - { - ushort vid = (ushort)poly.VertexIds[i]; - if (vertices.Vertices.TryGetValue(vid, out var sv)) - polyVerts[i] = sv.Origin; - else { allFound = false; break; } - } - if (!allFound) continue; - - // Compute the polygon plane using the retail CalcNormal port. - CollisionPrimitives.CalcNormal(polyVerts, out var normal, out float planeD); - if (normal.LengthSquared() < CollisionPrimitives.EpsilonSq) continue; - - var polyPlane = new Plane(normal, planeD); + if (!TryGetPolyPlane(poly, vertices, out var polyPlane, out var polyVerts)) + continue; if (CollisionPrimitives.SphereIntersectsPoly( polyPlane, polyVerts, sphereCenter, sphereRadius, out _)) { hitPolyId = polyIdx; - hitNormal = normal; + hitNormal = polyPlane.Normal; return true; } } return false; } - // ---------------------------------------------------------------- - // Internal node: classify sphere against splitting plane and - // recurse into the positive side, negative side, or both. - // - // System.Numerics.Plane convention: dot(N, p) + D = 0 on the - // surface, so signed distance = dot(N, center) + D. - // FUN_00539270 uses the same sign convention. - // ---------------------------------------------------------------- + // Internal node: classify against splitting plane float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter) + node.SplittingPlane.D; float reach = sphereRadius - CollisionPrimitives.Epsilon; if (splitDist >= reach) { - // Sphere entirely on the positive side. return SphereIntersectsPoly( node.PosNode, polygons, vertices, sphereCenter, sphereRadius, @@ -133,14 +1043,13 @@ public static class BSPQuery if (splitDist <= -reach) { - // Sphere entirely on the negative side. return SphereIntersectsPoly( node.NegNode, polygons, vertices, sphereCenter, sphereRadius, out hitPolyId, out hitNormal); } - // Sphere straddles the plane — check both sides, return on first hit. + // Straddles if (SphereIntersectsPoly(node.PosNode, polygons, vertices, sphereCenter, sphereRadius, out hitPolyId, out hitNormal)) return true;