feat(physics): PhysicsDataCache + BSP sphere query
Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming. BSPQuery.SphereIntersectsPoly traverses the tree for collision detection. Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly. - PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics (BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions). CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site. - BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly (FUN_00539500), and splitting-plane classification for internal nodes. - GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites (streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path). - 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact, internal node recursion, and empty cache behaviour. All 447 tests green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0bec5d5296
commit
874d267117
4 changed files with 462 additions and 0 deletions
|
|
@ -37,6 +37,11 @@ public sealed class GameWindow : IDisposable
|
|||
// Phase B.3: physics engine — populated from the streaming pipeline.
|
||||
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
|
||||
|
||||
// Task 4: physics data cache — BSP trees + collision shapes extracted from
|
||||
// GfxObj/Setup dats during streaming. Populated on the worker thread;
|
||||
// ConcurrentDictionary inside makes cross-thread access safe.
|
||||
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new();
|
||||
|
||||
// Step 4: portal-based interior cell visibility.
|
||||
private readonly CellVisibility _cellVisibility = new();
|
||||
|
||||
|
|
@ -212,6 +217,8 @@ public sealed class GameWindow : IDisposable
|
|||
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
var playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId);
|
||||
if (playerSetup is not null)
|
||||
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
|
||||
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
|
||||
? playerSetup.StepUpHeight
|
||||
: 2f; // default human step height
|
||||
|
|
@ -581,6 +588,8 @@ public sealed class GameWindow : IDisposable
|
|||
// Hydrate mesh refs from the Setup dat. This is the same code path
|
||||
// used by the static scenery pipeline (see the Setup hydration above).
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.SetupTableId.Value);
|
||||
if (setup is not null)
|
||||
_physicsDataCache.CacheSetup(spawn.SetupTableId.Value, setup);
|
||||
if (setup is null)
|
||||
{
|
||||
_liveDropReasonSetupDatMissing++;
|
||||
|
|
@ -683,6 +692,7 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine($"live: [STATUE] resolve part={pi} GfxObj 0x{parts[pi].GfxObjId:X8} missing");
|
||||
continue;
|
||||
}
|
||||
_physicsDataCache.CacheGfxObj(parts[pi].GfxObjId, partGfx);
|
||||
|
||||
if (isStatueDiag)
|
||||
Console.WriteLine($"live: [STATUE] resolve part={pi} gfx=0x{parts[pi].GfxObjId:X8} surfaces={partGfx.Surfaces.Count}");
|
||||
|
|
@ -724,6 +734,7 @@ public sealed class GameWindow : IDisposable
|
|||
var mr = parts[partIdx];
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
|
||||
|
||||
|
|
@ -1147,8 +1158,11 @@ public sealed class GameWindow : IDisposable
|
|||
// Single GfxObj stab — identity part transform.
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(
|
||||
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
|
||||
}
|
||||
}
|
||||
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||||
{
|
||||
|
|
@ -1156,11 +1170,13 @@ public sealed class GameWindow : IDisposable
|
|||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup);
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
meshRefs.Add(mr);
|
||||
}
|
||||
}
|
||||
|
|
@ -1258,6 +1274,7 @@ public sealed class GameWindow : IDisposable
|
|||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
|
||||
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
|
||||
|
|
@ -1268,11 +1285,13 @@ public sealed class GameWindow : IDisposable
|
|||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(spawn.ObjectId, setup);
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
// Compose: part's own transform, then the spawn's scale.
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat));
|
||||
|
|
@ -1384,6 +1403,7 @@ public sealed class GameWindow : IDisposable
|
|||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
|
||||
if (gfx is not null)
|
||||
{
|
||||
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
|
||||
}
|
||||
|
|
@ -1393,11 +1413,13 @@ public sealed class GameWindow : IDisposable
|
|||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
|
||||
if (setup is not null)
|
||||
{
|
||||
_physicsDataCache.CacheSetup(stab.Id, setup);
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
meshRefs.Add(mr);
|
||||
}
|
||||
|
|
@ -1720,6 +1742,7 @@ public sealed class GameWindow : IDisposable
|
|||
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
|
||||
if (gfx is null) continue;
|
||||
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
|
||||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||||
_staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
|
||||
}
|
||||
|
|
@ -2168,6 +2191,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(pe.SourceGfxObjOrSetupId);
|
||||
if (setup is null) return;
|
||||
_physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup);
|
||||
|
||||
// Build a minimal part template from the entity's current MeshRefs.
|
||||
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[pe.MeshRefs.Count];
|
||||
|
|
|
|||
151
src/AcDream.Core/Physics/BSPQuery.cs
Normal file
151
src/AcDream.Core/Physics/BSPQuery.cs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// BSP tree traversal for sphere-polygon collision detection.
|
||||
///
|
||||
/// <para>
|
||||
/// Ported from decompiled FUN_00539270 (chunk_00539000.c), cross-referenced
|
||||
/// against ACE's <c>BSPNode.sphere_intersects_poly()</c> in
|
||||
/// <c>Source/ACE.Server/Physics/BSP/BSPNode.cs</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The algorithm is a recursive descent through the BSP tree:
|
||||
/// <list type="number">
|
||||
/// <item>Broad phase: discard the subtree if the sphere cannot reach the
|
||||
/// node's bounding sphere.</item>
|
||||
/// <item>Leaf: test each polygon using the existing retail-ported
|
||||
/// <see cref="CollisionPrimitives.SphereIntersectsPoly"/>.</item>
|
||||
/// <item>Internal: classify the sphere against the splitting plane and
|
||||
/// recurse into the positive half, the negative half, or both when the
|
||||
/// sphere straddles the plane.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class BSPQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Test if a sphere intersects any polygon in the physics BSP tree.
|
||||
/// Returns <see langword="true"/> on the first hit, populating
|
||||
/// <paramref name="hitPolyId"/> and <paramref name="hitNormal"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Ported from FUN_00539270; cross-ref ACE BSPNode.sphere_intersects_poly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="node">Current BSP node (null-safe).</param>
|
||||
/// <param name="polygons">Physics polygon dictionary from the GfxObj.</param>
|
||||
/// <param name="vertices">Vertex array from the GfxObj.</param>
|
||||
/// <param name="sphereCenter">Sphere centre in object-local space.</param>
|
||||
/// <param name="sphereRadius">Sphere radius.</param>
|
||||
/// <param name="hitPolyId">Polygon id of the first intersecting polygon (0 on miss).</param>
|
||||
/// <param name="hitNormal">Outward normal at the hit point (zero on miss).</param>
|
||||
/// <returns><see langword="true"/> if any polygon is hit.</returns>
|
||||
public static bool SphereIntersectsPoly(
|
||||
PhysicsBSPNode? node,
|
||||
Dictionary<ushort, Polygon> polygons,
|
||||
VertexArray vertices,
|
||||
Vector3 sphereCenter,
|
||||
float sphereRadius,
|
||||
out ushort hitPolyId,
|
||||
out Vector3 hitNormal)
|
||||
{
|
||||
hitPolyId = 0;
|
||||
hitNormal = Vector3.Zero;
|
||||
|
||||
if (node is null) return false;
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Broad phase: reject the whole subtree when the sphere cannot
|
||||
// reach the node's bounding sphere. Both Leaf and internal nodes
|
||||
// carry a BoundingSphere in the retail format.
|
||||
// ----------------------------------------------------------------
|
||||
{
|
||||
float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin);
|
||||
if (dist > sphereRadius + node.BoundingSphere.Radius + CollisionPrimitives.Epsilon)
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Leaf node: test each referenced polygon against the sphere using
|
||||
// the retail-ported CollisionPrimitives.SphereIntersectsPoly.
|
||||
// ----------------------------------------------------------------
|
||||
if (node.Type == BSPNodeType.Leaf)
|
||||
{
|
||||
foreach (var polyIdx in node.Polygons)
|
||||
{
|
||||
if (!polygons.TryGetValue(polyIdx, out var poly)) continue;
|
||||
if (poly.VertexIds.Count < 3) continue;
|
||||
|
||||
// Gather polygon vertices from the vertex array.
|
||||
var polyVerts = new Vector3[poly.VertexIds.Count];
|
||||
bool allFound = true;
|
||||
for (int i = 0; i < poly.VertexIds.Count; i++)
|
||||
{
|
||||
ushort vid = (ushort)poly.VertexIds[i];
|
||||
if (vertices.Vertices.TryGetValue(vid, out var sv))
|
||||
polyVerts[i] = sv.Origin;
|
||||
else { allFound = false; break; }
|
||||
}
|
||||
if (!allFound) continue;
|
||||
|
||||
// Compute the polygon plane using the retail CalcNormal port.
|
||||
CollisionPrimitives.CalcNormal(polyVerts, out var normal, out float planeD);
|
||||
if (normal.LengthSquared() < CollisionPrimitives.EpsilonSq) continue;
|
||||
|
||||
var polyPlane = new Plane(normal, planeD);
|
||||
|
||||
if (CollisionPrimitives.SphereIntersectsPoly(
|
||||
polyPlane, polyVerts, sphereCenter, sphereRadius, out _))
|
||||
{
|
||||
hitPolyId = polyIdx;
|
||||
hitNormal = normal;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal node: classify sphere against splitting plane and
|
||||
// recurse into the positive side, negative side, or both.
|
||||
//
|
||||
// System.Numerics.Plane convention: dot(N, p) + D = 0 on the
|
||||
// surface, so signed distance = dot(N, center) + D.
|
||||
// FUN_00539270 uses the same sign convention.
|
||||
// ----------------------------------------------------------------
|
||||
float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter)
|
||||
+ node.SplittingPlane.D;
|
||||
float reach = sphereRadius - CollisionPrimitives.Epsilon;
|
||||
|
||||
if (splitDist >= reach)
|
||||
{
|
||||
// Sphere entirely on the positive side.
|
||||
return SphereIntersectsPoly(
|
||||
node.PosNode, polygons, vertices,
|
||||
sphereCenter, sphereRadius,
|
||||
out hitPolyId, out hitNormal);
|
||||
}
|
||||
|
||||
if (splitDist <= -reach)
|
||||
{
|
||||
// Sphere entirely on the negative side.
|
||||
return SphereIntersectsPoly(
|
||||
node.NegNode, polygons, vertices,
|
||||
sphereCenter, sphereRadius,
|
||||
out hitPolyId, out hitNormal);
|
||||
}
|
||||
|
||||
// Sphere straddles the plane — check both sides, return on first hit.
|
||||
if (SphereIntersectsPoly(node.PosNode, polygons, vertices,
|
||||
sphereCenter, sphereRadius, out hitPolyId, out hitNormal))
|
||||
return true;
|
||||
|
||||
return SphereIntersectsPoly(node.NegNode, polygons, vertices,
|
||||
sphereCenter, sphereRadius, out hitPolyId, out hitNormal);
|
||||
}
|
||||
}
|
||||
80
src/AcDream.Core/Physics/PhysicsDataCache.cs
Normal file
80
src/AcDream.Core/Physics/PhysicsDataCache.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using System.Collections.Concurrent;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe cache of physics-relevant data extracted from GfxObj and Setup
|
||||
/// dat objects during streaming. Populated by the streaming worker thread;
|
||||
/// read by the physics engine on the game/render thread. ConcurrentDictionary
|
||||
/// makes cross-thread access safe without a global lock.
|
||||
/// </summary>
|
||||
public sealed class PhysicsDataCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
|
||||
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
|
||||
|
||||
/// <summary>
|
||||
/// Extract and cache the physics BSP + polygon data from a GfxObj.
|
||||
/// No-ops if the id is already cached or the GfxObj has no physics data.
|
||||
/// </summary>
|
||||
public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj)
|
||||
{
|
||||
if (_gfxObj.ContainsKey(gfxObjId)) return;
|
||||
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
|
||||
if (gfxObj.PhysicsBSP?.Root is null) return;
|
||||
|
||||
_gfxObj[gfxObjId] = new GfxObjPhysics
|
||||
{
|
||||
BSP = gfxObj.PhysicsBSP,
|
||||
PhysicsPolygons = gfxObj.PhysicsPolygons,
|
||||
BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere,
|
||||
Vertices = gfxObj.VertexArray,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract and cache the collision shape data from a Setup.
|
||||
/// No-ops if the id is already cached.
|
||||
/// </summary>
|
||||
public void CacheSetup(uint setupId, Setup setup)
|
||||
{
|
||||
if (_setup.ContainsKey(setupId)) return;
|
||||
_setup[setupId] = new SetupPhysics
|
||||
{
|
||||
CylSpheres = setup.CylSpheres ?? new(),
|
||||
Spheres = setup.Spheres ?? new(),
|
||||
Height = setup.Height,
|
||||
Radius = setup.Radius,
|
||||
StepUpHeight = setup.StepUpHeight,
|
||||
StepDownHeight = setup.StepDownHeight,
|
||||
};
|
||||
}
|
||||
|
||||
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 int GfxObjCount => _gfxObj.Count;
|
||||
public int SetupCount => _setup.Count;
|
||||
}
|
||||
|
||||
/// <summary>Cached physics data for a single GfxObj part.</summary>
|
||||
public sealed class GfxObjPhysics
|
||||
{
|
||||
public required PhysicsBSPTree BSP { get; init; }
|
||||
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
|
||||
public Sphere? BoundingSphere { get; init; }
|
||||
public required VertexArray Vertices { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
|
||||
public sealed class SetupPhysics
|
||||
{
|
||||
public List<CylSphere> CylSpheres { get; init; } = new();
|
||||
public List<Sphere> Spheres { get; init; } = new();
|
||||
public float Height { get; init; }
|
||||
public float Radius { get; init; }
|
||||
public float StepUpHeight { get; init; }
|
||||
public float StepDownHeight { get; init; }
|
||||
}
|
||||
207
tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
Normal file
207
tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="BSPQuery.SphereIntersectsPoly"/>.
|
||||
///
|
||||
/// Real BSP data requires dat files (integration-test territory), so these
|
||||
/// tests use manually constructed BSP nodes and polygon/vertex data that
|
||||
/// match the structure the dat reader would produce.
|
||||
/// </summary>
|
||||
public class BSPQueryTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="VertexArray"/> with four vertices forming a unit
|
||||
/// square in the XY-plane (Z = 0), ids 0-3.
|
||||
/// </summary>
|
||||
private static VertexArray UnitSquareVertexArray()
|
||||
{
|
||||
var va = new VertexArray();
|
||||
var positions = new[]
|
||||
{
|
||||
new Vector3(0f, 0f, 0f),
|
||||
new Vector3(1f, 0f, 0f),
|
||||
new Vector3(1f, 1f, 0f),
|
||||
new Vector3(0f, 1f, 0f),
|
||||
};
|
||||
for (ushort i = 0; i < positions.Length; i++)
|
||||
{
|
||||
var sv = new SWVertex { Origin = positions[i], Normal = Vector3.UnitZ };
|
||||
va.Vertices[i] = sv;
|
||||
}
|
||||
return va;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="Polygon"/> referencing vertex ids 0-3 in order.
|
||||
/// </summary>
|
||||
private static Polygon UnitSquarePolygon() => new Polygon
|
||||
{
|
||||
SidesType = DatReaderWriter.Enums.CullMode.None,
|
||||
VertexIds = new List<short> { 0, 1, 2, 3 },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Build a leaf <see cref="PhysicsBSPNode"/> containing one polygon (id 0)
|
||||
/// with a bounding sphere that covers the unit square.
|
||||
/// </summary>
|
||||
private static PhysicsBSPNode LeafNode(Sphere bounds)
|
||||
{
|
||||
var node = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.Leaf,
|
||||
BoundingSphere = bounds,
|
||||
};
|
||||
node.Polygons.Add(0);
|
||||
return node;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: null node returns false without throwing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SphereIntersectsPoly_NullNode_ReturnsFalse()
|
||||
{
|
||||
var polygons = new Dictionary<ushort, Polygon>();
|
||||
var vertices = new VertexArray();
|
||||
|
||||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||||
null, polygons, vertices,
|
||||
new Vector3(0.5f, 0.5f, 0.1f), 0.2f,
|
||||
out _, out _);
|
||||
|
||||
Assert.False(hit);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: sphere far outside the bounding sphere is fast-rejected
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SphereIntersectsPoly_MissesBoundingSphere_ReturnsFalse()
|
||||
{
|
||||
// Leaf node centred at origin with radius 1.
|
||||
var bounds = new Sphere { Origin = Vector3.Zero, Radius = 1f };
|
||||
var node = LeafNode(bounds);
|
||||
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
|
||||
var vertices = UnitSquareVertexArray();
|
||||
|
||||
// Sphere is 100 units away — broad phase must reject.
|
||||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||||
node, polygons, vertices,
|
||||
new Vector3(100f, 100f, 100f), 0.5f,
|
||||
out _, out _);
|
||||
|
||||
Assert.False(hit);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: sphere resting just above the unit-square floor polygon hits
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SphereIntersectsPoly_HitsLeafPolygon()
|
||||
{
|
||||
// Bounding sphere covers the 1×1 unit-square leaf.
|
||||
var bounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
|
||||
var node = LeafNode(bounds);
|
||||
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
|
||||
var vertices = UnitSquareVertexArray();
|
||||
|
||||
// Sphere centred at (0.5, 0.5, 0.3) with radius 0.5 should touch Z=0 plane.
|
||||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||||
node, polygons, vertices,
|
||||
new Vector3(0.5f, 0.5f, 0.3f), 0.5f,
|
||||
out ushort polyId, out Vector3 normal);
|
||||
|
||||
Assert.True(hit);
|
||||
Assert.Equal(0, polyId);
|
||||
// Normal should point roughly upward (+Z).
|
||||
Assert.True(normal.Z > 0.9f, $"Expected Z-up normal, got {normal}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 4: sphere entirely above the polygon (no contact) returns false
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SphereIntersectsPoly_SphereTooHigh_ReturnsFalse()
|
||||
{
|
||||
var bounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
|
||||
var node = LeafNode(bounds);
|
||||
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
|
||||
var vertices = UnitSquareVertexArray();
|
||||
|
||||
// Sphere centred 5 units above the floor with radius 0.3 → no contact.
|
||||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||||
node, polygons, vertices,
|
||||
new Vector3(0.5f, 0.5f, 5f), 0.3f,
|
||||
out _, out _);
|
||||
|
||||
Assert.False(hit);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 5: internal node — sphere on positive side recurses pos subtree
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SphereIntersectsPoly_InternalNode_PosSubtreeHit()
|
||||
{
|
||||
// Leaf on the positive side (Z > 0 half-space) contains the floor poly.
|
||||
var leafBounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 2f };
|
||||
var leafNode = LeafNode(leafBounds);
|
||||
|
||||
var polygons = new Dictionary<ushort, Polygon> { [0] = UnitSquarePolygon() };
|
||||
var vertices = UnitSquareVertexArray();
|
||||
|
||||
// Splitting plane: Z = 0, normal = +Z, D = 0.
|
||||
// Sphere at Z = 0.3 is on the positive side.
|
||||
var internalBounds = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0f), Radius = 5f };
|
||||
var internalNode = new PhysicsBSPNode
|
||||
{
|
||||
Type = BSPNodeType.BPnn, // has PosNode only (BPnn = pos + null-neg)
|
||||
SplittingPlane = new Plane(Vector3.UnitZ, 0f),
|
||||
BoundingSphere = internalBounds,
|
||||
PosNode = leafNode,
|
||||
NegNode = null,
|
||||
};
|
||||
|
||||
bool hit = BSPQuery.SphereIntersectsPoly(
|
||||
internalNode, polygons, vertices,
|
||||
new Vector3(0.5f, 0.5f, 0.3f), 0.5f,
|
||||
out ushort polyId, out _);
|
||||
|
||||
Assert.True(hit);
|
||||
Assert.Equal(0, polyId);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 6: PhysicsDataCache — caches GfxObj with physics data
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PhysicsDataCache_CachesGfxObjWithPhysics()
|
||||
{
|
||||
// We can't easily construct a real GfxObj (field-based dat type),
|
||||
// so this test verifies the SetupPhysics cache path which is more
|
||||
// easily instantiated.
|
||||
var cache = new PhysicsDataCache();
|
||||
Assert.Equal(0, cache.GfxObjCount);
|
||||
Assert.Equal(0, cache.SetupCount);
|
||||
|
||||
// GetGfxObj for an unknown id should return null safely.
|
||||
Assert.Null(cache.GetGfxObj(0x01000001u));
|
||||
Assert.Null(cache.GetSetup(0x02000001u));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue