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). // 3. Fallback: 1.0m (conservative default for trees / small objects).
foreach (var entity in lb.Entities) foreach (var entity in lb.Entities)
{ {
float bestRadius = 0f; // Register EACH physics-enabled part so multi-part Setups
uint physicsGfxId = 0; // (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; uint partIndex = 0;
if ((sourceId & 0xFF000000u) == 0x01000000u) foreach (var meshRef in entity.MeshRefs)
{ {
// Direct GfxObj stab. var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
var cached = _physicsDataCache.GetGfxObj(sourceId); if (partCached?.BSP?.Root is null) { partIndex++; continue; }
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;
foreach (var meshRef in entity.MeshRefs) // Compute the part's world-space position from its transform.
{ var partWorld = meshRef.PartTransform * entityRoot;
var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId); var partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43);
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
}
}
}
if (physicsGfxId != 0) // Extract rotation from the world matrix.
{ System.Numerics.Quaternion partRot;
float reg_radius = bestRadius > 0f ? bestRadius : 1f; 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( _physicsEngine.ShadowObjects.Register(
entity.Id, physicsGfxId, partId, meshRef.GfxObjId,
entity.Position, reg_radius, partPos, partRot, partRadius,
origin.X, origin.Y, lb.LandblockId); origin.X, origin.Y, lb.LandblockId);
partIndex++;
} }
} }

View file

