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

@ -0,0 +1,169 @@
# Session 2026-04-16 — Scenery Alignment + Collision Registration
## Headline result
**Scenery alignment + collision finally in a working state.**
- All outdoor entities get collision cylinders
- Trees in roads mostly fixed
- Trees snapped to terrain (triangle-aware Z sample matching player physics)
- Visual-to-collision position alignment correct
## Critical root causes found this session
### 1. Scenery entity ID collision across landblocks
**File:** `src/AcDream.App/Rendering/GameWindow.cs` `BuildSceneryEntitiesForStreaming`
Old: `sceneryIdBase = 0x80000000 | (landblockId & 0x00FFFF00)` — the mask only
captured bits 8-23 which always has `0xFF` in the low byte (from the lb id
suffix). Result: every landblock with the same Y coord produced scenery with
**identical IDs**. When streaming loaded the next landblock, `ShadowObjects
.Register` called `Deregister(entityId)` first, wiping out the previous
landblock's collision. Only the LAST-loaded landblock had collision for any
given ID.
Fix: `0x80000000 | (lbXByte << 16) | (lbYByte << 8)` — each landblock gets a
unique ID namespace. Every scenery entity now uniquely identified.
### 2. Scenery generator using wrong terrain-Z formula
**File:** `src/AcDream.App/Rendering/GameWindow.cs` `SampleTerrainZ`
My earlier "triangle-aware" terrain sample had wrong barycentric math.
Physics's `TerrainSurface.SampleZ` used different (correct) math derived
from the WorldBuilder mesh index buffer. So player walked at one Z,
scenery placed at another → trees hovered above ground.
Fix: Ported WorldBuilder `TerrainUtils.GetHeight` exactly — same split
direction formula, same barycentric triangle pair, same interpolation.
Both scenery and player now use identical height sampling.
### 3. Collision cylinder position using AABB center, not entity root
Multi-part scenery meshes can have parts offset from the mesh origin. The
visual mesh AABB center is NOT the entity's rendered position. Using
AABB center for the collision cylinder put collision meters away from the
visible mesh.
Fix: Collision cylinder center = `entity.Position` (the rendered origin).
Radius from the retail Setup CylSphere when available, clamped to
[0.3, 1.5] m.
### 4. Scenery iterated 81 vertices, retail iterates 64 cells
**File:** `src/AcDream.Core/World/SceneryGenerator.cs`
Decompiled FUN_005311a0 iterates `local_8c < 8 && local_94 < 8` — 64 cells.
We iterated 0..8 on both axes (81 vertices). Extra scenery at landblock
seams.
Fix: `for (int x = 0; x < CellsPerSide; x++)` where `CellsPerSide = 8`.
Note: WorldBuilder iterates 81, ACViewer iterates 81. Retail iterates 64.
Currently matching retail decompiled — may need revisiting if we see seam
gaps.
### 5. Road check was per-vertex nearest-neighbor, should be 4-corner polygonal
**File:** `src/AcDream.Core/World/SceneryGenerator.cs` `IsOnRoad`
ACE-server's simplified road test uses single-vertex `(terrain & 0x3)` check.
That's wrong — retail uses a 4-corner polygonal test with 5m road ribbon
(`FUN_00530d30`).
Fix: Direct port of ACViewer's `Landblock.OnRoad` (lines 300-398) with
16-case corner-configuration dispatch and 5m `RoadHalfWidth`. Also kept an
extra "origin-cell has road vertex" guard since retail's exact threshold
constants (`_DAT_007c97cc/...`) weren't in dumped chunks.
### 6. Pre-displacement road check was wrong
Retail doesn't skip a vertex based on its own road bit — it rolls displacement
then tests the final position against OnRoad. My code had `if (IsRoadVertex(raw))
continue;` which silently dropped scenery retail would have kept.
Fix: Removed. The post-displacement OnRoad test is the only road check.
### 7. Rotation missing baseLoc + (450-heading)%360 flip
**File:** `src/AcDream.Core/World/SceneryGenerator.cs`
Retail's `AFrame::set_heading(degrees)` transforms `yaw = -(450 - heading) % 360`
before building the quaternion. It also composes with `BaseLoc.Orientation`.
Fix: Applied both. Rotations now match retail for asymmetric scenery.
### 8. Stabs in buildingCells
**File:** `src/AcDream.App/Rendering/GameWindow.cs`
Was treating `lbInfo.Objects` (stabs) as buildings, over-suppressing scenery
in town landblocks. Retail only suppresses scenery in cells with actual
`lbInfo.Buildings`.
Fix: Only `Buildings` contribute to `buildingCells`.
## Still imperfect (for tomorrow)
- Some scenery still hovers slightly above ground in certain cells.
Probably a minor split-direction edge case or BaseLoc.Z handling quirk.
- User wants a real debug overlay (on-screen text/markers with positions,
directions, distances, etc.). Currently has F3 console dump + cyan debug
wireframes from the DebugLineRenderer.
- Collision is now consistent but "feel" needs tuning against retail.
- Large trees with BSP on canopy still rely on our CylSphere fallback
rather than a retail-faithful dual-test (BSP + CylSphere).
## Pickup for next session
User explicitly said next session:
1. **Debug overlay UI** — on-screen text for player pos, direction, heading
in degrees, nearest object distances, "am I colliding" indicator. The
current approach (F3 console dump, wireframes in 3D world) is insufficient
because the user doesn't know "what 40m looks like" visually.
2. **Controls** — make working with the client easier. Presumably: keybind
list, camera controls, debug toggles, maybe a tool palette.
## Files changed this session
- `src/AcDream.App/Rendering/GameWindow.cs` — scenery ID namespace, OnRoad,
visual-mesh-AABB collision, triangle-aware SampleTerrainZ, debug wireframes,
F3 dump, building/stab separation, DebugLineRenderer wiring.
- `src/AcDream.App/Rendering/DebugLineRenderer.cs` (new) — minimal GL line
renderer for wireframe debug.
- `src/AcDream.App/Rendering/Shaders/debug_line.{vert,frag}` (new) — debug
line shader pair.
- `src/AcDream.Core/World/SceneryGenerator.cs` — 64-cell iteration, ACViewer
OnRoad, baseLoc+heading rotation, BaseLoc.Z passthrough, removed
pre-displacement road check.
- `src/AcDream.Core/World/WorldEntity.cs` — added `Scale` field for scenery.
- `src/AcDream.Core/Physics/PhysicsDataCache.cs``GfxObjVisualBounds` +
`GetVisualBounds` for mesh AABB fallback.
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs``AllEntriesForDebug()`
for the debug overlay, `Scale` on ShadowEntry.
- `src/AcDream.Core/Physics/BSPQuery.cs``localToWorld` rotation parameter
on all dispatcher methods so collision normals/offsets transform correctly.
- `src/AcDream.Core/Physics/TransitionTypes.cs``CylinderCollision` rewritten
with wall-slide + push-out, `FindObjCollisions` rewritten per-object with
retail 6-path dispatcher, TransitionalInsert retry loop.
## Key reference pointers (for tomorrow)
- WorldBuilder scenery: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/
Lib/SceneryRenderManager.cs` + `SceneryHelpers.cs`.
- WorldBuilder terrain: `references/WorldBuilder/WorldBuilder.Shared/
Modules/Landscape/Lib/TerrainUtils.cs` (GetHeight, GetNormal, OnRoad).
- Retail decompiled collision: `docs/research/decompiled/chunk_00530000.c`
FUN_005311a0 (scenery loop) and FUN_00530d30 (OnRoad).
- Setup field layout: `docs/research/decompiled/chunk_00510000.c` getter
thunks ~line 7563-7662 (CylSpheres at +0x48, Radius +0x64, etc.).
## Collision pipeline overview (current state)
1. **Scenery generation** (`SceneryGenerator.Generate`) — 64 cells per lb,
LCG-deterministic displacement, OnRoad + building cell filter, slope
filter, returns `ScenerySpawn{ObjectId, LocalPosition, Rotation, Scale}`.
2. **Scenery hydration** (`BuildSceneryEntitiesForStreaming`) — samples
terrain Z via `_physicsEngine.SampleTerrainZ`, builds `WorldEntity`.
3. **Collision registration** (end of `ApplyLoadedTerrain`) — for every
outdoor mesh entity: pick radius from Setup.CylSphere → Setup.Radius →
mesh AABB fallback. Height from Setup.Height or mesh. Register cylinder
at `entity.Position` in `ShadowObjectRegistry`.
4. **Collision query** (`Transition.FindObjCollisions`) — player sphere
sweeps via `GetNearbyObjects` which searches player's lb + 8 neighbors.
Per-object dispatch: BSP → `BSPQuery.FindCollisions` (retail 6-path),
Cylinder → `CylinderCollision` (wall-slide + push-out).
Good night.

