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:
parent
e2f0c8580e
commit
e12d255d2e
4 changed files with 182 additions and 84 deletions
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue