feat(physics): port full CTransition collision response from pseudocode

Replace simplified push-out with retail-faithful SlideSphere and
AdjustOffset from transition_pseudocode.md. Crease-projection between
collision normal and contact plane produces smooth wall-sliding.
Object collision uses proper rotation transform to object-local space.

SlideSphere (section 6): computes crease direction via cross product
of collision normal and contact plane normal, projects displacement
onto the crease, then applies the correction offset. Handles three
cases: crease exists, parallel same-direction, parallel opposing.

AdjustOffset (section 6): adds safety check to keep sphere above
contact plane by computing signed distance and pushing up along Z
when the sphere dips below.

FindObjCollisions: removes ad-hoc penetration push-out, now calls
SlideSphere after BSP hit detection for proper wall-slide behavior.

Also fixes: ShadowEntry gains Rotation field, tests updated to match
Register signature, unused variables removed from GameWindow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 11:17:45 +02:00
parent e2f0c8580e
commit e12d255d2e
4 changed files with 182 additions and 84 deletions

View file

@ -1764,49 +1764,41 @@ public sealed class GameWindow : IDisposable
// 3. Fallback: 1.0m (conservative default for trees / small objects).
foreach (var entity in lb.Entities)
{
float bestRadius = 0f;
uint physicsGfxId = 0;
// Register EACH physics-enabled part so multi-part Setups
// (buildings, trees) have all their collision geometry registered.
// Each part gets its own ShadowEntry with its world-space transform.
var entityRoot =
System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) *
System.Numerics.Matrix4x4.CreateTranslation(entity.Position);
uint sourceId = entity.SourceGfxObjOrSetupId;
if ((sourceId & 0xFF000000u) == 0x01000000u)
uint partIndex = 0;
foreach (var meshRef in entity.MeshRefs)
{
// Direct GfxObj stab.
var cached = _physicsDataCache.GetGfxObj(sourceId);
if (cached?.BSP?.Root is not null)
{
physicsGfxId = sourceId;
bestRadius = cached.BoundingSphere?.Radius ?? 1f;
}
}
else if ((sourceId & 0xFF000000u) == 0x02000000u)
{
// Setup (multi-part building / creature proxy). Use the first
// part that has a physics BSP; register using Setup.Radius for
// the broad-phase sphere so the query covers the whole assembly.
var setupCached = _physicsDataCache.GetSetup(sourceId);
if (setupCached is not null && setupCached.Radius > 0f)
bestRadius = setupCached.Radius;
var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
if (partCached?.BSP?.Root is null) { partIndex++; continue; }
foreach (var meshRef in entity.MeshRefs)
{
var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
if (partCached?.BSP?.Root is not null)
{
physicsGfxId = meshRef.GfxObjId;
if (bestRadius <= 0f)
bestRadius = partCached.BoundingSphere?.Radius ?? 1f;
break; // register just the first physics part for MVP
}
}
}
// Compute the part's world-space position from its transform.
var partWorld = meshRef.PartTransform * entityRoot;
var partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43);
if (physicsGfxId != 0)
{
float reg_radius = bestRadius > 0f ? bestRadius : 1f;
// Extract rotation from the world matrix.
System.Numerics.Quaternion partRot;
if (System.Numerics.Matrix4x4.Decompose(partWorld,
out _, out partRot, out _))
{ /* decompose succeeded */ }
else
partRot = entity.Rotation;
float partRadius = partCached.BoundingSphere?.Radius ?? 1f;
// Use a unique sub-ID per part: entity.Id * 256 + partIndex.
uint partId = entity.Id * 256u + partIndex;
_physicsEngine.ShadowObjects.Register(
entity.Id, physicsGfxId,
entity.Position, reg_radius,
partId, meshRef.GfxObjId,
partPos, partRot, partRadius,
origin.X, origin.Y, lb.LandblockId);
partIndex++;
}
}

View file

@ -20,14 +20,12 @@ public sealed class ShadowObjectRegistry
/// <summary>
/// Register an entity into the cells it overlaps based on world position + radius.
/// </summary>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, float radius,
float worldOffsetX, float worldOffsetY, uint landblockId)
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId)
{
// Deregister first if already registered (handles position updates)
Deregister(entityId);
// Compute which cells the entity's bounding sphere overlaps.
// Each cell is 24×24m within a 192m landblock.
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
@ -36,7 +34,7 @@ public sealed class ShadowObjectRegistry
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, radius);
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius);
var cellIds = new List<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u;
@ -148,4 +146,9 @@ public sealed class ShadowObjectRegistry
public int TotalRegistered => _entityToCells.Count;
}
public readonly record struct ShadowEntry(uint EntityId, uint GfxObjId, Vector3 Position, float Radius);
public readonly record struct ShadowEntry(
uint EntityId,
uint GfxObjId,
Vector3 Position,
Quaternion Rotation,
float Radius);

