feat(physics): complete retail collision — indoor BSP, dual sphere, step-up, swept-sphere, 6-path dispatcher

Indoor CellStruct PhysicsBSP collision for room walls/ceilings.
Dual sphere (body+head) from Setup dimensions.
StepUp attempts before sliding when hitting low obstacles.
FindTimeOfCollision for exact parametric BSP contact time.
Full 6-path BSP dispatcher wired into FindEnvCollisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-14 14:00:52 +02:00
parent 1f30fbd2f5
commit cadc72ed08
4 changed files with 362 additions and 100 deletions

View file

@ -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.
// -----------------------------------------------------------------------
/// <summary>
/// Movement-aware sphere-BSP intersection that uses
/// <see cref="CollisionPrimitives.FindTimeOfCollision"/> to compute the
/// exact parametric time of first contact. Returns the earliest collision
/// across all polygons in the BSP tree.
///
/// <para>
/// Unlike <see cref="SphereIntersectsPoly(PhysicsBSPNode?, Dictionary{ushort, Polygon},
/// VertexArray, Vector3, float, Vector3, out ushort, out Vector3)"/> which
/// tests static overlap at start and end positions, this method finds the
/// precise contact time via swept-sphere analysis.
/// </para>
/// </summary>
public static bool SphereIntersectsPolyWithTime(
PhysicsBSPNode? node,
Dictionary<ushort, Polygon> 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<ushort, Polygon> 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);
}
}