View file

@ -0,0 +1,113 @@
# Session 2026-04-17 — Debug Overlay + Control Tuning
## Headline result
**On-screen HUD overlay and refined input controls for the dev client.**
- TTF-based font atlas rendered via stb_truetype
- Screen-space text + rect batcher (`TextRenderer`)
- Composite overlay with panels for position / heading / collision /
FPS / compass / keybind help
- Per-mode mouse sensitivity (Chase, Fly, Orbit)
- RMB-held free-orbit around the player (no snap-back on release)
- Mouse-wheel zoom in chase mode
- Extended chase pitch range so mouse-Y moves both ways
## Files added
- `src/AcDream.App/Rendering/BitmapFont.cs` — TTF atlas (stb_truetype)
- `src/AcDream.App/Rendering/TextRenderer.cs` — 2D quad batcher for text + rects
- `src/AcDream.App/Rendering/DebugOverlay.cs` — composed HUD panels
- `src/AcDream.App/Rendering/Shaders/ui_text.{vert,frag}` — ortho-proj text shader
- `src/AcDream.App/Rendering/Shaders/debug_line.{vert,frag}` — wireframe shader
(carried from yesterday's scenery-alignment session but not committed then)
## Files modified
- `src/AcDream.App/AcDream.App.csproj` — added `StbTrueTypeSharp` 1.26.12
- `src/AcDream.App/Rendering/Shader.cs` — added `SetVec2` / `SetVec4`
- `src/AcDream.App/Rendering/ChaseCamera.cs`
- Added `YawOffset` for RMB free-orbit
- Added `AdjustDistance` for mouse-wheel zoom
- Widened pitch clamp from `[0.05, 1.4]` to `[-0.7, 1.4]`
(mouse-Y now moves camera in both directions from neutral)
- `DistanceMin=2f`, `DistanceMax=40f` zoom envelope
- `src/AcDream.App/Rendering/GameWindow.cs`
- Field block: `_textRenderer`, `_debugFont`, `_debugOverlay`,
`_sensChase/_sensFly/_sensOrbit`, `_rmbHeld`, `_lastFps`, `_lastFrameMs`
- OnLoad: load Consolas → BitmapFont → TextRenderer → DebugOverlay
(silently skips if no system font)
- Keyboard: F1/F2/F4/F5/F6 panel toggles; F8/F9 sensitivity
(multiplicative ×1.2 steps, per-mode)
- Mouse: MouseDown / MouseUp track RMB; MouseMove routes to the
active mode's sensitivity; RMB release does NOT snap YawOffset
- OnRender: snapshot builder (player pos, heading, nearest-obj dist,
colliding flag) passed to `DebugOverlay.Draw`
## Yesterday's scenery work (finally committed in this session's commit)
The untracked files show that yesterday's scenery-alignment fixes lived
on disk but hadn't been committed. This session's commit includes:
- `src/AcDream.Core/Physics/BSPQuery.cs``localToWorld` rotation params
- `src/AcDream.Core/Physics/PhysicsDataCache.cs``GfxObjVisualBounds`
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs``Scale` + debug iter
- `src/AcDream.Core/Physics/TransitionTypes.cs` — rewritten cylinder / BSP
- `src/AcDream.Core/World/SceneryGenerator.cs` — 64-cell, ACViewer OnRoad,
baseLoc + set_heading rotation
- `src/AcDream.Core/World/WorldEntity.cs``Scale` field
## Sensitivity defaults (current)
| Mode | Default | F8 step | F9 step |
|-------|---------|-------------|-----------|
| Chase | 0.15x | ÷ 1.2 | × 1.2 |
| Fly | 1.0x | ÷ 1.2 | × 1.2 |
| Orbit | 1.0x | ÷ 1.2 | × 1.2 |
Effective rate at chase 0.15x: `0.15 × 0.003 rad/px = 0.00045 rad/px`
≈ 0.026°/pixel. 1000 pixels → 26° rotation.
Fly at 1.0x is `0.003 rad/px` ≈ 0.172°/px.
## Keybinds (full, current)
| Key | Action |
|-----------|--------|
| F1 | Toggle keybind help panel |
| F2 | Toggle collision wireframes |
| F3 | Console dump (pos + nearby objects) |
| F4 | Toggle HUD info panel |
| F5 | Toggle HUD stats panel |
| F6 | Toggle compass |
| F8 / F9 | Active-mode mouse sensitivity slower / faster |
| F | Toggle fly camera |
| Tab | Toggle player mode (live session only) |
| WASD | Move (player mode) / fly |
| Space | Jump (hold to charge, release to fire) |
| Shift | Run |
| Mouse | Turn character / look |
| Hold RMB | Free-orbit camera around player (stays on release) |
| Wheel | Zoom chase distance |
| Escape | Exit fly / player / close window |
## Open issue (parked for follow-up)
User reports mouse "feels like you can only move one way" at low
sensitivity. Diagnosed + fixed: chase `PitchMin` was clamping at
`0.05f`, preventing any upward tilt. Widened to `-0.7f`. Needs
visual verification next session.
## Pickup for next session
**MAJOR TASK PARKED HERE**: user has asked for a deep investigation
+ port of the retail AC client's GUI subsystem. User explicitly
directed **Opus 4.7 with extra-high effort** for this work. The
agents are dispatched in this session and their output lives in
`docs/research/2026-04-17-retail-ui-*.md`. See that set of files
for the in-depth UI research + C# scaffold.
After the retail-UI port is in place:
1. Hook the retail chat window to the existing WorldSession message
stream
2. Port the health/stamina/mana globes to real player stats (need
`CharacterCreate`/`InqStats` wire parsing first)
3. Port the inventory panel (needs CreateObject item parsing)

View file

@ -15,6 +15,7 @@
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
<PackageReference Include="Serilog" Version="4.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="StbTrueTypeSharp" Version="1.26.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />

View file

@ -0,0 +1,180 @@
using System;
using System.IO;
using Silk.NET.OpenGL;
using StbTrueTypeSharp;
namespace AcDream.App.Rendering;
/// <summary>
/// A pixel-font atlas rasterized from a TTF at load time using stb_truetype.
/// Glyphs are packed into a single-channel (R8) GL texture. Call
/// <see cref="TryGetGlyph"/> to resolve an ASCII codepoint to UV + metrics.
///
/// Only printable ASCII (32..127) is supported for the debug overlay.
/// </summary>
public sealed unsafe class BitmapFont : IDisposable
{
public readonly struct Glyph
{
public readonly float UvMinX;
public readonly float UvMinY;
public readonly float UvMaxX;
public readonly float UvMaxY;
public readonly float OffsetX; // from cursor to glyph quad top-left
public readonly float OffsetY;
public readonly float Width; // pixels
public readonly float Height;
public readonly float Advance;
public Glyph(float umn, float vmn, float umx, float vmx,
float ox, float oy, float w, float h, float adv)
{
UvMinX = umn; UvMinY = vmn; UvMaxX = umx; UvMaxY = vmx;
OffsetX = ox; OffsetY = oy; Width = w; Height = h; Advance = adv;
}
}
private readonly GL _gl;
private readonly Glyph[] _glyphs;
private readonly int _firstChar;
private readonly int _numChars;
public uint TextureId { get; }
public float PixelHeight { get; }
public float LineHeight { get; }
public float Ascent { get; }
public int AtlasWidth { get; }
public int AtlasHeight { get; }
public BitmapFont(GL gl, byte[] ttfBytes, float pixelHeight,
int atlasSize = 512, int firstChar = 32, int numChars = 96)
{
_gl = gl;
PixelHeight = pixelHeight;
AtlasWidth = atlasSize;
AtlasHeight = atlasSize;
_firstChar = firstChar;
_numChars = numChars;
// Bake the glyph bitmap via stbtt_BakeFontBitmap.
var bakedChars = new StbTrueType.stbtt_bakedchar[numChars];
var pixels = new byte[AtlasWidth * AtlasHeight];
bool ok = StbTrueType.stbtt_BakeFontBitmap(
ttfBytes, 0, pixelHeight,
pixels, AtlasWidth, AtlasHeight,
firstChar, numChars, bakedChars);
if (!ok)
throw new InvalidOperationException(
$"stbtt_BakeFontBitmap failed: atlas {atlasSize}x{atlasSize} " +
$"too small for pixelHeight={pixelHeight}");
// Extract vertical metrics for line spacing.
using var info = StbTrueType.CreateFont(ttfBytes, 0)
?? throw new InvalidOperationException("stbtt_InitFont failed");
float scale = StbTrueType.stbtt_ScaleForPixelHeight(info, pixelHeight);
int ascent, descent, lineGap;
StbTrueType.stbtt_GetFontVMetrics(info, &ascent, &descent, &lineGap);
Ascent = ascent * scale;
LineHeight = (ascent - descent + lineGap) * scale;
// Convert baked-char records to our Glyph struct.
_glyphs = new Glyph[numChars];
for (int i = 0; i < numChars; i++)
{
var bc = bakedChars[i];
float w = bc.x1 - bc.x0;
float h = bc.y1 - bc.y0;
_glyphs[i] = new Glyph(
umn: bc.x0 / (float)AtlasWidth,
vmn: bc.y0 / (float)AtlasHeight,
umx: bc.x1 / (float)AtlasWidth,
vmx: bc.y1 / (float)AtlasHeight,
ox: bc.xoff,
oy: bc.yoff,
w: w, h: h,
adv: bc.xadvance);
}
// Upload atlas as a single-channel GL texture (R8).
TextureId = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, TextureId);
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
fixed (byte* ptr = pixels)
{
_gl.TexImage2D(TextureTarget.Texture2D, 0,
(int)InternalFormat.R8,
(uint)AtlasWidth, (uint)AtlasHeight, 0,
PixelFormat.Red, PixelType.UnsignedByte, ptr);
}
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
(int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int)TextureWrapMode.ClampToEdge);
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, 4); // restore default
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
public bool TryGetGlyph(char c, out Glyph g)
{
int idx = c - _firstChar;
if ((uint)idx >= (uint)_numChars)
{
g = default;
return false;
}
g = _glyphs[idx];
return true;
}
/// <summary>Measure the pixel width of a single-line string in this font.</summary>
public float MeasureWidth(string s)
{
float w = 0;
for (int i = 0; i < s.Length; i++)
{
if (TryGetGlyph(s[i], out var g))
w += g.Advance;
}
return w;
}
public void Dispose()
{
_gl.DeleteTexture(TextureId);
}
/// <summary>
/// Try to load a monospaced system font from well-known paths on the host OS.
/// Returns null if no candidate was found.
/// </summary>
public static byte[]? TryLoadSystemMonospaceFont()
{
string[] candidates =
{
@"C:\Windows\Fonts\consola.ttf",
@"C:\Windows\Fonts\cour.ttf",
@"C:\Windows\Fonts\arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
"/Library/Fonts/Menlo.ttc",
"/System/Library/Fonts/Menlo.ttc",
};
foreach (var path in candidates)
{
try
{
if (File.Exists(path))
return File.ReadAllBytes(path);
}
catch
{
// try next candidate
}
}
return null;
}
}

View file

@ -14,17 +14,34 @@ public sealed class ChaseCamera : ICamera
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
/// <summary>Distance behind the player.</summary>
/// <summary>Distance behind the player. Clamped to [<see cref="DistanceMin"/>, <see cref="DistanceMax"/>].</summary>
public float Distance { get; set; } = 8f;
public const float DistanceMin = 2f;
public const float DistanceMax = 40f;
/// <summary>Camera pitch above horizontal (radians). Positive = look down.</summary>
public float Pitch { get; set; } = 0.35f; // ~20 degrees
/// <summary>
/// Additional yaw applied on top of the player's heading when positioning
/// the camera. Used by the hold-RMB "inspect" mode to orbit around the
/// player without rotating the character. Snap to 0 to return the camera
/// to directly behind the player.
/// </summary>
public float YawOffset { get; set; } = 0f;
/// <summary>Vertical offset from the player's feet to the look-at point (eye height).</summary>
public float EyeHeight { get; set; } = 1.5f;
private const float PitchMin = 0.05f;
private const float PitchMax = 1.4f; // ~80 degrees
// Pitch range: negative values place the camera below the player's Z
// (at distance * sin(Pitch)) so the player can be viewed from a low
// angle. Clamped to -0.7 to avoid pushing the camera deep underground;
// at -0.7 and Distance=8 the camera is ~5m below player-Z which will
// clip terrain on hills but is OK on flat ground. 1.4 ≈ looking
// straight down. Wider than the old [0.05, 1.4] so mouse-Y moves the
// camera in both directions from the neutral [~20°] default.
private const float PitchMin = -0.7f;
private const float PitchMax = 1.4f;
private float _playerYaw;
private Vector3 _lookAt;
@ -43,9 +60,11 @@ public sealed class ChaseCamera : ICamera
_playerYaw = playerYaw;
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
// Camera offset: behind the player (-forward direction) and above.
float forwardX = MathF.Cos(playerYaw);
float forwardY = MathF.Sin(playerYaw);
// Camera offset: behind the player (-forward direction) plus any
// YawOffset for the hold-RMB inspect orbit mode.
float effectiveYaw = playerYaw + YawOffset;
float forwardX = MathF.Cos(effectiveYaw);
float forwardY = MathF.Sin(effectiveYaw);
float horizontalDist = Distance * MathF.Cos(Pitch);
float verticalDist = Distance * MathF.Sin(Pitch);
@ -63,4 +82,12 @@ public sealed class ChaseCamera : ICamera
{
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
/// <summary>
/// Adjust distance (zoom) by a delta, clamped to [DistanceMin, DistanceMax].
/// </summary>
public void AdjustDistance(float delta)
{
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
}
}

View file

@ -0,0 +1,172 @@
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Minimal GL debug line renderer for visualizing collision shapes,
/// bounding boxes, and other debug geometry. Collect lines each frame
/// via <see cref="AddLine"/> / <see cref="AddCylinder"/>, then call
/// <see cref="Flush"/> to upload + draw them.
///
/// Uses a single shared VBO that's respecialized each frame. Vertex
/// format is (vec3 pos, vec3 color) = 24 bytes per vertex.
/// </summary>
public sealed unsafe class DebugLineRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private readonly List<float> _buffer = new(4096);
private int _vertexCount;
private int _capacityBytes;
public DebugLineRenderer(GL gl, string shaderDir)
{
_gl = gl;
_shader = new Shader(gl,
Path.Combine(shaderDir, "debug_line.vert"),
Path.Combine(shaderDir, "debug_line.frag"));
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
// 24-byte stride: vec3 pos + vec3 color
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)(3 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
}
/// <summary>Clear accumulated lines. Call at the start of each frame.</summary>
public void Begin()
{
_buffer.Clear();
_vertexCount = 0;
}
public void AddLine(Vector3 a, Vector3 b, Vector3 color)
{
_buffer.Add(a.X); _buffer.Add(a.Y); _buffer.Add(a.Z);
_buffer.Add(color.X); _buffer.Add(color.Y); _buffer.Add(color.Z);
_buffer.Add(b.X); _buffer.Add(b.Y); _buffer.Add(b.Z);
_buffer.Add(color.X); _buffer.Add(color.Y); _buffer.Add(color.Z);
_vertexCount += 2;
}
/// <summary>
/// Draw a cylinder as 2 polygon rings (base + top) connected by 4
/// vertical line segments at 0/90/180/270 degrees.
/// </summary>
public void AddCylinder(Vector3 basePos, float radius, float height, Vector3 color)
{
const int segments = 16;
Vector3 top = basePos + new Vector3(0, 0, height);
// Ring vertices
var baseRing = new Vector3[segments];
var topRing = new Vector3[segments];
for (int i = 0; i < segments; i++)
{
float theta = i * (MathF.PI * 2f / segments);
float cx = MathF.Cos(theta) * radius;
float cy = MathF.Sin(theta) * radius;
baseRing[i] = new Vector3(basePos.X + cx, basePos.Y + cy, basePos.Z);
topRing[i] = new Vector3(top.X + cx, top.Y + cy, top.Z);
}
// Base ring
for (int i = 0; i < segments; i++)
AddLine(baseRing[i], baseRing[(i + 1) % segments], color);
// Top ring
for (int i = 0; i < segments; i++)
AddLine(topRing[i], topRing[(i + 1) % segments], color);
// 4 vertical connectors
for (int i = 0; i < 4; i++)
{
int idx = i * (segments / 4);
AddLine(baseRing[idx], topRing[idx], color);
}
}
/// <summary>
/// Draw an axis-aligned box as 12 edges.
/// </summary>
public void AddBox(Vector3 min, Vector3 max, Vector3 color)
{
Vector3[] c =
{
new(min.X, min.Y, min.Z),
new(max.X, min.Y, min.Z),
new(max.X, max.Y, min.Z),
new(min.X, max.Y, min.Z),
new(min.X, min.Y, max.Z),
new(max.X, min.Y, max.Z),
new(max.X, max.Y, max.Z),
new(min.X, max.Y, max.Z),
};
// Bottom
AddLine(c[0], c[1], color); AddLine(c[1], c[2], color);
AddLine(c[2], c[3], color); AddLine(c[3], c[0], color);
// Top
AddLine(c[4], c[5], color); AddLine(c[5], c[6], color);
AddLine(c[6], c[7], color); AddLine(c[7], c[4], color);
// Verticals
AddLine(c[0], c[4], color); AddLine(c[1], c[5], color);
AddLine(c[2], c[6], color); AddLine(c[3], c[7], color);
}
/// <summary>Upload + draw all accumulated lines.</summary>
public void Flush(Matrix4x4 view, Matrix4x4 projection)
{
if (_vertexCount == 0) return;
_shader.Use();
_shader.SetMatrix4("uView", view);
_shader.SetMatrix4("uProjection", projection);
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
int neededBytes = _buffer.Count * sizeof(float);
if (neededBytes > _capacityBytes)
{
fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)neededBytes, ptr, BufferUsageARB.DynamicDraw);
_capacityBytes = neededBytes;
}
else
{
fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)neededBytes, ptr);
}
// Depth test on so lines get occluded by geometry (but we want them
// visible through geometry — disable depth test so everything shows).
bool wasDepthEnabled = _gl.IsEnabled(EnableCap.DepthTest);
_gl.Disable(EnableCap.DepthTest);
_gl.DrawArrays(PrimitiveType.Lines, 0, (uint)_vertexCount);
if (wasDepthEnabled) _gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
}
public void Dispose()
{
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
_shader.Dispose();
}
}

View file

@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Screen-space debug HUD. Composes panels on top of the 3D scene using a
/// <see cref="TextRenderer"/> + <see cref="BitmapFont"/>. Panels can be
/// toggled independently (info / stats / controls-help / compass).
///
/// The overlay is stateless w.r.t. game state — callers populate a
/// <see cref="Snapshot"/> each frame and pass it to <see cref="Draw"/>.
/// </summary>
public sealed class DebugOverlay
{
private readonly TextRenderer _text;
private readonly BitmapFont _font;
public bool ShowInfoPanel { get; set; } = true;
public bool ShowStatsPanel { get; set; } = true;
public bool ShowHelpPanel { get; set; } = false;
public bool ShowCompass { get; set; } = true;
// Toast state for transient notifications (e.g. "wireframes off").
private string? _toastText;
private Vector4 _toastColor = White;
private float _toastTimeLeft;
private static readonly Vector4 White = new(1f, 1f, 1f, 1f);
private static readonly Vector4 Green = new(0.4f, 0.95f, 0.4f, 1f);
private static readonly Vector4 Yellow = new(1f, 0.9f, 0.3f, 1f);
private static readonly Vector4 Red = new(1f, 0.4f, 0.35f, 1f);
private static readonly Vector4 Cyan = new(0.4f, 0.85f, 1f, 1f);
private static readonly Vector4 Grey = new(0.7f, 0.7f, 0.75f, 1f);
private static readonly Vector4 PanelBg = new(0f, 0f, 0f, 0.55f);
private static readonly Vector4 PanelBorder = new(0.15f, 0.15f, 0.2f, 0.8f);
/// <summary>Per-frame state snapshot from the caller. See <see cref="Draw"/>.</summary>
public readonly record struct Snapshot(
float Fps,
float FrameTimeMs,
Vector3 PlayerPos,
float HeadingDeg,
uint CellId,
bool OnGround,
bool InPlayerMode,
bool InFlyMode,
float VerticalVelocity,
int EntityCount,
int AnimatedCount,
int LandblocksVisible,
int LandblocksTotal,
int ShadowObjectCount,
float NearestObjDist,
string NearestObjLabel,
bool Colliding,
bool DebugWireframes,
int StreamingRadius,
float MouseSensitivity,
float ChaseDistance,
bool RmbOrbit);
public DebugOverlay(TextRenderer text, BitmapFont font)
{
_text = text;
_font = font;
}
/// <summary>Show a short message in the center-top for <paramref name="durationSec"/> seconds.</summary>
public void Toast(string message, float durationSec = 1.5f, Vector4? color = null)
{
_toastText = message;
_toastColor = color ?? Yellow;
_toastTimeLeft = durationSec;
}
/// <summary>Advance toast timer. Call once per frame with dt in seconds.</summary>
public void Update(float dt)
{
if (_toastTimeLeft > 0f)
{
_toastTimeLeft -= dt;
if (_toastTimeLeft <= 0f)
_toastText = null;
}
}
public void Draw(Snapshot s, Vector2 screenSize)
{
_text.Begin(screenSize);
if (ShowInfoPanel) DrawInfoPanel(s);
if (ShowStatsPanel) DrawStatsPanel(s, screenSize);
if (ShowCompass) DrawCompass(s, screenSize);
if (ShowHelpPanel) DrawHelpPanel(screenSize);
DrawHintBar(screenSize);
DrawToast(screenSize);
_text.Flush(_font);
}
// ──────────────────────────────────────────────────────────────────────
// Info panel — top-left: mode, position, heading, ground, nearest-obj.
// ──────────────────────────────────────────────────────────────────────
private void DrawInfoPanel(Snapshot s)
{
var lines = new List<(string text, Vector4 color)>();
string modeLabel = s.InPlayerMode ? "PLAYER" : (s.InFlyMode ? "FLY " : "ORBIT ");
var modeColor = s.InPlayerMode ? Yellow : (s.InFlyMode ? Cyan : Grey);
lines.Add(($"[{modeLabel}] wireframes {(s.DebugWireframes ? "ON " : "OFF")}",
modeColor));
lines.Add(($"Pos {s.PlayerPos.X,8:F1} {s.PlayerPos.Y,8:F1} {s.PlayerPos.Z,8:F2}", White));
lines.Add(($"Head {s.HeadingDeg,5:F0} deg Cell 0x{s.CellId:X8}", White));
var gColor = s.OnGround ? Green : Yellow;
string vzStr = s.VerticalVelocity >= 0
? $"+{s.VerticalVelocity,4:F2}"
: $"{s.VerticalVelocity,5:F2}";
lines.Add(($"Grnd {(s.OnGround ? "yes" : "NO ")} vZ {vzStr}", gColor));
var nColor = s.Colliding ? Red : White;
string nearDist = float.IsPositiveInfinity(s.NearestObjDist) ? " --- " : $"{s.NearestObjDist,4:F1}m";
lines.Add(($"Near {nearDist} {s.NearestObjLabel}", nColor));
var cColor = s.Colliding ? Red : Green;
lines.Add(($"Coll {(s.Colliding ? "BLOCKED" : "free ")}", cColor));
if (s.InPlayerMode)
{
string orbitTag = s.RmbOrbit ? " [RMB orbit]" : "";
lines.Add(($"Cam dist {s.ChaseDistance,4:F1}m{orbitTag}",
s.RmbOrbit ? Cyan : White));
}
lines.Add(($"Sens {s.MouseSensitivity:F3}x (F8 slower / F9 faster)", Grey));
DrawPanel(10f, 10f, lines);
}
// ──────────────────────────────────────────────────────────────────────
// Stats panel — top-right: fps, frame time, landblock/entity counters.
// ──────────────────────────────────────────────────────────────────────
private void DrawStatsPanel(Snapshot s, Vector2 screenSize)
{
var lines = new List<(string text, Vector4 color)>
{
($"{s.Fps,5:F0} fps {s.FrameTimeMs,5:F1} ms", Green),
($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White),
};
float pad = 10f;
float panelW = MeasureMax(lines) + 2 * InnerPad;
DrawPanel(screenSize.X - panelW - pad, pad, lines, panelW);
}
// ──────────────────────────────────────────────────────────────────────
// Compass — bottom-center: arrow indicating heading, cardinal labels.
// ──────────────────────────────────────────────────────────────────────
private void DrawCompass(Snapshot s, Vector2 screenSize)
{
// Simple linear compass strip across 180° of horizon at the top-center.
const float stripW = 360f;
const float stripH = 20f;
float cx = screenSize.X * 0.5f;
float top = 8f;
float left = cx - stripW * 0.5f;
_text.DrawRect(left, top, stripW, stripH, PanelBg);
_text.DrawRectOutline(left, top, stripW, stripH, PanelBorder);
// Mark every 30° of yaw, labelled with cardinal letters at N/E/S/W.
// Heading 0 = +X (east). We show 180° wide, ±90° from current heading.
float h = NormalizeDeg(s.HeadingDeg);
for (int d = 0; d < 360; d += 15)
{
float rel = NormalizeDegSigned(d - h);
if (rel < -90 || rel > 90) continue;
float x = cx + rel / 90f * (stripW * 0.5f - 6f);
bool bold = (d % 90) == 0;
float tickH = bold ? stripH * 0.9f : stripH * 0.4f;
_text.DrawRect(x - 0.5f, top + stripH - tickH, 1f, tickH, bold ? White : Grey);
if (bold)
{
string lab = d switch
{
0 => "E",
90 => "N",
180 => "W",
270 => "S",
_ => ""
};
if (lab.Length > 0)
{
float w = _font.MeasureWidth(lab);
_text.DrawString(_font, lab, x - w * 0.5f, top - _font.LineHeight + 4f, White);
}
}
}
// Current heading indicator arrow below the strip.
_text.DrawRect(cx - 1.5f, top + stripH + 2f, 3f, 6f, Yellow);
string hText = $"{h,3:F0}°";
float hw = _font.MeasureWidth(hText);
_text.DrawString(_font, hText, cx - hw * 0.5f, top + stripH + 10f, Yellow);
}
// ──────────────────────────────────────────────────────────────────────
// Help panel — center: full keybind cheat-sheet, shown when F1 is pressed.
// ──────────────────────────────────────────────────────────────────────
private static readonly (string key, string desc)[] Keybinds =
{
("F1", "toggle this help"),
("F2", "toggle collision wireframes"),
("F3", "console dump (pos + nearby objects)"),
("F4", "toggle debug HUD info panel"),
("F5", "toggle stats panel"),
("F6", "toggle compass"),
("F", "toggle fly camera"),
("Tab", "toggle player mode (requires login)"),
("W A S D", "move (player mode) / fly"),
("Mouse", "turn character / look (fly)"),
("Hold RMB", "free orbit camera around player"),
("Wheel", "zoom chase camera in / out"),
("F8 / F9", "mouse sensitivity slower / faster"),
("Space", "jump (hold to charge)"),
("Shift", "run"),
("Escape", "exit fly / player / close window"),
};
private void DrawHelpPanel(Vector2 screenSize)
{
var lines = new List<(string text, Vector4 color)>();
lines.Add(("CONTROLS", Yellow));
lines.Add(("", White));
foreach (var (k, d) in Keybinds)
lines.Add(($" {k,-9} {d}", White));
lines.Add(("", White));
lines.Add(("Press F1 to close", Grey));
float panelW = MeasureMax(lines) + 2 * InnerPad;
float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
float x = (screenSize.X - panelW) * 0.5f;
float y = (screenSize.Y - panelH) * 0.5f;
DrawPanel(x, y, lines, panelW);
}
// ──────────────────────────────────────────────────────────────────────
// Hint bar — bottom-left: always-visible "F1 for help" reminder.
// ──────────────────────────────────────────────────────────────────────
private void DrawHintBar(Vector2 screenSize)
{
string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold RMB orbit Wheel zoom";
float w = _font.MeasureWidth(hint);
float pad = 10f;
float y = screenSize.Y - _font.LineHeight - pad;
_text.DrawRect(pad - 4, y - 3, w + 8, _font.LineHeight + 6, PanelBg);
_text.DrawString(_font, hint, pad, y, Grey);
}
private void DrawToast(Vector2 screenSize)
{
if (_toastText is null || _toastTimeLeft <= 0f) return;
float w = _font.MeasureWidth(_toastText);
float x = (screenSize.X - w) * 0.5f;
float y = 60f;
var c = _toastColor;
float alpha = MathF.Min(1f, _toastTimeLeft / 0.5f);
c.W *= alpha;
var bg = PanelBg;
bg.W *= alpha;
_text.DrawRect(x - 10, y - 4, w + 20, _font.LineHeight + 8, bg);
_text.DrawString(_font, _toastText, x, y, c);
}
// ──────────────────────────────────────────────────────────────────────
// Panel helpers.
// ──────────────────────────────────────────────────────────────────────
private const float InnerPad = 8f;
private float MeasureMax(IReadOnlyList<(string text, Vector4 color)> lines)
{
float m = 0;
foreach (var (text, _) in lines)
m = MathF.Max(m, _font.MeasureWidth(text));
return m;
}
private void DrawPanel(float x, float y, IReadOnlyList<(string text, Vector4 color)> lines, float? widthOverride = null)
{
float maxW = MeasureMax(lines);
float panelW = widthOverride ?? (maxW + 2 * InnerPad);
float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
_text.DrawRect(x, y, panelW, panelH, PanelBg);
_text.DrawRectOutline(x, y, panelW, panelH, PanelBorder);
float cy = y + InnerPad;
foreach (var (text, color) in lines)
{
_text.DrawString(_font, text, x + InnerPad, cy, color);
cy += _font.LineHeight;
}
}
private static float NormalizeDeg(float deg)
{
deg %= 360f;
if (deg < 0) deg += 360f;
return deg;
}
private static float NormalizeDegSigned(float deg)
{
deg %= 360f;
if (deg > 180) deg -= 360f;
if (deg < -180) deg += 360f;
return deg;
}
}

File diff suppressed because it is too large Load diff

View file

@ -64,5 +64,17 @@ public sealed class Shader : IDisposable
_gl.Uniform3(loc, v.X, v.Y, v.Z);
}
public void SetVec2(string name, Vector2 v)
{
int loc = _gl.GetUniformLocation(Program, name);
_gl.Uniform2(loc, v.X, v.Y);
}
public void SetVec4(string name, Vector4 v)
{
int loc = _gl.GetUniformLocation(Program, name);
_gl.Uniform4(loc, v.X, v.Y, v.Z, v.W);
}
public void Dispose() => _gl.DeleteProgram(Program);
}

View file

@ -0,0 +1,7 @@
#version 430 core
in vec3 vColor;
out vec4 FragColor;
void main() {
FragColor = vec4(vColor, 1.0);
}

View file

@ -0,0 +1,13 @@
#version 430 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
uniform mat4 uView;
uniform mat4 uProjection;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = uProjection * uView * vec4(aPos, 1.0);
}

View file

@ -0,0 +1,18 @@
#version 430 core
in vec2 vUv;
in vec4 vColor;
out vec4 FragColor;
uniform sampler2D uTex;
uniform int uUseTexture;
void main() {
if (uUseTexture != 0) {
// Font atlas is a single-channel R8 texture; red = coverage alpha.
float coverage = texture(uTex, vUv).r;
FragColor = vec4(vColor.rgb, vColor.a * coverage);
} else {
FragColor = vColor;
}
if (FragColor.a < 0.005) discard;
}

View file

@ -0,0 +1,19 @@
#version 430 core
layout(location = 0) in vec2 aPos; // screen pixels, origin top-left
layout(location = 1) in vec2 aUv;
layout(location = 2) in vec4 aColor;
uniform vec2 uScreenSize;
out vec2 vUv;
out vec4 vColor;
void main() {
// Convert pixel coords (origin top-left, +Y down) to NDC (origin center, +Y up).
vec2 ndc = vec2(
aPos.x / uScreenSize.x * 2.0 - 1.0,
1.0 - aPos.y / uScreenSize.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
vUv = aUv;
vColor = aColor;
}

View file

@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Runtime.InteropServices;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// 2D batched quad renderer for text + solid rectangles. Coordinates are in
/// screen pixels with origin top-left, +X right, +Y down. Call
/// <see cref="Begin"/> at the start of a HUD pass, queue geometry via
/// <see cref="DrawString"/> / <see cref="DrawRect"/>, then <see cref="Flush"/>.
///
/// Uses two internal vertex buffers (text and rect) flushed in two draw calls
/// to avoid a per-vertex "use texture" flag. Rects are drawn first so text
/// sits on top of background panels.
/// </summary>
public sealed unsafe class TextRenderer : IDisposable
{
private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
private readonly GL _gl;
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private int _vboCapacityBytes;
private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024);
private int _textVerts;
private int _rectVerts;
private Vector2 _screenSize;
public TextRenderer(GL gl, string shaderDir)
{
_gl = gl;
_shader = new Shader(gl,
Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag"));
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
uint stride = FloatsPerVertex * sizeof(float);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
public void Begin(Vector2 screenSize)
{
_screenSize = screenSize;
_textBuf.Clear();
_rectBuf.Clear();
_textVerts = 0;
_rectVerts = 0;
}
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
_rectVerts += 6;
}
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
// top, bottom, left, right
DrawRect(x, y, w, thickness, color);
DrawRect(x, y + h - thickness, w, thickness, color);
DrawRect(x, y, thickness, h, color);
DrawRect(x + w - thickness, y, thickness, h, color);
}
/// <summary>
/// Draw a single line of text at (x,y) where (x,y) is the top-left of the
/// typographic block. Handles '\n' as a line break.
/// </summary>
public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color)
{
float cursorX = x;
// The caller provides top-y; shift to baseline for glyph offset math.
float baseline = y + font.Ascent;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
if (c == '\n')
{
cursorX = x;
baseline += font.LineHeight;
continue;
}
if (!font.TryGetGlyph(c, out var g))
{
// Unknown glyph — skip its advance width if '?' exists.
if (font.TryGetGlyph('?', out var q))
cursorX += q.Advance;
continue;
}
float gx = cursorX + g.OffsetX;
float gy = baseline + g.OffsetY;
float gw = g.Width;
float gh = g.Height;
if (gw > 0 && gh > 0)
{
AppendQuad(_textBuf,
gx, gy, gw, gh,
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
color);
_textVerts += 6;
}
cursorX += g.Advance;
}
}
private static void AppendQuad(List<float> buf,
float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color)
{
// Two triangles (6 verts). CCW in pixel space is clockwise in NDC
// because the vertex shader flips Y, so OpenGL's default front-face
// is GL_CCW — we rely on cull-face being disabled during HUD pass.
// (x, y) ─ (x+w, y)
// │ │
// (x, y+h) ─ (x+w, y+h)
//
// Triangle 1: (x,y) (x+w,y+h) (x+w,y)
// Triangle 2: (x,y) (x,y+h) (x+w,y+h)
void V(float px, float py, float pu, float pv)
{
buf.Add(px); buf.Add(py);
buf.Add(pu); buf.Add(pv);
buf.Add(color.X); buf.Add(color.Y); buf.Add(color.Z); buf.Add(color.W);
}
V(x, y, u0, v0);
V(x + w, y + h, u1, v1);
V(x + w, y, u1, v0);
V(x, y, u0, v0);
V(x, y + h, u0, v1);
V(x + w, y + h, u1, v1);
}
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font)
{
if (_textVerts == 0 && _rectVerts == 0) return;
_shader.Use();
_shader.SetVec2("uScreenSize", _screenSize);
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
// Save GL state.
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
_gl.Disable(EnableCap.DepthTest);
_gl.Disable(EnableCap.CullFace);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// Untextured rects first — they form panel backgrounds.
if (_rectVerts > 0)
{
_shader.SetInt("uUseTexture", 0);
UploadBuffer(_rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
}
// Textured text glyphs.
if (_textVerts > 0 && font is not null)
{
_shader.SetInt("uUseTexture", 1);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0);
UploadBuffer(_textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
}
// Restore GL state.
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
}
private void UploadBuffer(List<float> buf)
{
int bytes = buf.Count * sizeof(float);
if (bytes == 0) return;
if (bytes > _vboCapacityBytes)
{
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)bytes, p, BufferUsageARB.DynamicDraw);
_vboCapacityBytes = bytes;
}
else
{
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p);
}
}
public void Dispose()
{
_gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_vao);
_shader.Dispose();
}
}

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))
bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
ref hitPoly1, ref contact1);
if (hit1 || hitPoly1 is not null)
{
var worldNormal = L2W(hitPoly1!.Plane.Normal);
return SlideSphere(transition, worldNormal);
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);
}
if (hitPoly1 is not null)
return NegPolyHitDispatch(path, hitPoly1, false, localToWorld);
if (hitPoly0 is not null)
return NegPolyHitDispatch(path, hitPoly0, true, localToWorld);
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,67 +395,115 @@ 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)
{
case TransitionState.OK:
// Outdoor path: no neighboring cell enumeration needed for MVP.
break;
case TransitionState.Collided:
if (transitState == TransitionState.Collided)
return TransitionState.Collided;
case TransitionState.Adjusted:
sp.NegPolyHit = false;
break;
case TransitionState.Slid:
if (transitState == TransitionState.Slid)
{
// 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;
break;
continue;
}
// Phase 1b: check object (static BSP) collisions when OK so far.
if (transitState == TransitionState.OK)
if (transitState == TransitionState.Adjusted)
{
// Env modified CheckPos. Retry at new position.
sp.NegPolyHit = false;
continue;
}
// ── 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)
{
transitState = TransitionState.Slid;
// 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;
}
else if (objState == TransitionState.Collided)
{
return TransitionState.Collided;
}
continue;
}
// Phase 2: post-collision response.
if (transitState == TransitionState.OK)
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)
{
@ -485,14 +537,13 @@ public sealed class Transition
sp.RestoreCheckPos();
return TransitionState.OK;
}
else
{
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;
}