feat(physics): CylSphere collision for trees, rocks, NPCs

Most scenery objects (trees, rocks) use CylSphere collision from
their Setup, not PhysicsBSP. Register these in ShadowObjectRegistry
with a Cylinder collision type. FindObjCollisions now handles both:
- BSP: full polygon collision via BSPQuery (buildings, stabs)
- Cylinder: radial + vertical cylinder-sphere test (trees, NPCs)

Diagnostics showed 170 CylSphere entities vs 278 BSP entities in
the Holtburg landblock alone — this roughly doubles collision coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 12:14:28 +02:00
parent 2a4aaf4db7
commit 14b0a6e2b8
3 changed files with 119 additions and 23 deletions

View file

@ -1800,8 +1800,65 @@ public sealed class GameWindow : IDisposable
partIndex++; partIndex++;
} }
// If no BSP parts were registered, check for CylSphere collision
// from the Setup (trees, rocks, NPCs use cylinder collision).
if (partIndex == 0 || !entity.MeshRefs.Any(mr =>
_physicsDataCache.GetGfxObj(mr.GfxObjId)?.BSP?.Root is not null))
{
var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
if (setup is not null && setup.CylSpheres.Count > 0)
{
var cyl = setup.CylSpheres[0];
float cylRadius = cyl.Radius > 0 ? cyl.Radius : setup.Radius;
if (cylRadius > 0)
{
_physicsEngine.ShadowObjects.Register(
entity.Id, entity.SourceGfxObjOrSetupId,
entity.Position + new System.Numerics.Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z),
entity.Rotation, cylRadius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cyl.Height);
}
}
else if (setup is not null && setup.Spheres.Count > 0)
{
var sph = setup.Spheres[0];
if (sph.Radius > 0)
{
_physicsEngine.ShadowObjects.Register(
entity.Id, entity.SourceGfxObjOrSetupId,
entity.Position + new System.Numerics.Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z),
entity.Rotation, sph.Radius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, 0f);
}
}
}
} }
// Debug: count collision types
int withBSP = 0, withCyl = 0, noPhys = 0;
foreach (var entity in lb.Entities)
{
bool hasBSP = false, hasCyl = false;
foreach (var mr in entity.MeshRefs)
{
if (_physicsDataCache.GetGfxObj(mr.GfxObjId)?.BSP?.Root is not null)
{ hasBSP = true; break; }
}
if (!hasBSP)
{
var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
if (setup is not null && (setup.CylSpheres.Count > 0 || setup.Spheres.Count > 0))
hasCyl = true;
}
if (hasBSP) withBSP++;
else if (hasCyl) withCyl++;
else noPhys++;
}
Console.WriteLine($"shadow: lb=0x{lb.LandblockId:X8} ent={lb.Entities.Count} bsp={withBSP} cyl={withCyl} none={noPhys} reg={_physicsEngine.ShadowObjects.TotalRegistered}");
// Register each stab as a plugin snapshot so the plugin host has // Register each stab as a plugin snapshot so the plugin host has
// visibility into the streaming world state. // visibility into the streaming world state.
foreach (var entity in lb.Entities) foreach (var entity in lb.Entities)

View file

@ -21,9 +21,10 @@ public sealed class ShadowObjectRegistry
/// 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, Quaternion rotation, public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId) float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
float cylHeight = 0f)
{ {
// Deregister first if already registered (handles position updates)
Deregister(entityId); Deregister(entityId);
float localX = worldPos.X - worldOffsetX; float localX = worldPos.X - worldOffsetX;
@ -34,7 +35,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, rotation, radius); var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight);
var cellIds = new List<uint>(); var cellIds = new List<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u; uint lbPrefix = landblockId & 0xFFFF0000u;
@ -146,9 +147,17 @@ public sealed class ShadowObjectRegistry
public int TotalRegistered => _entityToCells.Count; public int TotalRegistered => _entityToCells.Count;
} }
/// <summary>
/// Collision type for a shadow entry. BSP uses full polygon collision.
/// Cylinder uses a simple cylinder-sphere intersection test.
/// </summary>
public enum ShadowCollisionType : byte { BSP, Cylinder }
public readonly record struct ShadowEntry( public readonly record struct ShadowEntry(
uint EntityId, uint EntityId,
uint GfxObjId, uint GfxObjId,
Vector3 Position, Vector3 Position,
Quaternion Rotation, Quaternion Rotation,
float Radius); float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
float CylHeight = 0f);

View file

@ -645,36 +645,66 @@ public sealed class Transition
foreach (var obj in _nearbyObjs) foreach (var obj in _nearbyObjs)
{ {
// Broad-phase: sphere-sphere. // Broad-phase: sphere-sphere distance check.
float dist = Vector3.Distance(footCenter, obj.Position); float dist = Vector3.Distance(footCenter, obj.Position);
if (dist > sphereRadius + obj.Radius + 1f) if (dist > sphereRadius + obj.Radius + 1f)
continue; continue;
var physics = engine.DataCache.GetGfxObj(obj.GfxObjId); Vector3 worldHitNormal;
if (physics?.BSP?.Root is null) continue;
// Transform player sphere to object-local space using the if (obj.CollisionType == ShadowCollisionType.BSP)
// object's world rotation and position. {
var invRot = Quaternion.Inverse(obj.Rotation); // BSP narrow phase: full polygon collision.
Vector3 localSphereCenter = Vector3.Transform(footCenter - obj.Position, invRot); var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
if (physics?.BSP?.Root is null) continue;
if (!BSPQuery.SphereIntersectsPoly( var invRot = Quaternion.Inverse(obj.Rotation);
physics.BSP.Root, Vector3 localSphereCenter = Vector3.Transform(footCenter - obj.Position, invRot);
physics.PhysicsPolygons,
physics.Vertices,
localSphereCenter, sphereRadius,
out _, out Vector3 localHitNormal))
continue;
// Transform hit normal back to world space. if (!BSPQuery.SphereIntersectsPoly(
Vector3 worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation); physics.BSP.Root,
physics.PhysicsPolygons,
physics.Vertices,
localSphereCenter, sphereRadius,
out _, out Vector3 localHitNormal))
continue;
worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation);
}
else
{
// Cylinder/Sphere narrow phase: simple radial collision.
// Retail uses CylSphere::IntershectsSphere for trees, rocks, NPCs.
// The cylinder extends vertically from obj.Position.Z to Z+Height.
// We test if the player sphere overlaps the cylinder radially AND
// is within the vertical extent.
Vector3 delta = footCenter - obj.Position;
float horizontalDist = MathF.Sqrt(delta.X * delta.X + delta.Y * delta.Y);
float combinedRadius = sphereRadius + obj.Radius;
if (horizontalDist >= combinedRadius)
continue; // no radial overlap
// Vertical check: player sphere must overlap the cylinder height range.
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
float playerBottom = footCenter.Z - sphereRadius;
float playerTop = footCenter.Z + sphereRadius;
float objBottom = obj.Position.Z;
float objTop = obj.Position.Z + cylTop;
if (playerBottom > objTop || playerTop < objBottom)
continue; // vertically separated
// Collision normal: push player out radially (XY only).
if (horizontalDist < PhysicsGlobals.EPSILON)
worldHitNormal = Vector3.UnitX; // degenerate: directly on top
else
worldHitNormal = Vector3.Normalize(new Vector3(delta.X, delta.Y, 0f));
}
if (worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) if (worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{ {
worldHitNormal = Vector3.Normalize(worldHitNormal); 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; Vector3 currPos = sp.GlobalCurrCenter[0].Origin;
return SlideSphere(worldHitNormal, currPos); return SlideSphere(worldHitNormal, currPos);
} }