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++;
}
}