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

@ -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