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:
parent
2a4aaf4db7
commit
14b0a6e2b8
3 changed files with 119 additions and 23 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue