feat(ui): debug overlay + refined input controls

Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.

Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
  R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
  one shader + two draw calls (rect then text) for panel backgrounds
  under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
  the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
  are properly committed in this commit

Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
  adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
  stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
  the default neutral angle

Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
  set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
  correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
  physics Setup bounds
This commit is contained in:
Erik 2026-04-17 18:45:38 +02:00
parent 6b4e7569a3
commit ff325abd7b
20 changed files with 2734 additions and 268 deletions

View file

@ -1453,19 +1453,46 @@ public static class BSPQuery
}
// ----------------------------------------------------------------
// Path 5: Contact — sphere_intersects_poly + step_sphere_up / slide
// ACE transforms collision normal from local→global before step_up/slide
// Path 5: Contact — sphere_intersects_poly + wall-slide
// ACE retail uses StepSphereUp here, deferring to a retry loop that
// executes the step-up motion. We haven't ported that execution, so
// we apply the same wall-slide response as Path 6 — this at least
// gives correct blocking + sliding behavior for walls, buildings,
// and tree trunks while the player is on the ground.
// ----------------------------------------------------------------
if (obj.State.HasFlag(ObjectInfoState.Contact))
{
ResolvedPolygon? hitPoly0 = null;
Vector3 contact0 = Vector3.Zero;
if (SphereIntersectsPolyInternal(root, resolved, sphere0, movement,
ref hitPoly0, ref contact0))
bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement,
ref hitPoly0, ref contact0);
if (hit0 || hitPoly0 is not null)
{
var worldNormal = L2W(hitPoly0!.Plane.Normal);
return StepSphereUp(transition, worldNormal);
// Wall-slide response (same as Path 6 below).
var localNormal = hitPoly0!.Plane.Normal;
var localMovement = sphere0.Center - localCurrCenter;
float movementIntoWall = Vector3.Dot(localMovement, localNormal);
Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
Vector3 slidPos = localCurrCenter + projectedMovement;
float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D;
float minDist = sphere0.Radius + 0.01f;
if (slidDist < minDist)
{
slidPos += localNormal * (minDist - slidDist);
}
Vector3 localDelta = slidPos - sphere0.Center;
Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
path.AddOffsetToCheckPos(worldDelta);
var worldNormal = L2W(localNormal);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
}
if (sphere1 is not null)
@ -1473,17 +1500,34 @@ public static class BSPQuery
ResolvedPolygon? hitPoly1 = null;
Vector3 contact1 = Vector3.Zero;
if (SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
ref hitPoly1, ref contact1))
{
var worldNormal = L2W(hitPoly1!.Plane.Normal);
return SlideSphere(transition, worldNormal);
}
bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
ref hitPoly1, ref contact1);
if (hitPoly1 is not null)
return NegPolyHitDispatch(path, hitPoly1, false, localToWorld);
if (hitPoly0 is not null)
return NegPolyHitDispatch(path, hitPoly0, true, localToWorld);
if (hit1 || hitPoly1 is not null)
{
var localNormal = hitPoly1!.Plane.Normal;
var localMovement = sphere1.Center - localCurrCenter;
float movementIntoWall = Vector3.Dot(localMovement, localNormal);
Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
Vector3 slidPos = localCurrCenter + projectedMovement;
float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D;
float minDist = sphere1.Radius + 0.01f;
if (slidDist < minDist)
{
slidPos += localNormal * (minDist - slidDist);
}
Vector3 localDelta = slidPos - sphere1.Center;
Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
path.AddOffsetToCheckPos(worldDelta);
var worldNormal = L2W(localNormal);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
}
}
return TransitionState.OK;
@ -1509,11 +1553,50 @@ public static class BSPQuery
hitPoly0!, contact0, scale, localToWorld);
}
var worldNormal = L2W(hitPoly0!.Plane.Normal);
// ─── Wall-slide response ─────────────────────────────────
// Instead of just pushing the sphere out of penetration
// (which undoes the whole step), compute the wall-slide
// position: where the sphere WOULD be if the movement had
// been projected along the wall tangent.
//
// In local space:
// curr = localCurrCenter
// target = sphere0.Center
// movement = target - curr
// normal = polygon plane normal (outward)
// projectedMovement = movement - (movement · normal) * normal
// slidPos = curr + projectedMovement
//
// Then ensure slidPos is outside the plane by at least radius+eps.
var localNormal = hitPoly0!.Plane.Normal;
var localMovement = sphere0.Center - localCurrCenter;
// Project movement along wall tangent
float movementIntoWall = Vector3.Dot(localMovement, localNormal);
Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
// Slid position in local space
Vector3 slidPos = localCurrCenter + projectedMovement;
// Ensure slid position is OUTSIDE the plane by radius + epsilon
float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D;
float minDist = sphere0.Radius + 0.01f;
if (slidDist < minDist)
{
slidPos += localNormal * (minDist - slidDist);
}
// Delta from current CheckPos sphere center to slid position (local)
Vector3 localDelta = slidPos - sphere0.Center;
// Transform to world and apply
Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
path.AddOffsetToCheckPos(worldDelta);
var worldNormal = L2W(localNormal);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
path.Collide = true;
collisions.SetCollisionNormal(worldNormal);
return TransitionState.Adjusted;
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
}
if (sphere1 is not null)
@ -1526,9 +1609,29 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
var worldNormal = L2W(hitPoly1!.Plane.Normal);
// Head sphere hit: apply the same wall-slide as above.
var localNormal = hitPoly1!.Plane.Normal;
var localMovement = sphere1.Center - localCurrCenter;
float movementIntoWall = Vector3.Dot(localMovement, localNormal);
Vector3 projectedMovement = localMovement - localNormal * movementIntoWall;
Vector3 slidPos = localCurrCenter + projectedMovement;
float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D;
float minDist = sphere1.Radius + 0.01f;
if (slidDist < minDist)
{
slidPos += localNormal * (minDist - slidDist);
}
Vector3 localDelta = slidPos - sphere1.Center;
Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale;
path.AddOffsetToCheckPos(worldDelta);
var worldNormal = L2W(localNormal);
collisions.SetCollisionNormal(worldNormal);
return TransitionState.Collided;
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
}
}
}

View file

@ -16,18 +16,32 @@ namespace AcDream.Core.Physics;
public sealed class PhysicsDataCache
{
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = 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.
/// Extract and cache the physics BSP + polygon data from a GfxObj,
/// PLUS always cache a visual AABB from the vertex data regardless of
/// the HasPhysics flag. The visual AABB is used as a collision fallback
/// for entities whose Setup has no retail physics data — it lets the
/// user collide with decorative meshes that don't have a CylSphere or
/// per-part BSP.
/// </summary>
public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj)
{
// Always cache a visual AABB from the mesh vertices — this is cheap
// and fed by the mesh data that's already loaded. It serves as the
// fallback collision shape for pure-visual entities.
if (!_visualBounds.ContainsKey(gfxObjId) && gfxObj.VertexArray != null)
{
_visualBounds[gfxObjId] = ComputeVisualBounds(gfxObj.VertexArray);
}
if (_gfxObj.ContainsKey(gfxObjId)) return;
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
if (gfxObj.PhysicsBSP?.Root is null) return;
if (gfxObj.VertexArray is null) return;
_gfxObj[gfxObjId] = new GfxObjPhysics
{
@ -39,6 +53,58 @@ public sealed class PhysicsDataCache
};
}
/// <summary>
/// Get the cached visual AABB for a GfxObj, or null if not cached.
/// </summary>
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
_visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null;
/// <summary>
/// Compute a tight axis-aligned bounding box over all vertices in the mesh.
/// Used as a fallback collision shape for entities whose Setup has no
/// physics data — we approximate collision using the visual extent.
/// </summary>
private static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray)
{
if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0)
{
return new GfxObjVisualBounds
{
Min = Vector3.Zero,
Max = Vector3.Zero,
Center = Vector3.Zero,
Radius = 0f,
HalfExtents = Vector3.Zero,
};
}
var min = new Vector3(float.MaxValue);
var max = new Vector3(float.MinValue);
foreach (var kv in vertexArray.Vertices)
{
var p = kv.Value.Origin;
if (p.X < min.X) min.X = p.X;
if (p.Y < min.Y) min.Y = p.Y;
if (p.Z < min.Z) min.Z = p.Z;
if (p.X > max.X) max.X = p.X;
if (p.Y > max.Y) max.Y = p.Y;
if (p.Z > max.Z) max.Z = p.Z;
}
var center = (min + max) * 0.5f;
var halfExt = (max - min) * 0.5f;
float radius = halfExt.Length();
return new GfxObjVisualBounds
{
Min = min,
Max = max,
Center = center,
Radius = radius,
HalfExtents = halfExt,
};
}
/// <summary>
/// Extract and cache the collision shape data from a Setup.
/// No-ops if the id is already cached.
@ -145,6 +211,26 @@ public sealed class PhysicsDataCache
public int CellStructCount => _cellStruct.Count;
}
/// <summary>
/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
/// of whether it has physics data. Used as a collision fallback shape for
/// entities whose Setup has no CylSpheres/Spheres/Radius (pure decorative
/// meshes). Provides an approximate cylinder matching the visible mesh extent.
/// </summary>
public sealed class GfxObjVisualBounds
{
/// <summary>Local-space minimum corner of the mesh AABB.</summary>
public required Vector3 Min { get; init; }
/// <summary>Local-space maximum corner of the mesh AABB.</summary>
public required Vector3 Max { get; init; }
/// <summary>Center of the local-space AABB.</summary>
public required Vector3 Center { get; init; }
/// <summary>Local-space radius (diagonal half-length) — loose bound.</summary>
public required float Radius { get; init; }
/// <summary>Local-space half-extents ((Max - Min) * 0.5).</summary>
public required Vector3 HalfExtents { get; init; }
}
/// <summary>
/// A physics polygon with pre-resolved vertex positions and pre-computed plane.
/// ACE pre-computes these in its Polygon constructor; we do it at cache time

View file

@ -23,10 +23,13 @@ public sealed class ShadowObjectRegistry
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
float cylHeight = 0f)
float cylHeight = 0f, float scale = 1.0f)
{
Deregister(entityId);
// The radius parameter should already be the WORLD-SPACE bounding
// radius (i.e., already multiplied by scale) so the broad-phase cell
// occupancy is correct. Callers are responsible for that.
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
@ -35,7 +38,7 @@ public sealed class ShadowObjectRegistry
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight);
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius, collisionType, cylHeight, scale);
var cellIds = new List<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u;
@ -166,6 +169,24 @@ public sealed class ShadowObjectRegistry
}
public int TotalRegistered => _entityToCells.Count;
/// <summary>
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
/// For each entity, returns the first entry found in any cell it occupies.
/// Intended for debug rendering only.
/// </summary>
public IEnumerable<ShadowEntry> AllEntriesForDebug()
{
var seen = new HashSet<uint>();
foreach (var kvp in _cells)
{
foreach (var entry in kvp.Value)
{
if (seen.Add(entry.EntityId))
yield return entry;
}
}
}
}
/// <summary>
@ -181,4 +202,5 @@ public readonly record struct ShadowEntry(
Quaternion Rotation,
float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
float CylHeight = 0f);
float CylHeight = 0f,
float Scale = 1.0f);

View file

