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:
parent
79dee342f2
commit
78e5758185
7 changed files with 609 additions and 16 deletions
|
|
@ -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 < ε</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
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -532,9 +532,10 @@ public sealed class ShadowObjectRegistry
|
|||
|
||||
/// <summary>
|
||||
/// Collision type for a shadow entry. BSP uses full polygon collision.
|
||||
/// Cylinder uses a simple cylinder-sphere intersection test.
|
||||
/// Cylinder uses a cylinder-sphere intersection test (XY distance + height clamp).
|
||||
/// Sphere uses a true 3-D sphere-sphere intersection test (no height clamp).
|
||||
/// </summary>
|
||||
public enum ShadowCollisionType : byte { BSP, Cylinder }
|
||||
public enum ShadowCollisionType : byte { BSP, Cylinder, Sphere }
|
||||
|
||||
public readonly record struct ShadowEntry(
|
||||
uint EntityId,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ public static class ShadowShapeBuilder
|
|||
}
|
||||
|
||||
// 2. Spheres — only when no CylSpheres (matches landblock-static convention
|
||||
// at GameWindow.cs:6034). Each becomes a short Cylinder.
|
||||
// at GameWindow.cs:6034). Each becomes a true Sphere (no height clamping).
|
||||
// Retail anchor: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance
|
||||
// for the overlap check, unlike CCylSphere which clips to [low_pt, high_pt].
|
||||
if (setup.CylSpheres.Count == 0)
|
||||
{
|
||||
foreach (var sph in setup.Spheres)
|
||||
|
|
@ -75,9 +77,9 @@ public static class ShadowShapeBuilder
|
|||
LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale,
|
||||
LocalRotation: Quaternion.Identity,
|
||||
Scale: entScale,
|
||||
CollisionType: ShadowCollisionType.Cylinder,
|
||||
CollisionType: ShadowCollisionType.Sphere,
|
||||
Radius: sph.Radius * entScale,
|
||||
CylHeight: sph.Radius * 2f * entScale));
|
||||
CylHeight: 0f));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2604,6 +2604,28 @@ public sealed class Transition
|
|||
engine,
|
||||
worldOrigin: obj.Position);
|
||||
}
|
||||
else if (obj.CollisionType == ShadowCollisionType.Sphere)
|
||||
{
|
||||
// ── Sphere object: true 3-D sphere-sphere test ──────────
|
||||
// Retail anchor: CSphere::intersects_sphere @ 0x00537A80.
|
||||
// Unlike CCylSphere, CSphere uses 3-D distance (no height
|
||||
// clamp). The broad-phase above already used 3-D Length()
|
||||
// for Sphere entries (the Cylinder branch is XY-only).
|
||||
//
|
||||
// HAS_PHYSICS_BSP_PS dispatch (A6.P7): same rule as Cylinder —
|
||||
// if the entity's state marks BSP-only, skip the sphere test.
|
||||
if (BspOnlyDispatch(obj.State))
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||
{
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result = SphereCollision(obj, sp);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Cylinder object: swept-sphere cylinder test ──────────
|
||||
|
|
@ -2731,9 +2753,11 @@ public sealed class Transition
|
|||
// a BSP hit with null side-channel indicates a BSPQuery code
|
||||
// path that didn't write (a bug; we should fix it, not
|
||||
// pretend the entity was a cylinder).
|
||||
if (obj.CollisionType == ShadowCollisionType.Cylinder)
|
||||
if (obj.CollisionType == ShadowCollisionType.Cylinder ||
|
||||
obj.CollisionType == ShadowCollisionType.Sphere)
|
||||
{
|
||||
sb.Append("\n hitPoly: n/a (cylinder)");
|
||||
sb.Append(System.FormattableString.Invariant(
|
||||
$"\n hitPoly: n/a ({obj.CollisionType.ToString().ToLowerInvariant()})"));
|
||||
}
|
||||
else if (poly is null)
|
||||
{
|
||||
|
|
@ -2884,6 +2908,108 @@ public sealed class Transition
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sphere collision test for <see cref="ShadowCollisionType.Sphere"/> objects.
|
||||
/// Uses a true 3-D sphere-sphere overlap test — no height clamp, no XY-only
|
||||
/// distance — matching retail's <c>CSphere::intersects_sphere @ 0x00537A80</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// Implements the subset of the 6-path dispatcher needed for static/placed
|
||||
/// Sphere objects: static overlap check (obstruction_ethereal / check_walkable /
|
||||
/// Contact-grounded paths), plus a 3-D outward push-back for the slide response.
|
||||
/// The swept quadratic from <see cref="CollisionPrimitives.SweptSphereHitsSphere"/>
|
||||
/// is used for the narrow-phase; the slide response mirrors the cylinder's
|
||||
/// wall-slide but pushes outward in 3-D (not XY-only).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// ACE oracle: <c>Sphere.IntersectsSphere</c> in
|
||||
/// <c>ACE.Server/Physics/Sphere.cs</c> — particularly the
|
||||
/// <c>ObstructionEthereal/Placement</c>, <c>CheckWalkable</c>, and
|
||||
/// <c>Contact</c> branches. Retail decomp cross-reference:
|
||||
/// <c>acclient_2013_pseudo_c.txt:321678</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private TransitionState SphereCollision(ShadowEntry obj, SpherePath sp)
|
||||
{
|
||||
var ci = CollisionInfo;
|
||||
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
|
||||
Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
|
||||
float sphRadius = sp.GlobalSphere[0].Radius;
|
||||
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
|
||||
|
||||
// 3-D distance from check position to target sphere centre.
|
||||
// Unlike CCylSphere (which clips to a height range and uses XY-only
|
||||
// distance), CSphere uses the full 3-D Euclidean distance.
|
||||
// Retail anchor: CSphere::intersects_sphere @ 0x00537A80 —
|
||||
// the displacement vector is the full (x,y,z) delta, not XY-only.
|
||||
float dx = sphereCheckPos.X - obj.Position.X;
|
||||
float dy = sphereCheckPos.Y - obj.Position.Y;
|
||||
float dz = sphereCheckPos.Z - obj.Position.Z;
|
||||
float distSq = dx * dx + dy * dy + dz * dz;
|
||||
float combinedR = sphRadius + obj.Radius;
|
||||
float combinedRSq = combinedR * combinedR;
|
||||
|
||||
if (distSq >= combinedRSq)
|
||||
return TransitionState.OK; // not overlapping at check position
|
||||
|
||||
// ── Overlap detected — compute 3-D outward collision normal ──────
|
||||
float dist = MathF.Sqrt(distSq);
|
||||
Vector3 collisionNormal;
|
||||
if (dist < PhysicsGlobals.EPSILON)
|
||||
{
|
||||
// Sphere centers coincide — push back along reverse movement.
|
||||
float mLen = sphMovement.Length();
|
||||
if (mLen > PhysicsGlobals.EPSILON)
|
||||
collisionNormal = -sphMovement / mLen;
|
||||
else
|
||||
collisionNormal = Vector3.UnitX;
|
||||
}
|
||||
else
|
||||
{
|
||||
collisionNormal = new Vector3(dx / dist, dy / dist, dz / dist);
|
||||
}
|
||||
|
||||
// ── Wall-slide response (mirrors CylinderCollision but in 3-D) ───
|
||||
// Project movement onto the plane perpendicular to the collision normal,
|
||||
// then push the slid position outside the combined-radius shell.
|
||||
float movementIntoWall = Vector3.Dot(sphMovement, collisionNormal);
|
||||
Vector3 projectedMovement = sphMovement - collisionNormal * movementIntoWall;
|
||||
|
||||
Vector3 slidPos = sphereCurrPos + projectedMovement;
|
||||
|
||||
// Ensure slid position is outside combined radius (3-D push).
|
||||
float sdx = slidPos.X - obj.Position.X;
|
||||
float sdy = slidPos.Y - obj.Position.Y;
|
||||
float sdz = slidPos.Z - obj.Position.Z;
|
||||
float sDistSq = sdx * sdx + sdy * sdy + sdz * sdz;
|
||||
float minDist = combinedR + 0.01f;
|
||||
if (sDistSq < minDist * minDist)
|
||||
{
|
||||
float sDist = MathF.Sqrt(sDistSq);
|
||||
if (sDist < PhysicsGlobals.EPSILON)
|
||||
{
|
||||
slidPos.X = obj.Position.X + collisionNormal.X * minDist;
|
||||
slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
|
||||
slidPos.Z = obj.Position.Z + collisionNormal.Z * minDist;
|
||||
}
|
||||
else
|
||||
{
|
||||
float pushDist = minDist - sDist;
|
||||
slidPos.X += (sdx / sDist) * pushDist;
|
||||
slidPos.Y += (sdy / sDist) * pushDist;
|
||||
slidPos.Z += (sdz / sDist) * pushDist;
|
||||
}
|
||||
}
|
||||
|
||||
Vector3 delta = slidPos - sphereCheckPos;
|
||||
sp.AddOffsetToCheckPos(delta);
|
||||
|
||||
ci.SetCollisionNormal(collisionNormal);
|
||||
ci.SetSlidingNormal(collisionNormal);
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs,
|
||||
/// door foot-colliders). For Contact-grounded movers, attempts to step over short
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue