fix(core): Phase B.3 — add centroid + radius bounds to PortalPlane crossing test

Two targeted fixes for user-reported movement bugs:

1. Wall bounce: PortalPlane.FromVertices now accepts ALL polygon vertices
   (not just 3) for accurate centroid + bounding radius. IsCrossing uses
   2D (XY) distance check with tight radius (no multiplier) to prevent
   wall faces from triggering false indoor transitions. Walking along a
   building wall no longer launches the player into the air.

2. Slope alignment: PlayerMovementController adds a slope-proportional
   Z bias when walking uphill (up to +0.8 on steep slopes, grounded
   only). Prevents feet from sinking into the visual terrain mesh on
   slopes where the physics sample point lags the render surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 19:08:46 +02:00
parent 41013ce3e3
commit dc0341e85a
3 changed files with 71 additions and 31 deletions

View file

@ -88,6 +88,7 @@ public sealed class PlayerMovementController
// Heartbeat timer. // Heartbeat timer.
private float _heartbeatAccum; private float _heartbeatAccum;
private float _prevGroundZ;
public const float HeartbeatInterval = 0.2f; // 200ms public const float HeartbeatInterval = 0.2f; // 200ms
public bool HeartbeatDue { get; private set; } public bool HeartbeatDue { get; private set; }
@ -100,6 +101,7 @@ public sealed class PlayerMovementController
{ {
Position = pos; Position = pos;
CellId = cellId; CellId = cellId;
_prevGroundZ = pos.Z;
} }
public MovementResult Update(float dt, MovementInput input) public MovementResult Update(float dt, MovementInput input)
@ -198,8 +200,19 @@ public sealed class PlayerMovementController
} }
} }
// Small upward bias prevents feet from z-fighting with terrain surface. // Upward bias prevents feet from sinking into the terrain surface.
Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.15f); // On slopes the visual terrain mesh rises ahead of the physics sample
// point, so we add extra bias proportional to how fast the ground Z is
// changing (steeper slope → more bias). Only apply when grounded — during
// jumps/falls the bias would interfere with the ballistic arc.
float slopeBias = 0f;
if (!IsAirborne)
{
float slopeDelta = MathF.Max(0f, newZ - _prevGroundZ);
slopeBias = MathF.Min(slopeDelta * 3f, 0.8f);
}
_prevGroundZ = newZ;
Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.15f + slopeBias);
CellId = result.CellId; CellId = result.CellId;
// 4. Determine current motion commands. // 4. Determine current motion commands.

View file

@ -1473,13 +1473,18 @@ public sealed class GameWindow : IDisposable
if (poly.VertexIds.Count < 3) if (poly.VertexIds.Count < 3)
continue; continue;
// VertexIds are short; worldVerts keys are ushort. // Collect ALL polygon vertices for accurate centroid + radius.
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[0], out var v0)) continue; var portalVerts = new System.Numerics.Vector3[poly.VertexIds.Count];
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[1], out var v1)) continue; bool allFound = true;
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[2], out var v2)) continue; for (int pv = 0; pv < poly.VertexIds.Count; pv++)
{
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[pv], out portalVerts[pv]))
{ allFound = false; break; }
}
if (!allFound) continue;
portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices( portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices(
v0, v1, v2, portalVerts.AsSpan(),
portal.OtherCellId, // target cell (0xFFFF = outdoor) portal.OtherCellId, // target cell (0xFFFF = outdoor)
envCellId & 0xFFFFu, // owner cell (low 16 bits) envCellId & 0xFFFFu, // owner cell (low 16 bits)
(ushort)portal.Flags)); (ushort)portal.Flags));

View file

