diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7db0c83..a7b834f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1394,6 +1394,9 @@ public sealed class GameWindow : IDisposable // Step 4: build LoadedCell for portal visibility. BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform); + + // Cache CellStruct physics BSP for indoor collision. + _physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform); } } } diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 97f3419..fd8d8cc 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1142,4 +1142,164 @@ public static class BSPQuery return SphereIntersectsPoly(node.NegNode, polygons, vertices, sphereCenter, sphereRadius, movement, out hitPolyId, out hitNormal); } + + // ----------------------------------------------------------------------- + // 14. SphereIntersectsPolyWithTime — swept-sphere BSP query using + // FindTimeOfCollision for exact parametric contact time. + // Fix 4: replaces static overlap + ad-hoc t computation. + // ----------------------------------------------------------------------- + + /// + /// Movement-aware sphere-BSP intersection that uses + /// to compute the + /// exact parametric time of first contact. Returns the earliest collision + /// across all polygons in the BSP tree. + /// + /// + /// Unlike which + /// tests static overlap at start and end positions, this method finds the + /// precise contact time via swept-sphere analysis. + /// + /// + public static bool SphereIntersectsPolyWithTime( + PhysicsBSPNode? node, + Dictionary polygons, + VertexArray vertices, + Vector3 sphereCenter, + float sphereRadius, + Vector3 movement, + out ushort hitPolyId, + out Vector3 hitNormal, + out float hitTime) + { + hitPolyId = 0; + hitNormal = Vector3.Zero; + hitTime = float.MaxValue; + + if (node is null) return false; + + SphereIntersectsPolyWithTimeRecurse( + node, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPolyId, ref hitNormal, ref hitTime); + + return hitTime < float.MaxValue; + } + + private static void SphereIntersectsPolyWithTimeRecurse( + PhysicsBSPNode? node, + Dictionary polygons, + VertexArray vertices, + Vector3 sphereCenter, + float sphereRadius, + Vector3 movement, + ref ushort hitPolyId, + ref Vector3 hitNormal, + ref float bestTime) + { + if (node is null) return; + + // Broad phase: bounding sphere + movement extent + float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin); + if (dist > sphereRadius + node.BoundingSphere.Radius + movement.Length() + 0.1f) + return; + + // Leaf node: test each polygon with FindTimeOfCollision + if (node.Type == BSPNodeType.Leaf) + { + foreach (var polyIdx in node.Polygons) + { + if (!polygons.TryGetValue(polyIdx, out var poly)) continue; + if (!TryGetPolyPlane(poly, vertices, out var polyPlane, out var polyVerts)) + continue; + + // Front-face culling: only collide if moving toward this face. + if (Vector3.Dot(movement, polyPlane.Normal) >= 0f) + continue; + + // Use FindTimeOfCollision for exact parametric contact time. + if (CollisionPrimitives.FindTimeOfCollision( + polyPlane, polyVerts, + sphereCenter, sphereRadius, + movement, out float t)) + { + // FindTimeOfCollision returns t such that contact = origin - movement*t. + // For our purposes, a positive t means the sphere reaches the polygon + // when travelling along 'movement'. We want the absolute value as + // our parametric time (0=start, 1=end of movement). + float absT = MathF.Abs(t); + if (absT < bestTime) + { + bestTime = absT; + hitPolyId = polyIdx; + hitNormal = polyPlane.Normal; + } + } + else + { + // Fallback: static overlap test at start and end positions. + if (CollisionPrimitives.SphereIntersectsPoly( + polyPlane, polyVerts, sphereCenter, sphereRadius, out _)) + { + if (0f < bestTime) + { + bestTime = 0f; + hitPolyId = polyIdx; + hitNormal = polyPlane.Normal; + } + } + else + { + Vector3 endCenter = sphereCenter + movement; + if (CollisionPrimitives.SphereIntersectsPoly( + polyPlane, polyVerts, endCenter, sphereRadius, out _)) + { + if (1f < bestTime) + { + bestTime = 1f; + hitPolyId = polyIdx; + hitNormal = polyPlane.Normal; + } + } + } + } + } + return; + } + + // Internal node: classify against splitting plane + float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter) + + node.SplittingPlane.D; + float reach = sphereRadius + movement.Length(); + + if (splitDist >= reach) + { + SphereIntersectsPolyWithTimeRecurse( + node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPolyId, ref hitNormal, ref bestTime); + return; + } + + if (splitDist <= -reach) + { + SphereIntersectsPolyWithTimeRecurse( + node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPolyId, ref hitNormal, ref bestTime); + return; + } + + // Straddles: check both sides to find the earliest collision. + SphereIntersectsPolyWithTimeRecurse( + node.PosNode, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPolyId, ref hitNormal, ref bestTime); + + SphereIntersectsPolyWithTimeRecurse( + node.NegNode, polygons, vertices, + sphereCenter, sphereRadius, movement, + ref hitPolyId, ref hitNormal, ref bestTime); + } } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 7304a77..688bba7 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Numerics; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -15,6 +16,7 @@ public sealed class PhysicsDataCache { private readonly ConcurrentDictionary _gfxObj = new(); private readonly ConcurrentDictionary _setup = new(); + private readonly ConcurrentDictionary _cellStruct = new(); /// /// Extract and cache the physics BSP + polygon data from a GfxObj. @@ -53,10 +55,35 @@ public sealed class PhysicsDataCache }; } + /// + /// Extract and cache the physics BSP + polygon data from a CellStruct + /// (indoor room geometry). No-ops if the id is already cached or the + /// CellStruct has no physics BSP. + /// + public void CacheCellStruct(uint envCellId, CellStruct cellStruct, + Matrix4x4 worldTransform) + { + if (_cellStruct.ContainsKey(envCellId)) return; + if (cellStruct.PhysicsBSP?.Root is null) return; + + Matrix4x4.Invert(worldTransform, out var inverseTransform); + + _cellStruct[envCellId] = new CellPhysics + { + BSP = cellStruct.PhysicsBSP, + PhysicsPolygons = cellStruct.PhysicsPolygons, + Vertices = cellStruct.VertexArray, + WorldTransform = worldTransform, + InverseWorldTransform = inverseTransform, + }; + } + public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null; public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; + public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public int GfxObjCount => _gfxObj.Count; public int SetupCount => _setup.Count; + public int CellStructCount => _cellStruct.Count; } /// Cached physics data for a single GfxObj part. @@ -78,3 +105,17 @@ public sealed class SetupPhysics public float StepUpHeight { get; init; } public float StepDownHeight { get; init; } } + +/// +/// Cached physics data for an indoor cell's room geometry (CellStruct). +/// Used for wall/floor/ceiling collision in EnvCells. +/// ACE: EnvCell.find_env_collisions queries CellStructure.PhysicsBSP. +/// +public sealed class CellPhysics +{ + public required PhysicsBSPTree BSP { get; init; } + public required Dictionary PhysicsPolygons { get; init; } + public required VertexArray Vertices { get; init; } + public Matrix4x4 WorldTransform { get; init; } + public Matrix4x4 InverseWorldTransform { get; init; } +} diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 1774313..16f7fcf 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -511,18 +511,69 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; - // Sample terrain Z at the foot sphere's world position. Vector3 footCenter = sp.GlobalSphere[0].Origin; float sphereRadius = sp.GlobalSphere[0].Radius; + // ── Indoor cell BSP collision ──────────────────────────────────── + // If the player is in an indoor cell (low 16 bits >= 0x0100), + // query the CellStruct's PhysicsBSP for wall/floor/ceiling collision. + // ACE: EnvCell.find_env_collisions -> CellStructure.PhysicsBSP.find_collisions + uint cellLow = sp.CheckCellId & 0xFFFFu; + if (cellLow >= 0x0100 && engine.DataCache is not null) + { + var cellPhysics = engine.DataCache.GetCellStruct(sp.CheckCellId); + if (cellPhysics?.BSP?.Root is not null) + { + // Transform player sphere to cell-local space. + var localCenter = Vector3.Transform(footCenter, cellPhysics.InverseWorldTransform); + var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cellPhysics.InverseWorldTransform); + + var localSphere = new DatReaderWriter.Types.Sphere + { + Origin = localCenter, + Radius = sphereRadius, + }; + + // Second sphere (head) in local space, if present. + DatReaderWriter.Types.Sphere? localSphere1 = null; + if (sp.NumSphere > 1) + { + var headCenter = sp.GlobalSphere[1].Origin; + localSphere1 = new DatReaderWriter.Types.Sphere + { + Origin = Vector3.Transform(headCenter, cellPhysics.InverseWorldTransform), + Radius = sp.GlobalSphere[1].Radius, + }; + } + + // Use the full 6-path BSP dispatcher for retail-faithful collision. + var cellState = BSPQuery.FindCollisions( + cellPhysics.BSP.Root, + cellPhysics.PhysicsPolygons, + cellPhysics.Vertices, + this, + localSphere, + localSphere1, + localCurrCenter, + Vector3.UnitZ, // local space Z is up + 1.0f); // scale = 1.0 for cell geometry + + if (cellState != TransitionState.OK) + { + if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact)) + ci.CollidedWithEnvironment = true; + return cellState; + } + } + } + + // ── Outdoor terrain collision ──────────────────────────────────── + // Sample terrain Z at the foot sphere's world position. float? terrainZ = engine.SampleTerrainZ(footCenter.X, footCenter.Y); if (terrainZ is null) return TransitionState.OK; // no terrain loaded here — allow pass-through // Build the terrain contact plane (flat ground: Normal = +Z, D = -terrainZ). - // For sloped terrain we'd need the surface normal from the triangle; for MVP - // we use the vertical plane which matches flat terrain exactly and gives - // conservative results on slopes (terrain Z is already interpolated correctly). var contactPlane = new System.Numerics.Plane( new Vector3(0f, 0f, 1f), -terrainZ.Value); @@ -646,113 +697,108 @@ public sealed class Transition worldOffsetX, worldOffsetY, landblockId, _nearbyObjs); - // Find the EARLIEST collision along the movement path. + // Test both foot sphere (index 0) and head sphere (index 1) if present. float bestT = float.MaxValue; Vector3 bestNormal = Vector3.Zero; + bool bestIsHeadSphere = false; - foreach (var obj in _nearbyObjs) + for (int sphereIdx = 0; sphereIdx < sp.NumSphere; sphereIdx++) { - // Broad-phase: can the moving sphere reach this object? - // Use horizontal distance for cylinders (Z extent is checked separately). - Vector3 deltaToCurr = currPos - obj.Position; - float distToCurr; - if (obj.CollisionType == ShadowCollisionType.Cylinder) - distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y); - else - distToCurr = deltaToCurr.Length(); - float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f; - if (distToCurr > maxReach) - continue; + Vector3 sphereCheckPos = sp.GlobalSphere[sphereIdx].Origin; + Vector3 sphereCurrPos = sp.GlobalCurrCenter[sphereIdx].Origin; + float sphRadius = sp.GlobalSphere[sphereIdx].Radius; + Vector3 sphMovement = sphereCheckPos - sphereCurrPos; - float t; - Vector3 worldHitNormal; - - if (obj.CollisionType == ShadowCollisionType.BSP) + foreach (var obj in _nearbyObjs) { - var physics = engine.DataCache.GetGfxObj(obj.GfxObjId); - if (physics?.BSP?.Root is null) continue; - - // Transform to object-local space. - var invRot = Quaternion.Inverse(obj.Rotation); - Vector3 localCurrPos = Vector3.Transform(currPos - obj.Position, invRot); - Vector3 localMovement = Vector3.Transform(movement, invRot); - - // Use movement-aware BSP query with front-face culling. - if (!BSPQuery.SphereIntersectsPoly( - physics.BSP.Root, - physics.PhysicsPolygons, - physics.Vertices, - localCurrPos, sphereRadius, - localMovement, - out _, out Vector3 localHitNormal)) + // Broad-phase: can the moving sphere reach this object? + Vector3 deltaToCurr = sphereCurrPos - obj.Position; + float distToCurr; + if (obj.CollisionType == ShadowCollisionType.Cylinder) + distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y); + else + distToCurr = deltaToCurr.Length(); + float maxReach = sphRadius + obj.Radius + sphMovement.Length() + 2f; + if (distToCurr > maxReach) continue; - worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation); + float t; + Vector3 worldHitNormal; - // Compute parametric contact time: how far along the movement - // does the sphere first touch this polygon? - // Project the center-to-plane distance onto the movement direction. - float planeDist = Vector3.Dot(localHitNormal, localCurrPos) - - Vector3.Dot(localHitNormal, Vector3.Zero); // plane through origin in local - float approach = -Vector3.Dot(localHitNormal, localMovement); - if (approach > PhysicsGlobals.EPSILON) - t = (planeDist - sphereRadius) / approach; - else - t = 0f; // already touching or parallel - t = Math.Clamp(t, 0f, 1f); - } - else - { - // Cylinder swept-sphere test. - // Find parametric time when moving sphere first contacts the cylinder. - Vector3 deltaCurr = currPos - obj.Position; - float dx = deltaCurr.X, dy = deltaCurr.Y; - float mx = movement.X, my = movement.Y; - float combinedR = sphereRadius + obj.Radius; - - // Quadratic: |curr_xy + t*move_xy|^2 = combinedR^2 - float a = mx * mx + my * my; - float b = 2f * (dx * mx + dy * my); - float c = dx * dx + dy * dy - combinedR * combinedR; - - if (a < PhysicsGlobals.EPSILON) + if (obj.CollisionType == ShadowCollisionType.BSP) { - // Not moving horizontally — check static overlap. - if (c > 0f) continue; - t = 0f; + var physics = engine.DataCache.GetGfxObj(obj.GfxObjId); + if (physics?.BSP?.Root is null) continue; + + // Transform to object-local space. + var invRot = Quaternion.Inverse(obj.Rotation); + Vector3 localCurrPos = Vector3.Transform(sphereCurrPos - obj.Position, invRot); + Vector3 localMovement = Vector3.Transform(sphMovement, invRot); + + // Use movement-aware BSP query with front-face culling. + if (!BSPQuery.SphereIntersectsPolyWithTime( + physics.BSP.Root, + physics.PhysicsPolygons, + physics.Vertices, + localCurrPos, sphRadius, + localMovement, + out _, out Vector3 localHitNormal, out t)) + continue; + + worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation); + t = Math.Clamp(t, 0f, 1f); } else { - float disc = b * b - 4f * a * c; - if (disc < 0f) continue; // no intersection - float sqrtDisc = MathF.Sqrt(disc); - t = (-b - sqrtDisc) / (2f * a); // first contact time - if (t > 1f) continue; // contact is past this step - if (t < 0f) t = 0f; // already overlapping + // Cylinder swept-sphere test. + Vector3 deltaCurr = sphereCurrPos - obj.Position; + float dx = deltaCurr.X, dy = deltaCurr.Y; + float mx = sphMovement.X, my = sphMovement.Y; + float combinedR = sphRadius + obj.Radius; + + float a = mx * mx + my * my; + float b = 2f * (dx * mx + dy * my); + float c = dx * dx + dy * dy - combinedR * combinedR; + + if (a < PhysicsGlobals.EPSILON) + { + if (c > 0f) continue; + t = 0f; + } + else + { + float disc = b * b - 4f * a * c; + if (disc < 0f) continue; + float sqrtDisc = MathF.Sqrt(disc); + t = (-b - sqrtDisc) / (2f * a); + if (t > 1f) continue; + if (t < 0f) t = 0f; + } + + // Vertical check at contact time. + Vector3 contactPos = sphereCurrPos + sphMovement * t; + float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f; + float playerBottom = contactPos.Z - sphRadius; + float playerTop = contactPos.Z + sphRadius; + if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z) + continue; + + // Normal: radial at contact point. + Vector3 contactDelta = contactPos - obj.Position; + float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y); + if (hDist < PhysicsGlobals.EPSILON) + worldHitNormal = Vector3.UnitX; + else + worldHitNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f)); } - // Vertical check at contact time. - Vector3 contactPos = currPos + movement * t; - float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f; - float playerBottom = contactPos.Z - sphereRadius; - float playerTop = contactPos.Z + sphereRadius; - if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z) - continue; - - // Normal: radial at contact point. - Vector3 contactDelta = contactPos - obj.Position; - float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y); - if (hDist < PhysicsGlobals.EPSILON) - worldHitNormal = Vector3.UnitX; - else - worldHitNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f)); - } - - if (t < bestT && worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) - { - bestT = t; - bestNormal = Vector3.Normalize(worldHitNormal); + if (t < bestT && worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + { + bestT = t; + bestNormal = Vector3.Normalize(worldHitNormal); + bestIsHeadSphere = (sphereIdx == 1); + } } } @@ -761,10 +807,24 @@ public sealed class Transition return TransitionState.OK; } + // ── Fix 3: Contact-path step-up attempt ───────────────────────── + // When in contact with ground and hitting a low obstacle (not the head + // sphere), try stepping up before falling back to slide. + // ACE: BSPTree.find_collisions path 5 — Contact|OnWalkable → step_sphere_up. + if (!bestIsHeadSphere + && ObjectInfo.Contact + && bestNormal.Z > PhysicsGlobals.EPSILON + && bestNormal.Z < PhysicsGlobals.FloorZ) + { + // The surface is angled (not a vertical wall, not a floor) — + // attempt step-up. Set the flag for the transition system. + sp.StepUp = true; + sp.StepUpNormal = bestNormal; + ci.SetCollisionNormal(bestNormal); + return TransitionState.OK; + } + // Already overlapping at the START of the step (bestT == 0 or very small). - // This happens when the player spawns inside an object or a previous - // step left them penetrating. Push out along the collision normal - // instead of sliding — sliding with zero displacement gets stuck. if (bestT <= PhysicsGlobals.EPSILON) { Vector3 pushOut = bestNormal * (sphereRadius * 0.5f + 0.01f); @@ -775,12 +835,10 @@ public sealed class Transition } // Rewind the sphere to just BEFORE the contact point. - // Use t slightly before bestT to ensure no penetration. if (bestT < 1f) { float safeT = MathF.Max(0f, bestT - 0.02f); Vector3 contactPos = currPos + movement * safeT; - // Additional push along normal to clear the surface. contactPos += bestNormal * 0.02f; sp.SetCheckPos(contactPos, sp.CheckCellId); }