feat(physics): Task 2 — true sphere collision primitive (CSphere::intersects_sphere)

Setup.Spheres were previously coerced to short cylinders (CylHeight=2*r),
which is geometrically wrong: a cylinder has flat caps; a sphere does not.
This ported CSphere::intersects_sphere (0x00537A80) so sphere-typed shadow
entries are tested as spheres — 3-D distance, no height clamping.

Changes:
- ShadowObjectRegistry.cs: added ShadowCollisionType.Sphere (enum value 2).
  The BuildFloodSpheres anyCyl dedup at :232 is unaffected: only Cylinder
  sets anyCyl=true; Sphere shapes fall through to the BSP-fallback path
  (anyCyl=false → included), which is correct.
- ShadowShapeBuilder.cs: FromSetup now emits ShadowCollisionType.Sphere
  (CylHeight=0) for Setup.Spheres instead of a short Cylinder.
- CollisionPrimitives.cs: added SweptSphereHitsSphere — quadratic swept
  solve ported from ACE Sphere.cs::FindTimeOfCollision, which is a C# port
  of retail's CSphere::intersects_sphere @ 0x00537A80. Sign convention
  confirmed against the decomp: retail negates the root to produce a
  forward t ∈ (0,1].
- TransitionTypes.cs: added Sphere narrow-phase branch between BSP and
  Cylinder in FindObjCollisionsInCell; uses 3-D distance for overlap
  (not XY-only). Added SphereCollision() method implementing the 3-D
  wall-slide response. Updated diagnostic logging at :2734 to cover Sphere.
- Updated ShadowShapeBuilderTests for new Sphere type assertion.
- New SphereIntersectsSphereConformanceTests: 9 geometrically-anchored
  cases (head-on, tangent, perpendicular-miss, lateral-near-miss,
  sweep-away, beyond-step, degenerate-zero-sweep, already-overlapping,
  vertical-sweep).

Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail);
ACE Sphere.cs::FindTimeOfCollision (C# port, cross-confirmed).
Build: 0 errors, 10 warnings (pre-existing).
Tests: 1576 pass / 0 fail / 2 skip (1578 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 19:08:53 +02:00
parent 79dee342f2
commit 78e5758185
7 changed files with 609 additions and 16 deletions

View file

@ -634,6 +634,115 @@ public static class CollisionPrimitives
return (offset - dist) / denom;
}
// -----------------------------------------------------------------------
// 8b. SweptSphereHitsSphere — CSphere::intersects_sphere narrow-phase
// -----------------------------------------------------------------------
/// <summary>
/// Returns <see langword="true"/> when a moving sphere first intersects a
/// stationary sphere within the movement step, and the parametric contact
/// time <paramref name="t"/> is in (0, 1].
///
/// <para>
/// Ported from <c>CSphere::FindTimeOfCollision</c> in
/// <c>ACE.Server/Physics/Sphere.cs</c>, which is a line-for-line C# port
/// of retail's <c>CSphere::intersects_sphere @ 0x00537A80</c> (the
/// "collide ≠ 0, not creature" branch at <c>0x00537B8C</c>).
/// </para>
///
/// <para>
/// The retail quadratic (from the decomp):
/// <list type="bullet">
/// <item><c>distSq = |movement|²</c> — squared length of sweep vector.</item>
/// <item><c>gap = |spherePos|² radSum²</c> — positive when centers
/// are separated, negative when already overlapping.</item>
/// <item><c>similar = dot(spherePos, movement)</c> — projection of the
/// separation onto the movement direction.</item>
/// <item><c>disc = similar² gap·distSq</c> — discriminant.</item>
/// <item>Pick the earlier root, normalise by <c>distSq</c>.</item>
/// </list>
/// </para>
///
/// <para>
/// Returns <see langword="false"/> when the spheres are already overlapping
/// (<c>gap &lt; ε</c>), the discriminant is negative (miss), the movement
/// is degenerate, or the contact time is outside (0, 1].
/// </para>
/// </summary>
/// <param name="moverCenter">
/// World-space centre of the moving sphere at the START of the step.
/// </param>
/// <param name="moverRadius">Radius of the moving sphere.</param>
/// <param name="sweepDelta">
/// Movement vector: <c>checkPos currCenter</c>.
/// </param>
/// <param name="targetCenter">
/// World-space centre of the stationary target sphere.
/// </param>
/// <param name="targetRadius">Radius of the target sphere.</param>
/// <param name="t">
/// On success: parametric fraction of <paramref name="sweepDelta"/> at
/// which the sphere surfaces first touch (in (0, 1]).
/// Undefined on failure.
/// </param>
/// <returns>
/// <see langword="true"/> when the mover hits the target within this step.
/// </returns>
public static bool SweptSphereHitsSphere(
Vector3 moverCenter, float moverRadius,
Vector3 sweepDelta,
Vector3 targetCenter, float targetRadius,
out float t)
{
t = 0f;
// movement = sweepDelta (mover travels from moverCenter by this vector)
// spherePos = targetCenter moverCenter (target relative to mover start)
// radSum = combined radius for first-surface-contact
float radSum = moverRadius + targetRadius;
float mx = sweepDelta.X, my = sweepDelta.Y, mz = sweepDelta.Z;
float distSq = mx * mx + my * my + mz * mz;
if (distSq < EpsilonSq)
return false; // degenerate sweep (stationary mover)
float sx = targetCenter.X - moverCenter.X;
float sy = targetCenter.Y - moverCenter.Y;
float sz = targetCenter.Z - moverCenter.Z;
// gap = |spherePos|² radSum²
// Positive → centers are separated (the common case).
// Negative → already overlapping → treat as no forward collision (retail returns -1).
float gap = sx * sx + sy * sy + sz * sz - radSum * radSum;
if (gap < EpsilonSq)
return false; // already overlapping — use static test separately
// similar = dot(spherePos, movement)
// Positive when the sphere is in FRONT of us (moving toward it).
float similar = -(sx * mx + sy * my + sz * mz);
// discriminant = similar² gap · distSq
float disc = similar * similar - gap * distSq;
if (disc < 0f)
return false; // ray misses the combined-radius sphere entirely
float cDist = MathF.Sqrt(disc);
// Pick the nearer root. ACE mirrors retail (Sphere.cs::FindTimeOfCollision):
// if (similar cDist < 0) → return 1 × (cDist + similar) / distSq
// else → return 1 × (similar cDist) / distSq
// The 1 negation converts from ACE's "closest-approach" parameterisation
// back to a forward t ∈ (0,1] (positive = hit ahead of mover).
float root = (similar - cDist < 0f) ? -(cDist + similar) : -(similar - cDist);
// Normalise to [0, 1] scale
t = root / distSq;
// t ≤ 0: contact is behind / at the start (already handled by gap check).
// t > 1: contact is beyond this movement step — miss.
return t > 0f && t <= 1f;
}
// -----------------------------------------------------------------------
// 9. land_on_sphere — FUN_00538f50
// -----------------------------------------------------------------------