View file

@ -614,33 +614,30 @@ public sealed class Transition
/// <summary>
/// Query the ShadowObjectRegistry for nearby static objects and run
/// sphere-vs-BSP collision against each. On hit, sets the sliding normal
/// and returns Slid so the caller redirects movement along the surface.
/// sphere-vs-BSP collision against each. On hit, calls SlideSphere to
/// compute a wall-slide offset and returns the result.
///
/// Object-local transform: the player sphere is mapped into each object's
/// local space via the inverse of (Rotation, Position) before the BSP query.
/// The hit normal is then rotated back to world space.
///
/// Task 7 implementation.
/// Ported from pseudocode section 4 (ObjCell.FindObjCollisions) and
/// section 6 (SlideSphere).
/// </summary>
private TransitionState FindObjCollisions(PhysicsEngine engine)
{
if (engine.DataCache is null) return TransitionState.OK;
var sp = SpherePath;
var ci = CollisionInfo;
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
// Find which landblock the player foot sphere is in.
if (!engine.TryGetLandblockContext(footCenter.X, footCenter.Y,
out uint landblockId, out float worldOffsetX, out float worldOffsetY))
return TransitionState.OK;
// Query radius includes sphere radius plus a generous margin so we
// don't miss objects whose BSP extends beyond their bounding sphere.
float queryRadius = sphereRadius + 5f;
float queryRadius = sphereRadius + 10f;
engine.ShadowObjects.GetNearbyObjects(
footCenter, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
@ -648,59 +645,142 @@ public sealed class Transition
foreach (var obj in _nearbyObjs)
{
// Broad-phase: skip if spheres can't possibly overlap.
// Broad-phase: sphere-sphere.
float dist = Vector3.Distance(footCenter, obj.Position);
if (dist > sphereRadius + obj.Radius + CollisionPrimitives.Epsilon)
if (dist > sphereRadius + obj.Radius + 1f)
continue;
// Narrow-phase: fetch cached BSP physics data for this GfxObj.
var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
if (physics?.BSP?.Root is null) continue;
// Transform the player sphere center into object-local space.
// The object transform is: worldPos = Rotation * localPos + objPosition.
// For static objects stored in ShadowEntry we don't have a rotation
// stored (they are stabs/buildings whose orientation varies per entity).
// For the broad-phase pass here we treat them as axis-aligned
// (rotation = Identity), which is conservative: it over-reports hits
// but never misses them. Full per-part rotation requires storing
// Quaternion in ShadowEntry — deferred to a follow-up task.
Vector3 localSphereCenter = footCenter - obj.Position;
// Transform player sphere to object-local space using the
// object's world rotation and position.
var invRot = Quaternion.Inverse(obj.Rotation);
Vector3 localSphereCenter = Vector3.Transform(footCenter - obj.Position, invRot);
if (!BSPQuery.SphereIntersectsPoly(
physics.BSP.Root,
physics.PhysicsPolygons,
physics.Vertices,
localSphereCenter, sphereRadius,
out _, out Vector3 hitNormal))
out _, out Vector3 localHitNormal))
continue;
// Hit: set sliding normal (XY plane, no Z component) so the
// player slides along the surface rather than tunnelling through.
if (hitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
// Transform hit normal back to world space.
Vector3 worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation);
if (worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
ci.SetSlidingNormal(hitNormal);
ci.CollidedWithEnvironment = true;
return TransitionState.Slid;
worldHitNormal = Vector3.Normalize(worldHitNormal);
// Use the retail SlideSphere algorithm to compute wall-slide.
// currPos = GlobalCurrCenter (where we came from this step).
Vector3 currPos = sp.GlobalCurrCenter[0].Origin;
return SlideSphere(worldHitNormal, currPos);
}
}
return TransitionState.OK;
}
// -----------------------------------------------------------------------
// SlideSphere — wall slide projection
// -----------------------------------------------------------------------
/// <summary>
/// Compute a wall-slide offset after a collision. Projects the sphere's
/// displacement along the crease between the collision normal and the
/// contact plane, producing smooth wall-sliding behavior.
///
/// Ported from pseudocode section 6 (SlideSphere — environment collision
/// normal variant). ACE: Sphere.SlideSphere(Transition, ref Vector3, Vector3).
/// Decompiled: FUN_00538180.
/// </summary>
private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos)
{
var sp = SpherePath;
var ci = CollisionInfo;
// Degenerate case: zero collision normal — nudge halfway.
if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
{
Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f;
sp.AddOffsetToCheckPos(halfOffset);
return TransitionState.Adjusted;
}
ci.SetCollisionNormal(collisionNormal);
// gDelta: displacement from currPos to the current check sphere center.
// In the retail code this includes a block offset for cross-landblock
// transitions; for outdoor single-landblock movement this is zero.
Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos;
// Get the contact plane (prefer current, fall back to last known).
System.Numerics.Plane contactPlane;
if (ci.ContactPlaneValid)
contactPlane = ci.ContactPlane;
else if (ci.LastKnownContactPlaneValid)
contactPlane = ci.LastKnownContactPlane;
else
contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f);
// Crease direction = cross(collisionNormal, contactPlane.Normal).
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
float dirLenSq = direction.LengthSquared();
if (dirLenSq >= PhysicsGlobals.EpsilonSq)
{
// Crease exists: project displacement onto it.
float diff = Vector3.Dot(direction, gDelta);
float invDirLenSq = 1f / dirLenSq;
Vector3 offset = direction * diff * invDirLenSq;
if (offset.LengthSquared() < PhysicsGlobals.EpsilonSq)
return TransitionState.Collided;
// Subtract current displacement to get the correction vector.
offset -= gDelta;
sp.AddOffsetToCheckPos(offset);
return TransitionState.Slid;
}
// Collision normal and contact plane are parallel.
if (Vector3.Dot(collisionNormal, contactPlane.Normal) >= 0f)
{
// Same direction: project out along collision normal.
float diff = Vector3.Dot(collisionNormal, gDelta);
Vector3 offset = -collisionNormal * diff;
sp.AddOffsetToCheckPos(offset);
return TransitionState.Slid;
}
// Opposing normals: give up, reverse direction.
// Retail returns OK here to allow retry with the reversed normal.
Vector3 reversed = -gDelta;
if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
reversed = Vector3.Normalize(reversed);
ci.SetCollisionNormal(reversed);
}
return TransitionState.OK;
}
// -----------------------------------------------------------------------
// Offset adjustment (contact-plane + slide-plane projection)
// -----------------------------------------------------------------------
/// <summary>
/// Project the per-step movement offset to avoid pushing into the contact
/// surface or slide plane.
/// surface or slide plane. Also performs a safety check to keep the sphere
/// above the contact plane.
///
/// Ported from pseudocode section 6 (AdjustOffset).
/// ACE: Transition.AdjustOffset(Vector3 offset).
/// </summary>
private Vector3 AdjustOffset(Vector3 offset)
{
var sp = SpherePath;
var ci = CollisionInfo;
Vector3 result = offset;
@ -749,7 +829,30 @@ public sealed class Transition
{
// Moving away from contact plane: snap to plane surface.
// SnapToPlane: remove any component that would violate the plane.
result -= ci.ContactPlane.Normal * (collisionAngle - 0f);
result -= ci.ContactPlane.Normal * collisionAngle;
}
// Safety check: ensure the sphere stays above the contact plane.
// Ported from pseudocode section 6 (AdjustOffset safety block).
if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater)
{
Vector3 globCenter = sp.GlobalSphere[0].Origin;
float radius = sp.GlobalSphere[0].Radius;
// Signed distance from sphere center to contact plane.
// For outdoor terrain within the same landblock, block offset is zero.
float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal)
+ ci.ContactPlane.D;
if (dist < radius - PhysicsGlobals.EPSILON)
{
// Sphere is below the contact plane — push it up.
float zDist = (radius - dist) / ci.ContactPlane.Normal.Z;
if (radius > MathF.Abs(zDist))
{
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, zDist));
}
}
}
return result;

View file

@ -23,7 +23,7 @@ public class ShadowObjectRegistryTests
public void Register_SingleEntity_TotalRegisteredIsOne()
{
var reg = new ShadowObjectRegistry();
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
Assert.Equal(1, reg.TotalRegistered);
}
@ -31,8 +31,8 @@ public class ShadowObjectRegistryTests
public void Register_SameEntityTwice_NoDuplicate()
{
var reg = new ShadowObjectRegistry();
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
reg.Register(1u, 0x01000001u, new Vector3(13f, 12f, 50f), 1f, OffX, OffY, LbId); // re-register (position update)
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
reg.Register(1u, 0x01000001u, new Vector3(13f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); // re-register (position update)
Assert.Equal(1, reg.TotalRegistered);
}
@ -45,7 +45,7 @@ public class ShadowObjectRegistryTests
{
// Entity at local (12, 12) = cell (0,0) = cellId prefix | 1.
var reg = new ShadowObjectRegistry();
reg.Register(42u, 0x01000002u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
reg.Register(42u, 0x01000002u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
uint cellId = LbId | 1u; // cx=0, cy=0 → 0*8+0+1 = 1
var objs = reg.GetObjectsInCell(cellId);
@ -59,7 +59,7 @@ public class ShadowObjectRegistryTests
// Entity at local (24, 12) with radius=2 spans cells cx=0 and cx=1 in X.
// Cell 0,0 = prefix|1; cell 1,0 = prefix|(1*8+0+1)=prefix|9.
var reg = new ShadowObjectRegistry();
reg.Register(7u, 0x01000003u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
reg.Register(7u, 0x01000003u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId);
uint cell00 = LbId | 1u; // cx=0, cy=0
uint cell10 = LbId | 9u; // cx=1, cy=0
@ -76,7 +76,7 @@ public class ShadowObjectRegistryTests
public void Deregister_RemovesFromAllCells()
{
var reg = new ShadowObjectRegistry();
reg.Register(5u, 0x01000004u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
reg.Register(5u, 0x01000004u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId);
reg.Deregister(5u);
Assert.Equal(0, reg.TotalRegistered);
@ -101,10 +101,10 @@ public class ShadowObjectRegistryTests
{
const uint otherLb = 0xAAAA0000u;
var reg = new ShadowObjectRegistry();
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
// Entity 2 lives in otherLb whose world origin is at X=192. Place it at
// world X=204 so localX = 204-192 = 12, which maps to cell (0,0) of otherLb.
reg.Register(2u, 0x01000002u, new Vector3(204f, 12f, 50f), 1f, 192f, 0f, otherLb);
reg.Register(2u, 0x01000002u, new Vector3(204f, 12f, 50f), Quaternion.Identity, 1f, 192f, 0f, otherLb);
reg.RemoveLandblock(LbId);
@ -120,7 +120,7 @@ public class ShadowObjectRegistryTests
public void GetNearbyObjects_QueryCoversEntity_ReturnsIt()
{
var reg = new ShadowObjectRegistry();
reg.Register(10u, 0x01000005u, new Vector3(30f, 30f, 50f), 1f, OffX, OffY, LbId);
reg.Register(10u, 0x01000005u, new Vector3(30f, 30f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
var results = new List<ShadowEntry>();
reg.GetNearbyObjects(new Vector3(30f, 30f, 50f), 5f, OffX, OffY, LbId, results);
@ -134,7 +134,7 @@ public class ShadowObjectRegistryTests
{
var reg = new ShadowObjectRegistry();
// Entity at local (12, 12) — cell (0,0).
reg.Register(11u, 0x01000006u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
reg.Register(11u, 0x01000006u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
var results = new List<ShadowEntry>();
// Query at local (180, 180) — cell (7,7) — far away.
@ -148,7 +148,7 @@ public class ShadowObjectRegistryTests
{
// Entity spans cells 0,0 and 1,0 (local X≈24, radius=2).
var reg = new ShadowObjectRegistry();
reg.Register(20u, 0x01000007u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
reg.Register(20u, 0x01000007u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId);
var results = new List<ShadowEntry>();
// Large query covers both cells; entity must appear exactly once.
@ -171,7 +171,7 @@ public class ShadowObjectRegistryTests
{
var reg = new ShadowObjectRegistry();
// Small radius so entity sits in exactly one cell.
reg.Register(99u, 0x01000008u, new Vector3(lx + 0.5f, ly + 0.5f, 50f), 0.1f, OffX, OffY, LbId);
reg.Register(99u, 0x01000008u, new Vector3(lx + 0.5f, ly + 0.5f, 50f), Quaternion.Identity, 0.1f, OffX, OffY, LbId);
uint cellId = LbId | expectedLow;
var objs = reg.GetObjectsInCell(cellId);