@ -16,29 +16,49 @@ public readonly record struct PortalPlane(
float Radius) // bounding radius of the portal polygon float Radius) // bounding radius of the portal polygon
{ {
/// <summary> /// <summary>
/// Construct a PortalPlane from three coplanar vertices (winding order /// Construct a PortalPlane from ALL polygon vertices (winding order of the
/// determines the normal direction via cross product). Also computes /// first three determines the normal). Centroid + radius are computed from
/// the centroid and bounding radius from the vertices so /// the full vertex set for accurate doorway bounds.
/// <see cref="IsCrossing"/> can reject crossings far from the portal. /// </summary>
public static PortalPlane FromVertices(
ReadOnlySpan<Vector3> vertices,
uint targetCellId, uint ownerCellId, ushort flags)
{
if (vertices.Length < 3)
throw new ArgumentException("Need at least 3 vertices", nameof(vertices));
var edge1 = vertices[1] - vertices[0];
var edge2 = vertices[2] - vertices[0];
var normal = Vector3.Normalize(Vector3.Cross(edge1, edge2));
float d = -Vector3.Dot(normal, vertices[0]);
// Centroid = average of ALL vertices.
var sum = Vector3.Zero;
foreach (var v in vertices) sum += v;
var centroid = sum / vertices.Length;
// Bounding radius = max distance from centroid to any vertex.
// NO padding — we rely on the tight radius to prevent wall-bounce.
float maxR = 0f;
foreach (var v in vertices)
{
float r = Vector3.Distance(centroid, v);
if (r > maxR) maxR = r;
}
return new PortalPlane(normal, d, targetCellId, ownerCellId, flags, centroid, maxR);
}
/// <summary>
/// Convenience overload for 3-vertex portals (backwards-compatible with
/// existing test call sites).
/// </summary> /// </summary>
public static PortalPlane FromVertices( public static PortalPlane FromVertices(
Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v0, Vector3 v1, Vector3 v2,
uint targetCellId, uint ownerCellId, ushort flags) uint targetCellId, uint ownerCellId, ushort flags)
{ {
var edge1 = v1 - v0; ReadOnlySpan<Vector3> verts = stackalloc Vector3[] { v0, v1, v2 };
var edge2 = v2 - v0; return FromVertices(verts, targetCellId, ownerCellId, flags);
var normal = Vector3.Normalize(Vector3.Cross(edge1, edge2));
float d = -Vector3.Dot(normal, v0);
// Centroid = average of the three vertices.
var centroid = (v0 + v1 + v2) / 3f;
// Bounding radius = max distance from centroid to any vertex + padding.
float r0 = Vector3.Distance(centroid, v0);
float r1 = Vector3.Distance(centroid, v1);
float r2 = Vector3.Distance(centroid, v2);
float radius = MathF.Max(r0, MathF.Max(r1, r2)) + 2f; // 2 unit padding
return new PortalPlane(normal, d, targetCellId, ownerCellId, flags, centroid, radius);
} }
/// <summary> /// <summary>
@ -50,12 +70,14 @@ public readonly record struct PortalPlane(
/// </summary> /// </summary>
public bool IsCrossing(Vector3 oldPos, Vector3 newPos) public bool IsCrossing(Vector3 oldPos, Vector3 newPos)
{ {
// Quick reject: if both positions are far from the portal, skip // Quick reject: 2D (XY) distance from the portal centroid.
// the plane test entirely. Use the closer of the two positions. // Using XY-only prevents roof-level positions from triggering
float distOld = Vector3.Distance(oldPos, Centroid); // ground-floor doorways. The radius is kept tight (no multiplier)
float distNew = Vector3.Distance(newPos, Centroid); // so only positions genuinely near the doorway opening pass.
float minDist = MathF.Min(distOld, distNew); float dx = MathF.Min(MathF.Abs(oldPos.X - Centroid.X), MathF.Abs(newPos.X - Centroid.X));
if (minDist > Radius * 2f) return false; float dy = MathF.Min(MathF.Abs(oldPos.Y - Centroid.Y), MathF.Abs(newPos.Y - Centroid.Y));
float minDist2D = MathF.Sqrt(dx * dx + dy * dy);
if (minDist2D > Radius) return false;
float oldDist = Vector3.Dot(Normal, oldPos) + D; float oldDist = Vector3.Dot(Normal, oldPos) + D;
float newDist = Vector3.Dot(Normal, newPos) + D; float newDist = Vector3.Dot(Normal, newPos) + D;