From 14b0a6e2b8e90caf5d0fd54867cb702d4d1dbf13 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Apr 2026 12:14:28 +0200 Subject: [PATCH] feat(physics): CylSphere collision for trees, rocks, NPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 57 ++++++++++++++++ .../Physics/ShadowObjectRegistry.cs | 17 +++-- src/AcDream.Core/Physics/TransitionTypes.cs | 68 +++++++++++++------ 3 files changed, 119 insertions(+), 23 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c139ff2..c06cf7a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1800,8 +1800,65 @@ public sealed class GameWindow : IDisposable 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 // visibility into the streaming world state. foreach (var entity in lb.Entities) diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 7453c79..b0ff7a6 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -21,9 +21,10 @@ public sealed class ShadowObjectRegistry /// Register an entity into the cells it overlaps based on world position + radius. /// 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); float localX = worldPos.X - worldOffsetX; @@ -34,7 +35,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, rotation, radius); + var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight); var cellIds = new List(); uint lbPrefix = landblockId & 0xFFFF0000u; @@ -146,9 +147,17 @@ public sealed class ShadowObjectRegistry public int TotalRegistered => _entityToCells.Count; } +/// +/// Collision type for a shadow entry. BSP uses full polygon collision. +/// Cylinder uses a simple cylinder-sphere intersection test. +/// +public enum ShadowCollisionType : byte { BSP, Cylinder } + public readonly record struct ShadowEntry( uint EntityId, uint GfxObjId, Vector3 Position, Quaternion Rotation, - float Radius); + float Radius, + ShadowCollisionType CollisionType = ShadowCollisionType.BSP, + float CylHeight = 0f); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 19b1846..bc7d987 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -645,36 +645,66 @@ public sealed class Transition foreach (var obj in _nearbyObjs) { - // Broad-phase: sphere-sphere. + // Broad-phase: sphere-sphere distance check. float dist = Vector3.Distance(footCenter, obj.Position); if (dist > sphereRadius + obj.Radius + 1f) continue; - var physics = engine.DataCache.GetGfxObj(obj.GfxObjId); - if (physics?.BSP?.Root is null) continue; + Vector3 worldHitNormal; - // 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 (obj.CollisionType == ShadowCollisionType.BSP) + { + // BSP narrow phase: full polygon collision. + var physics = engine.DataCache.GetGfxObj(obj.GfxObjId); + if (physics?.BSP?.Root is null) continue; - if (!BSPQuery.SphereIntersectsPoly( - physics.BSP.Root, - physics.PhysicsPolygons, - physics.Vertices, - localSphereCenter, sphereRadius, - out _, out Vector3 localHitNormal)) - continue; + var invRot = Quaternion.Inverse(obj.Rotation); + Vector3 localSphereCenter = Vector3.Transform(footCenter - obj.Position, invRot); - // Transform hit normal back to world space. - Vector3 worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation); + if (!BSPQuery.SphereIntersectsPoly( + 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) { 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); }