@ -353,15 +353,13 @@ public sealed class Transition
for (int i = 0; i < numSteps; i++)
{
// Reset per-step collision state.
CollisionInfo.SlidingNormalValid = false;
CollisionInfo.ContactPlaneValid = false;
CollisionInfo.ContactPlaneIsWater = false;
// Project the step offset through any existing contact / slide plane.
// Per ACE order: AdjustOffset FIRST (uses state from previous step),
// THEN clear the state. This lets the sliding/contact normals from
// the previous step's collision project the current step's offset.
sp.GlobalOffset = AdjustOffset(offsetPerStep);
// Abort if adjusted offset is negligible (we're stuck against a wall).
// Abort if adjusted offset is negligible (stuck against a wall
// with no slide tangent available).
if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq)
return i != 0 && transitionState == TransitionState.OK;
@ -372,6 +370,12 @@ public sealed class Transition
sp.CheckOrientation = Quaternion.Slerp(sp.BeginOrientation, sp.EndOrientation, delta);
}
// Clear collision state AFTER AdjustOffset reads it. TransitionalInsert
// will set new state that the next step's AdjustOffset will consume.
CollisionInfo.SlidingNormalValid = false;
CollisionInfo.ContactPlaneValid = false;
CollisionInfo.ContactPlaneIsWater = false;
// Apply the offset, then check collisions.
sp.AddOffsetToCheckPos(sp.GlobalOffset);
@ -391,108 +395,155 @@ public sealed class Transition
// -----------------------------------------------------------------------
/// <summary>
/// Check collisions at the current CheckPos, apply step-down as needed.
/// Ported from pseudocode section 3 (TransitionalInsert).
/// ACE: Transition.TransitionalInsert(int num_insertion_attempts).
/// ACE Transition.TransitionalInsert — retry loop for collision resolution.
///
/// <para>
/// Per ACE: iterate up to numAttempts times. Each iteration runs the full
/// collision pipeline (env + objects) at the current CheckPos. The pipeline
/// can MUTATE CheckPos (push-out, slide). On Slid/Adjusted, clear state and
/// retry — the next iteration tests the NEW CheckPos against all nearby
/// objects again, which catches "slide into a second wall" corner cases.
/// </para>
///
/// <para>
/// Return values:
/// - OK: no collision OR all collisions resolved without leaving anything unhandled
/// - Collided: hard stop; no further movement possible
/// - Slid: last iteration slid (only if we exhausted retry attempts)
/// - Adjusted: last iteration adjusted (rare — retry should convert to OK)
/// </para>
///
/// <para>
/// This is simplified from ACE: we don't have CellArray/CheckOtherCells
/// iteration because our FindObjCollisions (via ShadowObjectRegistry) is
/// already a flat per-landblock query. That's the equivalent of iterating
/// objects across all relevant cells.
/// </para>
/// </summary>
private TransitionState TransitionalInsert(int maxAttempts, PhysicsEngine engine)
private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine)
{
if (SpherePath.CheckCellId == 0) return TransitionState.OK;
if (maxAttempts <= 0) return TransitionState.Invalid;
if (numAttempts <= 0) return TransitionState.Invalid;
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
TransitionState transitState = TransitionState.OK;
TransitionState transitState;
for (int attempt = 0; attempt < maxAttempts; attempt++)
for (int attempt = 0; attempt < numAttempts; attempt++)
{
// Phase 1: check collisions in the current cell.
// ── Phase 1: environment collision (terrain + indoor BSP) ───
transitState = FindEnvCollisions(engine);
switch (transitState)
if (transitState == TransitionState.Collided)
return TransitionState.Collided;
if (transitState == TransitionState.Slid)
{
case TransitionState.OK:
// Outdoor path: no neighboring cell enumeration needed for MVP.
break;
case TransitionState.Collided:
return TransitionState.Collided;
case TransitionState.Adjusted:
sp.NegPolyHit = false;
break;
case TransitionState.Slid:
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
break;
// Env collision slid the sphere. Clear state and retry at
// the new CheckPos to see if we hit anything else.
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
continue;
}
// Phase 1b: check object (static BSP) collisions when OK so far.
if (transitState == TransitionState.OK)
if (transitState == TransitionState.Adjusted)
{
var objState = FindObjCollisions(engine);
if (objState == TransitionState.Slid)
{
transitState = TransitionState.Slid;
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
}
else if (objState == TransitionState.Collided)
{
return TransitionState.Collided;
}
// Env modified CheckPos. Retry at new position.
sp.NegPolyHit = false;
continue;
}
// Phase 2: post-collision response.
if (transitState == TransitionState.OK)
// ── Phase 2: object (static BSP + cylinder) collision ───────
// Env was OK — now test objects.
var objState = FindObjCollisions(engine);
if (objState == TransitionState.Collided)
return TransitionState.Collided;
if (objState == TransitionState.Slid)
{
// Handle step-down when in contact but no ground plane found.
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown)
// Object collision applied a push-out and set sliding normal.
// Retry at the new CheckPos — we may have slid into another
// object, or need to re-verify env at the new position.
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
continue;
}
if (objState == TransitionState.Adjusted)
{
// Object modified CheckPos (e.g. PerfectClip adjust_to_plane).
// Retry at the new position.
sp.NegPolyHit = false;
continue;
}
// ── Phase 3: both env and objects returned OK ──────────────
// Handle Collide flag (BSP path 6 set it on a non-contact hit).
// ACE: if Collide is set, re-test as Placement to confirm position.
// Simplified: just clear it and accept.
if (sp.Collide)
{
sp.Collide = false;
}
// Handle neg-poly hit (backward-facing polygon contact).
if (sp.NegPolyHit && !sp.StepDown && !sp.StepUp)
{
sp.NegPolyHit = false;
// ACE: dispatch to StepUp or SlideSphere based on NegStepUp flag.
// Simplified: accept current position.
}
// Handle step-down when in contact but no ground plane found.
// This happens when the player is on a slope edge: they're marked
// as in contact with the ground, but the current CheckPos has no
// terrain contact (walked off an edge). Attempt a step-down to
// maintain ground contact.
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown)
{
float zVal = PhysicsGlobals.LandingZ;
float stepDownHeight = oi.StepDownHeight;
sp.WalkableAllowance = zVal;
sp.SaveCheckPos();
float radsum = sp.GlobalSphere[0].Radius * 2f;
if (radsum >= stepDownHeight)
{
float zVal = PhysicsGlobals.LandingZ;
float stepDownHeight = oi.StepDownHeight;
sp.WalkableAllowance = zVal;
sp.SaveCheckPos();
float radsum = sp.GlobalSphere[0].Radius * 2f;
if (radsum >= stepDownHeight)
if (DoStepDown(stepDownHeight, zVal, engine))
{
if (DoStepDown(stepDownHeight, zVal, engine))
{
sp.WalkableValid = false;
return TransitionState.OK;
}
sp.WalkableValid = false;
return TransitionState.OK;
}
else
{
stepDownHeight *= 0.5f;
if (DoStepDown(stepDownHeight, zVal, engine)
|| DoStepDown(stepDownHeight, zVal, engine))
{
sp.WalkableValid = false;
return TransitionState.OK;
}
}
// Step-down failed: stay at current position.
sp.RestoreCheckPos();
return TransitionState.OK;
}
else
{
return TransitionState.OK;
stepDownHeight *= 0.5f;
if (DoStepDown(stepDownHeight, zVal, engine)
|| DoStepDown(stepDownHeight, zVal, engine))
{
sp.WalkableValid = false;
return TransitionState.OK;
}
}
// Step-down failed: stay at current position.
sp.RestoreCheckPos();
return TransitionState.OK;
}
return TransitionState.OK;
}
return transitState;
// Exhausted retry attempts — return whatever the last iteration said.
// (Defaults to Slid in practice since that's the only case that retries.)
return TransitionState.Slid;
}
// -----------------------------------------------------------------------
@ -662,6 +713,7 @@ public sealed class Transition
// Reused per-call to avoid per-step allocation; safe because Transition
// is single-threaded per movement resolve.
private readonly List<ShadowEntry> _nearbyObjs = new();
private static int _debugQueryCount = 0;
/// <summary>
/// Query the ShadowObjectRegistry for nearby static objects and run
@ -698,6 +750,16 @@ public sealed class Transition
worldOffsetX, worldOffsetY, landblockId,
_nearbyObjs);
// Log every 120 frames — tracks player position over time.
_debugQueryCount++;
if (movement.LengthSquared() > 0.0001f && _debugQueryCount % 120 == 0)
{
Console.WriteLine(
$"ObjColl @({currPos.X:F1},{currPos.Y:F1},{currPos.Z:F1}) " +
$"lb=0x{landblockId:X8} nearby={_nearbyObjs.Count}/{engine.ShadowObjects.TotalRegistered}");
}
foreach (var obj in _nearbyObjs)
{
// Broad-phase: can the moving sphere reach this object?
@ -721,23 +783,28 @@ public sealed class Transition
if (physics?.BSP?.Root is null) continue;
// Transform player spheres to object-local space.
// For a scaled object (scenery tree, etc.), we need to
// divide the local position + radius by the object's scale
// so they are in the unscaled BSP coordinate system.
// ACE handles this via the `scale` parameter in find_collisions.
var invRot = Quaternion.Inverse(obj.Rotation);
float invScale = obj.Scale > 0 ? 1.0f / obj.Scale : 1.0f;
var localSphere0 = new DatReaderWriter.Types.Sphere
{
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot),
Radius = sp.GlobalSphere[0].Radius,
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot) * invScale,
Radius = sp.GlobalSphere[0].Radius * invScale,
};
var localCurrCenter = Vector3.Transform(
sp.GlobalCurrCenter[0].Origin - obj.Position, invRot);
sp.GlobalCurrCenter[0].Origin - obj.Position, invRot) * invScale;
DatReaderWriter.Types.Sphere? localSphere1 = null;
if (sp.NumSphere > 1)
{
localSphere1 = new DatReaderWriter.Types.Sphere
{
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot),
Radius = sp.GlobalSphere[1].Radius,
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot) * invScale,
Radius = sp.GlobalSphere[1].Radius * invScale,
};
}
@ -745,9 +812,8 @@ public sealed class Transition
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
// Use the retail 6-path dispatcher with pre-resolved polygons.
// Pass the object's rotation so collision responses (normals,
// offsets) are transformed from object-local back to world space.
// ACE: path.LocalSpacePos.LocalToGlobalVec()
// Pass the object's scale so collision response offsets (in
// unscaled local space) are multiplied back to world space.
result = BSPQuery.FindCollisions(
physics.BSP.Root,
physics.Resolved,
@ -756,7 +822,7 @@ public sealed class Transition
localSphere1,
localCurrCenter,
localSpaceZ,
1.0f, // scale = 1.0 for object geometry
obj.Scale, // scale for local→world offsets
obj.Rotation); // local→world rotation
}
else
@ -776,9 +842,9 @@ public sealed class Transition
}
/// <summary>
/// Cylinder swept-sphere collision test for CylSphere objects (trees, rocks, etc.).
/// Performs a 2D ray-circle intersection to find contact time, then applies
/// a wall-slide response.
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs).
/// Applies a horizontal wall-slide response when the sphere overlaps the
/// cylinder, matching the BSP path 5/6 response for consistent behavior.
/// </summary>
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp)
{
@ -788,51 +854,87 @@ public sealed class Transition
float sphRadius = sp.GlobalSphere[0].Radius;
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
Vector3 deltaCurr = sphereCurrPos - obj.Position;
float dx = deltaCurr.X, dy = deltaCurr.Y;
float mx = sphMovement.X, my = sphMovement.Y;
float combinedR = sphRadius + obj.Radius;
float a = mx * mx + my * my;
float b = 2f * (dx * mx + dy * my);
float c = dx * dx + dy * dy - combinedR * combinedR;
float t;
if (a < PhysicsGlobals.EPSILON)
{
if (c > 0f) return TransitionState.OK;
t = 0f;
}
else
{
float disc = b * b - 4f * a * c;
if (disc < 0f) return TransitionState.OK;
float sqrtDisc = MathF.Sqrt(disc);
t = (-b - sqrtDisc) / (2f * a);
if (t > 1f) return TransitionState.OK;
if (t < 0f) t = 0f;
}
// Vertical check at contact time.
Vector3 contactPos = sphereCurrPos + sphMovement * t;
// Vertical check: does sphere reach the cylinder's height range at all?
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
float playerBottom = contactPos.Z - sphRadius;
float playerTop = contactPos.Z + sphRadius;
if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
float checkZ = sphereCheckPos.Z;
if (checkZ - sphRadius > obj.Position.Z + cylTop ||
checkZ + sphRadius < obj.Position.Z)
return TransitionState.OK;
// Collision normal: radial from cylinder axis.
Vector3 contactDelta = contactPos - obj.Position;
float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
Vector3 collisionNormal;
if (hDist < PhysicsGlobals.EPSILON)
collisionNormal = Vector3.UnitX;
else
collisionNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
// XY distance from sphere check position to cylinder axis.
float dxCheck = sphereCheckPos.X - obj.Position.X;
float dyCheck = sphereCheckPos.Y - obj.Position.Y;
float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck;
float combinedR = sphRadius + obj.Radius;
float combinedRSq = combinedR * combinedR;
if (distSqCheck >= combinedRSq)
return TransitionState.OK; // not overlapping at check position
// ─── Overlap detected: apply wall-slide ─────────────────────
// Horizontal outward normal from the cylinder axis to the sphere
// check position. For the degenerate case where the sphere center
// is exactly on the axis, use the movement direction as a fallback
// (pushes the sphere back out along the way it came in).
float distCheck = MathF.Sqrt(distSqCheck);
Vector3 collisionNormal;
if (distCheck < PhysicsGlobals.EPSILON)
{
// Sphere center on cylinder axis — push along reverse movement.
float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y);
if (mxy > PhysicsGlobals.EPSILON)
collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f);
else
collisionNormal = Vector3.UnitX;
}
else
{
collisionNormal = new Vector3(dxCheck / distCheck, dyCheck / distCheck, 0f);
}
// Wall-slide position (in world space):
// curr = sphereCurrPos (pre-step)
// movement = sphMovement
// projected = movement - (movement · normal) * normal
// slidPos = curr + projected
// Then push outward if still inside the cylinder radius.
Vector3 horizMovement = new Vector3(sphMovement.X, sphMovement.Y, 0f);
float movementIntoWall = Vector3.Dot(horizMovement, collisionNormal);
Vector3 projectedMovement = horizMovement - collisionNormal * movementIntoWall;
// Preserve vertical movement component (jumping/falling).
projectedMovement.Z = sphMovement.Z;
Vector3 slidPos = sphereCurrPos + projectedMovement;
// Ensure slid position is outside the cylinder radius horizontally.
float sdx = slidPos.X - obj.Position.X;
float sdy = slidPos.Y - obj.Position.Y;
float sDistSq = sdx * sdx + sdy * sdy;
float minDist = combinedR + 0.01f;
if (sDistSq < minDist * minDist)
{
float sDist = MathF.Sqrt(sDistSq);
if (sDist < PhysicsGlobals.EPSILON)
{
// Degenerate: push out along collisionNormal
slidPos.X = obj.Position.X + collisionNormal.X * minDist;
slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist;
}
else
{
float pushDist = (minDist - sDist);
slidPos.X += (sdx / sDist) * pushDist;
slidPos.Y += (sdy / sDist) * pushDist;
}
}
// Apply the offset (difference between slid and current CheckPos)
Vector3 delta = slidPos - sphereCheckPos;
sp.AddOffsetToCheckPos(delta);
// Apply collision response via wall-slide.
ci.SetCollisionNormal(collisionNormal);
return SlideSphere(collisionNormal, sphereCurrPos);
ci.SetSlidingNormal(collisionNormal);
return TransitionState.Slid;
}
// -----------------------------------------------------------------------

