feat(physics): retail-faithful collision system port from ACE

Replace the patched collision system (~60-70% retail) with a faithful
port of ACE's BSPTree/BSPNode/BSPLeaf/Polygon collision pipeline.

BSPQuery.cs completely rewritten (1808 lines):
- Polygon-level: polygon_hits_sphere_precise (retail two-loop test),
  pos_hits_sphere, hits_sphere, walkable_hits_sphere, check_walkable,
  adjust_sphere_to_plane, find_crossed_edge, adjust_to_placement_poly
- BSP traversal: sphere_intersects_poly, find_walkable, hits_walkable,
  sphere_intersects_solid, sphere_intersects_solid_poly
- BSP tree-level: find_collisions (6-path dispatcher), step_sphere_up,
  step_sphere_down, slide_sphere, collide_with_pt, adjust_to_plane,
  placement_insert

PhysicsDataCache.cs: Added ResolvedPolygon type with pre-computed
vertex positions and face planes (matching ACE's Polygon constructor
which calls make_plane() at load time). Populated at cache time to
avoid per-collision-test vertex lookups.

TransitionTypes.cs: FindObjCollisions rewritten to use the retail
per-object FindCollisions 6-path dispatcher instead of the old
"find earliest t, then apply custom response" approach. BSP objects
now go through the same collision paths as indoor cell BSP.

The previous approach was explicitly rejected by the user after ~10
iterations of patches. This port follows the CLAUDE.md mandatory
workflow: decompile first → cross-reference ACE → port faithfully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 16:18:43 +02:00
parent b16a149718
commit 874bcc8690
3 changed files with 1781 additions and 1211 deletions

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ using System.Numerics;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums; using DatReaderWriter.Enums;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using Plane = System.Numerics.Plane;
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
@ -34,6 +35,7 @@ public sealed class PhysicsDataCache
PhysicsPolygons = gfxObj.PhysicsPolygons, PhysicsPolygons = gfxObj.PhysicsPolygons,
BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere, BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere,
Vertices = gfxObj.VertexArray, Vertices = gfxObj.VertexArray,
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
}; };
} }
@ -75,9 +77,66 @@ public sealed class PhysicsDataCache
Vertices = cellStruct.VertexArray, Vertices = cellStruct.VertexArray,
WorldTransform = worldTransform, WorldTransform = worldTransform,
InverseWorldTransform = inverseTransform, InverseWorldTransform = inverseTransform,
Resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray),
}; };
} }
/// <summary>
/// Pre-resolve all physics polygons: lookup vertex positions from VertexArray
/// and compute the face plane. Matches ACE's Polygon constructor which calls
/// make_plane() and resolves Vertices from VertexIDs at load time.
/// </summary>
private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
VertexArray vertexArray)
{
var resolved = new Dictionary<ushort, ResolvedPolygon>(polys.Count);
foreach (var (id, poly) in polys)
{
int numVerts = poly.VertexIds.Count;
if (numVerts < 3) continue;
var verts = new Vector3[numVerts];
bool valid = true;
for (int i = 0; i < numVerts; i++)
{
ushort vid = (ushort)poly.VertexIds[i];
if (!vertexArray.Vertices.TryGetValue(vid, out var sv))
{ valid = false; break; }
verts[i] = sv.Origin;
}
if (!valid) continue;
// Compute plane normal using ACE's make_plane algorithm:
// fan cross-product accumulation + normalization.
var normal = Vector3.Zero;
for (int i = 1; i < numVerts - 1; i++)
{
var v1 = verts[i] - verts[0];
var v2 = verts[i + 1] - verts[0];
normal += Vector3.Cross(v1, v2);
}
float len = normal.Length();
if (len < 1e-8f) continue;
normal /= len;
// D = -(average dot(normal, vertex))
float dotSum = 0f;
for (int i = 0; i < numVerts; i++)
dotSum += Vector3.Dot(normal, verts[i]);
float d = -(dotSum / numVerts);
resolved[id] = new ResolvedPolygon
{
Vertices = verts,
Plane = new Plane(normal, d),
NumPoints = numVerts,
SidesType = poly.SidesType,
};
}
return resolved;
}
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null; public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null;
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
@ -86,6 +145,19 @@ public sealed class PhysicsDataCache
public int CellStructCount => _cellStruct.Count; public int CellStructCount => _cellStruct.Count;
} }
/// <summary>
/// A physics polygon with pre-resolved vertex positions and pre-computed plane.
/// ACE pre-computes these in its Polygon constructor; we do it at cache time
/// to avoid per-collision-test vertex lookups.
/// </summary>
public sealed class ResolvedPolygon
{
public required Vector3[] Vertices { get; init; }
public required Plane Plane { get; init; }
public required int NumPoints { get; init; }
public required CullMode SidesType { get; init; }
}
/// <summary>Cached physics data for a single GfxObj part.</summary> /// <summary>Cached physics data for a single GfxObj part.</summary>
public sealed class GfxObjPhysics public sealed class GfxObjPhysics
{ {
@ -93,6 +165,12 @@ public sealed class GfxObjPhysics
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; } public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
public Sphere? BoundingSphere { get; init; } public Sphere? BoundingSphere { get; init; }
public required VertexArray Vertices { get; init; } public required VertexArray Vertices { get; init; }
/// <summary>
/// Pre-resolved polygon data with vertex positions and computed planes.
/// Populated once at cache time so BSP queries don't pay per-test lookup cost.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
} }
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary> /// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
@ -118,4 +196,9 @@ public sealed class CellPhysics
public required VertexArray Vertices { get; init; } public required VertexArray Vertices { get; init; }
public Matrix4x4 WorldTransform { get; init; } public Matrix4x4 WorldTransform { get; init; }
public Matrix4x4 InverseWorldTransform { get; init; } public Matrix4x4 InverseWorldTransform { get; init; }
/// <summary>
/// Pre-resolved polygon data with vertex positions and computed planes.
/// </summary>
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
} }

View file

@ -547,10 +547,10 @@ public sealed class Transition
} }
// Use the full 6-path BSP dispatcher for retail-faithful collision. // Use the full 6-path BSP dispatcher for retail-faithful collision.
// Use pre-resolved polygons (vertices+planes computed at cache time).
var cellState = BSPQuery.FindCollisions( var cellState = BSPQuery.FindCollisions(
cellPhysics.BSP.Root, cellPhysics.BSP.Root,
cellPhysics.PhysicsPolygons, cellPhysics.Resolved,
cellPhysics.Vertices,
this, this,
localSphere, localSphere,
localSphere1, localSphere1,
@ -665,27 +665,28 @@ public sealed class Transition
/// <summary> /// <summary>
/// Query the ShadowObjectRegistry for nearby static objects and run /// Query the ShadowObjectRegistry for nearby static objects and run
/// sphere-vs-BSP collision against each. On hit, calls SlideSphere to /// collision against each using the retail BSPTree.find_collisions 6-path
/// compute a wall-slide offset and returns the result. /// dispatcher.
/// ///
/// Object-local transform: the player sphere is mapped into each object's /// ACE: ObjCell.FindObjCollisions iterates objects, calling
/// local space via the inverse of (Rotation, Position) before the BSP query. /// PhysicsObj.FindObjCollisions on each. For BSP objects, this transforms
/// The hit normal is then rotated back to world space. /// to object-local space and calls BSPTree.find_collisions (the 6-path
/// dispatcher that handles step-up, slide, collide-with-point, etc.).
/// ///
/// Ported from pseudocode section 4 (ObjCell.FindObjCollisions) and /// The retail approach processes objects sequentially — the first non-OK
/// section 6 (SlideSphere). /// result modifies SpherePath and is returned. This differs from the
/// previous "find earliest t" approach.
/// </summary> /// </summary>
private TransitionState FindObjCollisions(PhysicsEngine engine) private TransitionState FindObjCollisions(PhysicsEngine engine)
{ {
if (engine.DataCache is null) return TransitionState.OK; if (engine.DataCache is null) return TransitionState.OK;
var sp = SpherePath; var sp = SpherePath;
var ci = CollisionInfo;
Vector3 checkPos = sp.GlobalSphere[0].Origin; Vector3 checkPos = sp.GlobalSphere[0].Origin;
Vector3 currPos = sp.GlobalCurrCenter[0].Origin; Vector3 currPos = sp.GlobalCurrCenter[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius; float sphereRadius = sp.GlobalSphere[0].Radius;
Vector3 movement = checkPos - currPos; // this step's movement vector Vector3 movement = checkPos - currPos;
if (!engine.TryGetLandblockContext(checkPos.X, checkPos.Y, if (!engine.TryGetLandblockContext(checkPos.X, checkPos.Y,
out uint landblockId, out float worldOffsetX, out float worldOffsetY)) out uint landblockId, out float worldOffsetX, out float worldOffsetY))
@ -697,154 +698,137 @@ public sealed class Transition
worldOffsetX, worldOffsetY, landblockId, worldOffsetX, worldOffsetY, landblockId,
_nearbyObjs); _nearbyObjs);
// Find the EARLIEST collision along the movement path. foreach (var obj in _nearbyObjs)
// Test both foot sphere (index 0) and head sphere (index 1) if present.
float bestT = float.MaxValue;
Vector3 bestNormal = Vector3.Zero;
bool bestIsHeadSphere = false;
for (int sphereIdx = 0; sphereIdx < sp.NumSphere; sphereIdx++)
{ {
Vector3 sphereCheckPos = sp.GlobalSphere[sphereIdx].Origin; // Broad-phase: can the moving sphere reach this object?
Vector3 sphereCurrPos = sp.GlobalCurrCenter[sphereIdx].Origin; Vector3 deltaToCurr = currPos - obj.Position;
float sphRadius = sp.GlobalSphere[sphereIdx].Radius; float distToCurr;
Vector3 sphMovement = sphereCheckPos - sphereCurrPos; if (obj.CollisionType == ShadowCollisionType.Cylinder)
distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y);
else
distToCurr = deltaToCurr.Length();
float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f;
if (distToCurr > maxReach)
continue;
foreach (var obj in _nearbyObjs) TransitionState result;
if (obj.CollisionType == ShadowCollisionType.BSP)
{ {
// Broad-phase: can the moving sphere reach this object? // ── BSP object: use the full 6-path retail dispatcher ────
Vector3 deltaToCurr = sphereCurrPos - obj.Position; // ACE: PhysicsObj.FindObjCollisions → Setup.BSP.find_collisions
float distToCurr; var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
if (obj.CollisionType == ShadowCollisionType.Cylinder) if (physics?.BSP?.Root is null) continue;
distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y);
else
distToCurr = deltaToCurr.Length();
float maxReach = sphRadius + obj.Radius + sphMovement.Length() + 2f;
if (distToCurr > maxReach)
continue;
float t; // Transform player spheres to object-local space.
Vector3 worldHitNormal; var invRot = Quaternion.Inverse(obj.Rotation);
if (obj.CollisionType == ShadowCollisionType.BSP) var localSphere0 = new DatReaderWriter.Types.Sphere
{ {
var physics = engine.DataCache.GetGfxObj(obj.GfxObjId); Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot),
if (physics?.BSP?.Root is null) continue; Radius = sp.GlobalSphere[0].Radius,
};
var localCurrCenter = Vector3.Transform(
sp.GlobalCurrCenter[0].Origin - obj.Position, invRot);
// Transform to object-local space. DatReaderWriter.Types.Sphere? localSphere1 = null;
var invRot = Quaternion.Inverse(obj.Rotation); if (sp.NumSphere > 1)
Vector3 localCurrPos = Vector3.Transform(sphereCurrPos - obj.Position, invRot);
Vector3 localMovement = Vector3.Transform(sphMovement, invRot);
// Use movement-aware BSP query with front-face culling.
if (!BSPQuery.SphereIntersectsPolyWithTime(
physics.BSP.Root,
physics.PhysicsPolygons,
physics.Vertices,
localCurrPos, sphRadius,
localMovement,
out _, out Vector3 localHitNormal, out t))
continue;
worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation);
t = Math.Clamp(t, 0f, 1f);
}
else
{ {
// Cylinder swept-sphere test. localSphere1 = new DatReaderWriter.Types.Sphere
Vector3 deltaCurr = sphereCurrPos - obj.Position;
float dx = deltaCurr.X, dy = deltaCurr.Y;
float mx = sphMovement.X, my = sphMovement.Y;
float combinedR = sphRadius + obj.Radius;
float a = mx * mx + my * my;
float b = 2f * (dx * mx + dy * my);
float c = dx * dx + dy * dy - combinedR * combinedR;
if (a < PhysicsGlobals.EPSILON)
{ {
if (c > 0f) continue; Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot),
t = 0f; Radius = sp.GlobalSphere[1].Radius,
} };
else
{
float disc = b * b - 4f * a * c;
if (disc < 0f) continue;
float sqrtDisc = MathF.Sqrt(disc);
t = (-b - sqrtDisc) / (2f * a);
if (t > 1f) continue;
if (t < 0f) t = 0f;
}
// Vertical check at contact time.
Vector3 contactPos = sphereCurrPos + sphMovement * t;
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
float playerBottom = contactPos.Z - sphRadius;
float playerTop = contactPos.Z + sphRadius;
if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
continue;
// Normal: radial at contact point.
Vector3 contactDelta = contactPos - obj.Position;
float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
if (hDist < PhysicsGlobals.EPSILON)
worldHitNormal = Vector3.UnitX;
else
worldHitNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
} }
if (t < bestT && worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) // Local-space Z (up direction rotated into object space).
{ var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
bestT = t;
bestNormal = Vector3.Normalize(worldHitNormal); // Use the retail 6-path dispatcher with pre-resolved polygons.
bestIsHeadSphere = (sphereIdx == 1); result = BSPQuery.FindCollisions(
} physics.BSP.Root,
physics.Resolved,
this,
localSphere0,
localSphere1,
localCurrCenter,
localSpaceZ,
1.0f); // scale = 1.0 for object geometry
} }
else
{
// ── Cylinder object: swept-sphere cylinder test ──────────
// ACE: Sphere.IntersectsSphere handles CylSphere objects via
// the same 6-path dispatcher. For now we keep the swept-sphere
// cylinder test which matches the retail CylSphere behavior.
result = CylinderCollision(obj, sp);
}
if (result != TransitionState.OK)
return result;
} }
if (bestT >= float.MaxValue) return TransitionState.OK;
}
/// <summary>
/// Cylinder swept-sphere collision test for CylSphere objects (trees, rocks, etc.).
/// Performs a 2D ray-circle intersection to find contact time, then applies
/// a wall-slide response.
/// </summary>
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp)
{
var ci = CollisionInfo;
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
float sphRadius = sp.GlobalSphere[0].Radius;
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
Vector3 deltaCurr = sphereCurrPos - obj.Position;
float dx = deltaCurr.X, dy = deltaCurr.Y;
float mx = sphMovement.X, my = sphMovement.Y;
float combinedR = sphRadius + obj.Radius;
float a = mx * mx + my * my;
float b = 2f * (dx * mx + dy * my);
float c = dx * dx + dy * dy - combinedR * combinedR;
float t;
if (a < PhysicsGlobals.EPSILON)
{ {
if (c > 0f) return TransitionState.OK;
t = 0f;
}
else
{
float disc = b * b - 4f * a * c;
if (disc < 0f) return TransitionState.OK;
float sqrtDisc = MathF.Sqrt(disc);
t = (-b - sqrtDisc) / (2f * a);
if (t > 1f) return TransitionState.OK;
if (t < 0f) t = 0f;
}
// Vertical check at contact time.
Vector3 contactPos = sphereCurrPos + sphMovement * t;
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
float playerBottom = contactPos.Z - sphRadius;
float playerTop = contactPos.Z + sphRadius;
if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
return TransitionState.OK; return TransitionState.OK;
}
// ── Fix 3: Contact-path step-up attempt ───────────────────────── // Collision normal: radial from cylinder axis.
// When in contact with ground and hitting a low obstacle (not the head Vector3 contactDelta = contactPos - obj.Position;
// sphere), try stepping up before falling back to slide. float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
// ACE: BSPTree.find_collisions path 5 — Contact|OnWalkable → step_sphere_up. Vector3 collisionNormal;
if (!bestIsHeadSphere if (hDist < PhysicsGlobals.EPSILON)
&& ObjectInfo.Contact collisionNormal = Vector3.UnitX;
&& bestNormal.Z > PhysicsGlobals.EPSILON else
&& bestNormal.Z < PhysicsGlobals.FloorZ) collisionNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
{
// The surface is angled (not a vertical wall, not a floor) —
// attempt step-up. Set the flag for the transition system.
sp.StepUp = true;
sp.StepUpNormal = bestNormal;
ci.SetCollisionNormal(bestNormal);
return TransitionState.OK;
}
// Already overlapping at the START of the step (bestT == 0 or very small). // Apply collision response via wall-slide.
if (bestT <= PhysicsGlobals.EPSILON) ci.SetCollisionNormal(collisionNormal);
{ return SlideSphere(collisionNormal, sphereCurrPos);
Vector3 pushOut = bestNormal * (sphereRadius * 0.5f + 0.01f);
sp.AddOffsetToCheckPos(pushOut);
ci.SetCollisionNormal(bestNormal);
ci.SetSlidingNormal(bestNormal);
return TransitionState.Adjusted;
}
// Rewind the sphere to just BEFORE the contact point.
if (bestT < 1f)
{
float safeT = MathF.Max(0f, bestT - 0.02f);
Vector3 contactPos = currPos + movement * safeT;
contactPos += bestNormal * 0.02f;
sp.SetCheckPos(contactPos, sp.CheckCellId);
}
// Apply wall-slide from the contact point.
return SlideSphere(bestNormal, currPos);
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------