@ -20,14 +20,12 @@ public sealed class ShadowObjectRegistry
/// <summary> /// <summary>
/// Register an entity into the cells it overlaps based on world position + radius. /// Register an entity into the cells it overlaps based on world position + radius.
/// </summary> /// </summary>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, float radius, public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float worldOffsetX, float worldOffsetY, uint landblockId) float radius, float worldOffsetX, float worldOffsetY, uint landblockId)
{ {
// Deregister first if already registered (handles position updates) // Deregister first if already registered (handles position updates)
Deregister(entityId); 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 localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY; float localY = worldPos.Y - worldOffsetY;
@ -36,7 +34,7 @@ public sealed class ShadowObjectRegistry
int minCy = Math.Max(0, (int)((localY - radius) / 24f)); int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (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>(); var cellIds = new List<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u; uint lbPrefix = landblockId & 0xFFFF0000u;
@ -148,4 +146,9 @@ public sealed class ShadowObjectRegistry
public int TotalRegistered => _entityToCells.Count; 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> /// <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, sets the sliding normal /// sphere-vs-BSP collision against each. On hit, calls SlideSphere to
/// and returns Slid so the caller redirects movement along the surface. /// compute a wall-slide offset and returns the result.
/// ///
/// Object-local transform: the player sphere is mapped into each object's /// Object-local transform: the player sphere is mapped into each object's
/// local space via the inverse of (Rotation, Position) before the BSP query. /// local space via the inverse of (Rotation, Position) before the BSP query.
/// The hit normal is then rotated back to world space. /// 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> /// </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 footCenter = sp.GlobalSphere[0].Origin; Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius; float sphereRadius = sp.GlobalSphere[0].Radius;
// Find which landblock the player foot sphere is in.
if (!engine.TryGetLandblockContext(footCenter.X, footCenter.Y, if (!engine.TryGetLandblockContext(footCenter.X, footCenter.Y,
out uint landblockId, out float worldOffsetX, out float worldOffsetY)) out uint landblockId, out float worldOffsetX, out float worldOffsetY))
return TransitionState.OK; return TransitionState.OK;
// Query radius includes sphere radius plus a generous margin so we float queryRadius = sphereRadius + 10f;
// don't miss objects whose BSP extends beyond their bounding sphere.
float queryRadius = sphereRadius + 5f;
engine.ShadowObjects.GetNearbyObjects( engine.ShadowObjects.GetNearbyObjects(
footCenter, queryRadius, footCenter, queryRadius,
worldOffsetX, worldOffsetY, landblockId, worldOffsetX, worldOffsetY, landblockId,
@ -648,59 +645,142 @@ public sealed class Transition
foreach (var obj in _nearbyObjs) 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); float dist = Vector3.Distance(footCenter, obj.Position);
if (dist > sphereRadius + obj.Radius + CollisionPrimitives.Epsilon) if (dist > sphereRadius + obj.Radius + 1f)
continue; continue;
// Narrow-phase: fetch cached BSP physics data for this GfxObj.
var physics = engine.DataCache.GetGfxObj(obj.GfxObjId); var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
if (physics?.BSP?.Root is null) continue; if (physics?.BSP?.Root is null) continue;
// Transform the player sphere center into object-local space. // Transform player sphere to object-local space using the
// The object transform is: worldPos = Rotation * localPos + objPosition. // object's world rotation and position.
// For static objects stored in ShadowEntry we don't have a rotation var invRot = Quaternion.Inverse(obj.Rotation);
// stored (they are stabs/buildings whose orientation varies per entity). Vector3 localSphereCenter = Vector3.Transform(footCenter - obj.Position, invRot);
// 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;
if (!BSPQuery.SphereIntersectsPoly( if (!BSPQuery.SphereIntersectsPoly(
physics.BSP.Root, physics.BSP.Root,
physics.PhysicsPolygons, physics.PhysicsPolygons,
physics.Vertices, physics.Vertices,
localSphereCenter, sphereRadius, localSphereCenter, sphereRadius,
out _, out Vector3 hitNormal)) out _, out Vector3 localHitNormal))
continue; continue;
// Hit: set sliding normal (XY plane, no Z component) so the // Transform hit normal back to world space.
// player slides along the surface rather than tunnelling through. Vector3 worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation);
if (hitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
if (worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{ {
ci.SetSlidingNormal(hitNormal); worldHitNormal = Vector3.Normalize(worldHitNormal);
ci.CollidedWithEnvironment = true;
return TransitionState.Slid; // 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; 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) // Offset adjustment (contact-plane + slide-plane projection)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Project the per-step movement offset to avoid pushing into the contact /// 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). /// Ported from pseudocode section 6 (AdjustOffset).
/// ACE: Transition.AdjustOffset(Vector3 offset). /// ACE: Transition.AdjustOffset(Vector3 offset).
/// </summary> /// </summary>
private Vector3 AdjustOffset(Vector3 offset) private Vector3 AdjustOffset(Vector3 offset)
{ {
var sp = SpherePath;
var ci = CollisionInfo; var ci = CollisionInfo;
Vector3 result = offset; Vector3 result = offset;
@ -749,7 +829,30 @@ public sealed class Transition
{ {
// Moving away from contact plane: snap to plane surface. // Moving away from contact plane: snap to plane surface.
// SnapToPlane: remove any component that would violate the plane. // 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; return result;

View file

@ -23,7 +23,7 @@ public class ShadowObjectRegistryTests
public void Register_SingleEntity_TotalRegisteredIsOne() public void Register_SingleEntity_TotalRegisteredIsOne()
{ {
var reg = new ShadowObjectRegistry(); 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); Assert.Equal(1, reg.TotalRegistered);
} }
@ -31,8 +31,8 @@ public class ShadowObjectRegistryTests
public void Register_SameEntityTwice_NoDuplicate() public void Register_SameEntityTwice_NoDuplicate()
{ {
var reg = new ShadowObjectRegistry(); 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);
reg.Register(1u, 0x01000001u, new Vector3(13f, 12f, 50f), 1f, OffX, OffY, LbId); // re-register (position update) reg.Register(1u, 0x01000001u, new Vector3(13f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); // re-register (position update)
Assert.Equal(1, reg.TotalRegistered); Assert.Equal(1, reg.TotalRegistered);
} }
@ -45,7 +45,7 @@ public class ShadowObjectRegistryTests
{ {
// Entity at local (12, 12) = cell (0,0) = cellId prefix | 1. // Entity at local (12, 12) = cell (0,0) = cellId prefix | 1.
var reg = new ShadowObjectRegistry(); 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 uint cellId = LbId | 1u; // cx=0, cy=0 → 0*8+0+1 = 1
var objs = reg.GetObjectsInCell(cellId); 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. // 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. // Cell 0,0 = prefix|1; cell 1,0 = prefix|(1*8+0+1)=prefix|9.
var reg = new ShadowObjectRegistry(); 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 cell00 = LbId | 1u; // cx=0, cy=0
uint cell10 = LbId | 9u; // cx=1, cy=0 uint cell10 = LbId | 9u; // cx=1, cy=0
@ -76,7 +76,7 @@ public class ShadowObjectRegistryTests
public void Deregister_RemovesFromAllCells() public void Deregister_RemovesFromAllCells()
{ {
var reg = new ShadowObjectRegistry(); 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); reg.Deregister(5u);
Assert.Equal(0, reg.TotalRegistered); Assert.Equal(0, reg.TotalRegistered);
@ -101,10 +101,10 @@ public class ShadowObjectRegistryTests
{ {
const uint otherLb = 0xAAAA0000u; const uint otherLb = 0xAAAA0000u;
var reg = new ShadowObjectRegistry(); 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 // 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. // 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); reg.RemoveLandblock(LbId);
@ -120,7 +120,7 @@ public class ShadowObjectRegistryTests
public void GetNearbyObjects_QueryCoversEntity_ReturnsIt() public void GetNearbyObjects_QueryCoversEntity_ReturnsIt()
{ {
var reg = new ShadowObjectRegistry(); 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>(); var results = new List<ShadowEntry>();
reg.GetNearbyObjects(new Vector3(30f, 30f, 50f), 5f, OffX, OffY, LbId, results); reg.GetNearbyObjects(new Vector3(30f, 30f, 50f), 5f, OffX, OffY, LbId, results);
@ -134,7 +134,7 @@ public class ShadowObjectRegistryTests
{ {
var reg = new ShadowObjectRegistry(); var reg = new ShadowObjectRegistry();
// Entity at local (12, 12) — cell (0,0). // 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>(); var results = new List<ShadowEntry>();
// Query at local (180, 180) — cell (7,7) — far away. // 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). // Entity spans cells 0,0 and 1,0 (local X≈24, radius=2).
var reg = new ShadowObjectRegistry(); 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>(); var results = new List<ShadowEntry>();
// Large query covers both cells; entity must appear exactly once. // Large query covers both cells; entity must appear exactly once.
@ -171,7 +171,7 @@ public class ShadowObjectRegistryTests
{ {
var reg = new ShadowObjectRegistry(); var reg = new ShadowObjectRegistry();
// Small radius so entity sits in exactly one cell. // 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; uint cellId = LbId | expectedLow;
var objs = reg.GetObjectsInCell(cellId); var objs = reg.GetObjectsInCell(cellId);