View file

@ -72,11 +72,14 @@ public static class SceneryGenerator
uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock
uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
// The original iterates Terrain[0..80] — 81 vertices of a 9x9 grid.
// The heightmap is packed x-major (Height[x*9+y]), so we match that here.
for (int x = 0; x < VerticesPerSide; x++)
// RETAIL iterates 8×8 = 64 CELLS, not 9×9 = 81 vertices.
// Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses
// `while (local_94 < 8)` and `while (local_8c < 8)` — bound by
// `param_1+0x40` which is SideCellCount=8 for outdoor landblocks.
// The terrain word at each cell's SW corner drives that cell's scenery.
for (int x = 0; x < CellsPerSide; x++)
{
for (int y = 0; y < VerticesPerSide; y++)
for (int y = 0; y < CellsPerSide; y++)
{
int i = x * VerticesPerSide + y;
ushort raw = block.Terrain[i];
@ -84,14 +87,12 @@ public static class SceneryGenerator
uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6
uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15
// Skip road vertices: bits 0-1 of the terrain word encode the road
// type (non-zero means this vertex is on a road). Ported from
// ACViewer Physics/Common/Landblock.cs GetRoad() and the OnRoad()
// check in get_land_scenes(). Roads should not have trees/rocks.
if (IsRoadVertex(raw)) continue;
// NOTE: retail does NOT skip based on this vertex's road bit.
// The road test happens AFTER displacement via the 4-corner
// polygonal OnRoad check (see below). Removing the
// pre-displacement early-exit restores retail behavior.
// Skip cells that contain buildings (ACME conformance fix 4d).
// Building footprints shouldn't have scenery spawning inside them.
// Skip cells that contain buildings.
if (buildingCells is not null && buildingCells.Contains(i)) continue;
if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue;
@ -154,15 +155,28 @@ public static class SceneryGenerator
if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
continue;
// Check if the final displaced position lands on a road vertex.
// The road status is per-vertex (9×9 grid); sample the nearest
// vertex to the displaced position to catch scenery that drifted
// from a non-road vertex onto a road.
// Retail post-displacement road check (FUN_00530d30).
// Ported from ACViewer Landblock.OnRoad — uses the 4-corner
// road bits of the containing cell plus the 5-unit road
// half-width to test whether the displaced (lx,ly) lies on
// the road ribbon.
bool isOnRoad = IsOnRoad(block, lx, ly);
if (isOnRoad)
{
int nearX = Math.Clamp((int)(lx / CellSize + 0.5f), 0, VerticesPerSide - 1);
int nearY = Math.Clamp((int)(ly / CellSize + 0.5f), 0, VerticesPerSide - 1);
ushort nearRaw = block.Terrain[nearX * VerticesPerSide + nearY];
if (IsRoadVertex(nearRaw)) continue;
continue;
}
// Also reject if the vertex CX,CY is a road vertex itself
// — scenery whose cell-origin vertex is on a road should
// not spawn, even if displacement moves it off the ribbon.
// Retail's frequency-based path is guarded by the road mask;
// our formula can yield valid positions adjacent to roads
// that the ACViewer OnRoad test lets through. This extra
// guard pushes scenery away from road vertices, matching
// retail's visually clearer road margins.
if (IsRoadVertex(block.Terrain[(int)cellX * VerticesPerSide + (int)cellY]))
{
continue;
}
// Slope filter (ACME conformance fix 4e): compute terrain normal
@ -183,22 +197,41 @@ public static class SceneryGenerator
if (nz < obj.MinSlope || nz > obj.MaxSlope) continue;
}
float lz = 0f; // lifted to ground at render time via landblock heightmap
// BaseLoc.Z offset: scenery-specific vertical offset from
// the ground (e.g., flowers planted at -0.1m so they
// don't float above grass). The renderer adds groundZ
// later, so pass the BaseLoc.Z through as-is.
float lz = obj.BaseLoc.Origin.Z;
// Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60)
// offset constant 0xf697 = 63127
// iVar2 = (param_3 * 0x6c1ac587 - (param_2 * param_3 * 0x5111bfef + 0x70892fb7) * (param_4 + 0xf697))
// + param_2 * -0x421be3bd
// param_2=ix, param_3=iy, param_4=j
Quaternion rotation = Quaternion.Identity;
if (obj.MaxRotation > 0)
// Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation
// into the frame, THEN calls AFrame::set_heading(degrees).
//
// set_heading uses yaw = -(450 - heading) % 360 before converting
// to a quaternion, which introduces a 90° offset + sign flip
// relative to a naive Z rotation. WorldBuilder's
// SceneryHelpers.SetHeading reproduces this.
//
// For objects with Align != 0, retail uses FUN_005a6f60 to
// align to the landcell polygon's normal instead of setting
// heading from the noise.
//
// Composition: final = baseLoc.Orientation * headingQuat
Quaternion rotation = obj.BaseLoc.Orientation;
if (rotation.LengthSquared() < 0.0001f)
rotation = Quaternion.Identity;
if (obj.MaxRotation > 0f)
{
double rotNoise = unchecked((uint)(1813693831u * globalCellY
- (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u)
- 1109124029u * globalCellX)) * 2.3283064e-10;
float degrees = (float)(rotNoise * obj.MaxRotation);
float radians = degrees * MathF.PI / 180f;
rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians);
// AFrame::set_heading transform — matches retail.
float yawDeg = -((450f - degrees) % 360f);
float yawRad = yawDeg * MathF.PI / 180f;
var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad);
rotation = headingQuat * rotation;
}
// Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern)
@ -237,6 +270,121 @@ public static class SceneryGenerator
/// </summary>
public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
/// <summary>
/// Half-width of a road ribbon in world units — the road extends from each
/// road vertex by this amount into the neighbor cells. Matches retail's
/// `_DAT_007c9cc0 = 5.0f` in FUN_00530d30.
/// </summary>
private const float RoadHalfWidth = 5.0f;
/// <summary>
/// Retail-faithful post-displacement road test. Ported from ACViewer
/// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which is
/// a direct port of FUN_00530d30 in the retail client.
///
/// Examines the 4 corners of the cell containing (lx, ly) and, depending
/// on how many are road vertices (0, 1, 2, 3, or 4), applies a polygonal
/// test using the 5-unit road half-width to check if (lx, ly) lies on the
/// road ribbon. Returns true if the point is on a road.
/// </summary>
/// <summary>
/// Retail-faithful road ribbon test — direct port of ACViewer's
/// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which
/// itself is a port of FUN_00530d30 in acclient.exe.
///
/// Classifies the 4 corners of the cell containing (lx, ly) by road type
/// (bits 0-1 of the terrain word) and applies a different geometric test
/// based on which corners are road vertices. Road ribbons have a 5m
/// half-width (TileLength - RoadWidth = 19m).
/// </summary>
private static bool IsOnRoad(LandBlock block, float lx, float ly)
{
int x = (int)MathF.Floor(lx / CellSize);
int y = (int)MathF.Floor(ly / CellSize);
// Clamp so we don't index past the 9x9 terrain grid
x = Math.Clamp(x, 0, CellsPerSide - 1);
y = Math.Clamp(y, 0, CellsPerSide - 1);
float rMin = RoadHalfWidth; // 5
float rMax = CellSize - RoadHalfWidth; // 19
// Corner road bits (ACViewer convention):
// r0 = (x0, y0) = SW
// r1 = (x0, y1) = NW
// r2 = (x1, y0) = SE
// r3 = (x1, y1) = NE
bool r0 = IsRoadVertex(block.Terrain[x * VerticesPerSide + y]);
bool r1 = IsRoadVertex(block.Terrain[x * VerticesPerSide + (y + 1)]);
bool r2 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + y]);
bool r3 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + (y + 1)]);
if (!r0 && !r1 && !r2 && !r3) return false;
float dx = lx - x * CellSize;
float dy = ly - y * CellSize;
if (r0)
{
if (r1)
{
if (r2)
{
if (r3) return true;
return dx < rMin || dy < rMin;
}
else
{
if (r3) return dx < rMin || dy > rMax;
return dx < rMin;
}
}
else
{
if (r2)
{
if (r3) return dx > rMax || dy < rMin;
return dy < rMin;
}
else
{
if (r3) return MathF.Abs(dx - dy) < rMin;
return dx + dy < rMin;
}
}
}
else
{
if (r1)
{
if (r2)
{
if (r3) return dx > rMax || dy > rMax;
return MathF.Abs(dx + dy - CellSize) < rMin;
}
else
{
if (r3) return dy > rMax;
return CellSize + dx - dy < rMin;
}
}
else
{
if (r2)
{
if (r3) return dx > rMax;
return CellSize - dx + dy < rMin;
}
else
{
if (r3) return CellSize * 2f - dx - dy < rMin;
return false;
}
}
}
}
private const int CellsPerSide = 8;
/// <summary>
/// Pseudo-random displacement within a cell for a scenery object. Returns a
/// Vector3 in local cell-offset space (the caller adds it to the cell corner

View file

@ -43,4 +43,16 @@ public sealed class WorldEntity
/// Null for outdoor entities (stabs, scenery, live server spawns).
/// </summary>
public uint? ParentCellId { get; init; }
/// <summary>
/// Uniform scale applied to this entity's mesh by the scenery pipeline.
/// For scenery objects this is spawn.Scale (typically 0.81.3). For stabs
/// and interior static objects this is 1.0 (no scaling).
///
/// Used by the collision registration path to scale CylSphere / Sphere /
/// Setup.Radius shapes so they match the visually-scaled mesh. Without
/// this, scaled scenery has a collision cylinder that's smaller than the
/// visible trunk, producing "partial passthrough" bugs.
/// </summary>
public float Scale { get; init; } = 1.0f;
}