From ff325abd7bcd0a24a36e61d015daa60af29daf81 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Apr 2026 18:45:38 +0200 Subject: [PATCH] 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 --- memory/project_session_2026_04_16.md | 169 ++++ memory/project_session_2026_04_17.md | 113 +++ src/AcDream.App/AcDream.App.csproj | 1 + src/AcDream.App/Rendering/BitmapFont.cs | 180 ++++ src/AcDream.App/Rendering/ChaseCamera.cs | 39 +- .../Rendering/DebugLineRenderer.cs | 172 ++++ src/AcDream.App/Rendering/DebugOverlay.cs | 330 +++++++ src/AcDream.App/Rendering/GameWindow.cs | 854 ++++++++++++++++-- src/AcDream.App/Rendering/Shader.cs | 12 + .../Rendering/Shaders/debug_line.frag | 7 + .../Rendering/Shaders/debug_line.vert | 13 + .../Rendering/Shaders/ui_text.frag | 18 + .../Rendering/Shaders/ui_text.vert | 19 + src/AcDream.App/Rendering/TextRenderer.cs | 230 +++++ src/AcDream.Core/Physics/BSPQuery.cs | 145 ++- src/AcDream.Core/Physics/PhysicsDataCache.cs | 90 +- .../Physics/ShadowObjectRegistry.cs | 28 +- src/AcDream.Core/Physics/TransitionTypes.cs | 366 +++++--- src/AcDream.Core/World/SceneryGenerator.cs | 204 ++++- src/AcDream.Core/World/WorldEntity.cs | 12 + 20 files changed, 2734 insertions(+), 268 deletions(-) create mode 100644 memory/project_session_2026_04_16.md create mode 100644 memory/project_session_2026_04_17.md create mode 100644 src/AcDream.App/Rendering/BitmapFont.cs create mode 100644 src/AcDream.App/Rendering/DebugLineRenderer.cs create mode 100644 src/AcDream.App/Rendering/DebugOverlay.cs create mode 100644 src/AcDream.App/Rendering/Shaders/debug_line.frag create mode 100644 src/AcDream.App/Rendering/Shaders/debug_line.vert create mode 100644 src/AcDream.App/Rendering/Shaders/ui_text.frag create mode 100644 src/AcDream.App/Rendering/Shaders/ui_text.vert create mode 100644 src/AcDream.App/Rendering/TextRenderer.cs diff --git a/memory/project_session_2026_04_16.md b/memory/project_session_2026_04_16.md new file mode 100644 index 0000000..24de3a9 --- /dev/null +++ b/memory/project_session_2026_04_16.md @@ -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. diff --git a/memory/project_session_2026_04_17.md b/memory/project_session_2026_04_17.md new file mode 100644 index 0000000..df6e462 --- /dev/null +++ b/memory/project_session_2026_04_17.md @@ -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) diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 7311fe0..a9edc9f 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -15,6 +15,7 @@ + diff --git a/src/AcDream.App/Rendering/BitmapFont.cs b/src/AcDream.App/Rendering/BitmapFont.cs new file mode 100644 index 0000000..c3106ba --- /dev/null +++ b/src/AcDream.App/Rendering/BitmapFont.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using Silk.NET.OpenGL; +using StbTrueTypeSharp; + +namespace AcDream.App.Rendering; + +/// +/// 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 +/// to resolve an ASCII codepoint to UV + metrics. +/// +/// Only printable ASCII (32..127) is supported for the debug overlay. +/// +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; + } + + /// Measure the pixel width of a single-line string in this font. + 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); + } + + /// + /// Try to load a monospaced system font from well-known paths on the host OS. + /// Returns null if no candidate was found. + /// + 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; + } +} diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs index d70950a..c778ff6 100644 --- a/src/AcDream.App/Rendering/ChaseCamera.cs +++ b/src/AcDream.App/Rendering/ChaseCamera.cs @@ -14,17 +14,34 @@ public sealed class ChaseCamera : ICamera public float Aspect { get; set; } = 16f / 9f; public float FovY { get; set; } = MathF.PI / 3f; - /// Distance behind the player. + /// Distance behind the player. Clamped to [, ]. public float Distance { get; set; } = 8f; + public const float DistanceMin = 2f; + public const float DistanceMax = 40f; /// Camera pitch above horizontal (radians). Positive = look down. public float Pitch { get; set; } = 0.35f; // ~20 degrees + /// + /// 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. + /// + public float YawOffset { get; set; } = 0f; + /// Vertical offset from the player's feet to the look-at point (eye height). 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); } + + /// + /// Adjust distance (zoom) by a delta, clamped to [DistanceMin, DistanceMax]. + /// + public void AdjustDistance(float delta) + { + Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); + } } diff --git a/src/AcDream.App/Rendering/DebugLineRenderer.cs b/src/AcDream.App/Rendering/DebugLineRenderer.cs new file mode 100644 index 0000000..d86340e --- /dev/null +++ b/src/AcDream.App/Rendering/DebugLineRenderer.cs @@ -0,0 +1,172 @@ +using System.Collections.Generic; +using System.Numerics; +using System.Runtime.InteropServices; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +/// +/// Minimal GL debug line renderer for visualizing collision shapes, +/// bounding boxes, and other debug geometry. Collect lines each frame +/// via / , then call +/// 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. +/// +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 _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); + } + + /// Clear accumulated lines. Call at the start of each frame. + 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; + } + + /// + /// Draw a cylinder as 2 polygon rings (base + top) connected by 4 + /// vertical line segments at 0/90/180/270 degrees. + /// + 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); + } + } + + /// + /// Draw an axis-aligned box as 12 edges. + /// + 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); + } + + /// Upload + draw all accumulated lines. + 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(); + } +} diff --git a/src/AcDream.App/Rendering/DebugOverlay.cs b/src/AcDream.App/Rendering/DebugOverlay.cs new file mode 100644 index 0000000..7ac635a --- /dev/null +++ b/src/AcDream.App/Rendering/DebugOverlay.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Screen-space debug HUD. Composes panels on top of the 3D scene using a +/// + . Panels can be +/// toggled independently (info / stats / controls-help / compass). +/// +/// The overlay is stateless w.r.t. game state — callers populate a +/// each frame and pass it to . +/// +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); + + /// Per-frame state snapshot from the caller. See . + 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; + } + + /// Show a short message in the center-top for seconds. + public void Toast(string message, float durationSec = 1.5f, Vector4? color = null) + { + _toastText = message; + _toastColor = color ?? Yellow; + _toastTimeLeft = durationSec; + } + + /// Advance toast timer. Call once per frame with dt in seconds. + 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; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a7b834f..773c280 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -26,6 +26,21 @@ public sealed class GameWindow : IDisposable private InstancedMeshRenderer? _staticMesh; private Shader? _meshShader; private TextureCache? _textureCache; + private DebugLineRenderer? _debugLines; + private bool _debugCollisionVisible = true; + private int _debugDrawLogOnce = 0; + + // On-screen debug HUD — info panel, stats panel, compass, keybind help. + // F1/F2/F4/F5/F6 toggle the individual panels (see the key handler). + // Null if no system font is available at startup; in that case the HUD + // is silently disabled and the rest of the client keeps working. + private TextRenderer? _textRenderer; + private BitmapFont? _debugFont; + private DebugOverlay? _debugOverlay; + // Last-computed perf values so the HUD always has something to show even + // though the title-bar FPS is only updated every 0.5s. + private double _lastFps = 60.0; + private double _lastFrameMs = 16.7; // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; @@ -119,6 +134,19 @@ public sealed class GameWindow : IDisposable // callback, consumed + reset in OnUpdate each frame. private float _playerMouseDeltaX; + // Mouse sensitivity multipliers — one per camera mode because the visual + // feel is very different. Adjust via F8 / F9 for whichever mode is + // currently active. Chase default is low because the character + camera + // rotating together is overwhelming at fly speeds. + private float _sensChase = 0.15f; + private float _sensFly = 1.0f; + private float _sensOrbit = 1.0f; + + // Right-mouse-button held → free-orbit the chase camera around the + // player without turning the character. Release leaves the camera at + // the orbited position (no snap back). + private bool _rmbHeld; + // Phase 4.7: optional live connection to an ACE server. Enabled only when // ACDREAM_LIVE=1 is in the environment — fully backward compatible with // the offline rendering pipeline. @@ -189,6 +217,121 @@ public sealed class GameWindow : IDisposable { if (key == Key.F) _cameraController?.ToggleFly(); + else if (key == Key.F3) + { + // Dump player position + ALL entities (visible rendered) + shadow objs within 15m. + // Lets us see visible-entity-without-collision gaps. + System.Numerics.Vector3 pos; + if (_playerMode && _playerController is not null) + pos = _playerController.Position; + else + { + System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv); + pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43); + } + int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f); + int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f); + Console.WriteLine( + $"=== F3 DEBUG DUMP ===\n" + + $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" + + $" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" + + $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}"); + + // Collect VISIBLE entities within 15m (from GpuWorldState) + var visibleNearby = new List(); + foreach (var e in _worldState.Entities) + { + float dx = e.Position.X - pos.X; + float dy = e.Position.Y - pos.Y; + if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e); + } + Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}"); + foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12)) + { + float d = (e.Position - pos).Length(); + Console.WriteLine( + $" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " + + $"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}"); + } + + // Collect shadow objects within 15m + var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>(); + foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) + { + float dx = o.Position.X - pos.X; + float dy = o.Position.Y - pos.Y; + float d = MathF.Sqrt(dx * dx + dy * dy); + if (d < 15f) sorted.Add((o, d)); + } + sorted.Sort((a, b) => a.dist.CompareTo(b.dist)); + Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}"); + foreach (var (o, d) in sorted.Take(12)) + { + Console.WriteLine( + $" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " + + $"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}"); + } + } + else if (key == Key.F1) + { + if (_debugOverlay is not null) + { + _debugOverlay.ShowHelpPanel = !_debugOverlay.ShowHelpPanel; + _debugOverlay.Toast($"Help {(_debugOverlay.ShowHelpPanel ? "ON" : "OFF")}"); + } + } + else if (key == Key.F2) + { + _debugCollisionVisible = !_debugCollisionVisible; + _debugOverlay?.Toast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}"); + } + else if (key == Key.F4) + { + if (_debugOverlay is not null) + { + _debugOverlay.ShowInfoPanel = !_debugOverlay.ShowInfoPanel; + _debugOverlay.Toast($"Info panel {(_debugOverlay.ShowInfoPanel ? "ON" : "OFF")}"); + } + } + else if (key == Key.F5) + { + if (_debugOverlay is not null) + { + _debugOverlay.ShowStatsPanel = !_debugOverlay.ShowStatsPanel; + _debugOverlay.Toast($"Stats panel {(_debugOverlay.ShowStatsPanel ? "ON" : "OFF")}"); + } + } + else if (key == Key.F6) + { + if (_debugOverlay is not null) + { + _debugOverlay.ShowCompass = !_debugOverlay.ShowCompass; + _debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}"); + } + } + else if (key == Key.F8 || key == Key.F9) + { + // Adjust whichever mode's sensitivity is currently active. + // Multiplicative step (1.2x / /1.2x) so low values stay fine + // grained and high values move in proportional chunks. + string modeLabel; + float current; + if (_playerMode && _cameraController?.IsChaseMode == true) + { modeLabel = "Chase"; current = _sensChase; } + else if (_cameraController?.IsFlyMode == true) + { modeLabel = "Fly"; current = _sensFly; } + else + { modeLabel = "Orbit"; current = _sensOrbit; } + + float next = (key == Key.F9) ? current * 1.2f : current / 1.2f; + next = MathF.Min(3.0f, MathF.Max(0.005f, next)); + + if (modeLabel == "Chase") _sensChase = next; + else if (modeLabel == "Fly") _sensFly = next; + else _sensOrbit = next; + + _debugOverlay?.Toast($"{modeLabel} sens {next:F3}x"); + } else if (key == Key.Escape) { if (_cameraController?.IsFlyMode == true) @@ -292,37 +435,82 @@ public sealed class GameWindow : IDisposable float dx = pos.X - _lastMouseX; float dy = pos.Y - _lastMouseY; - if (_playerMode && _cameraController.IsChaseMode) + if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) { - // Phase B.2: player mode — mouse X turns the character, - // mouse Y adjusts chase camera pitch. - // Accumulate X for the controller to consume in OnUpdate. - _playerMouseDeltaX += dx; - _chaseCamera?.AdjustPitch(dy * 0.005f); + float sens = _sensChase; + if (_rmbHeld) + { + // Hold-RMB orbit: player stays the central point, camera + // free-orbits around. X rotates around, Y pitches. On release + // the camera STAYS at the new angle (no snap back). + _chaseCamera.YawOffset -= dx * 0.004f * sens; + _chaseCamera.AdjustPitch(dy * 0.003f * sens); + } + else + { + // Normal chase: X turns the character, Y pitches camera. + _playerMouseDeltaX += dx * sens; + _chaseCamera.AdjustPitch(dy * 0.003f * sens); + } } else if (_cameraController.IsFlyMode) { - // Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last. - _cameraController.Fly.Look(dx, dy); + float sens = _sensFly; + _cameraController.Fly.Look(dx * sens, dy * sens); } else { + float sens = _sensOrbit; if (m.IsButtonPressed(MouseButton.Left)) { - _cameraController.Orbit.Yaw -= dx * 0.005f; + _cameraController.Orbit.Yaw -= dx * 0.005f * sens; _cameraController.Orbit.Pitch = Math.Clamp( - _cameraController.Orbit.Pitch + dy * 0.005f, + _cameraController.Orbit.Pitch + dy * 0.005f * sens, 0.1f, 1.5f); } } _lastMouseX = pos.X; _lastMouseY = pos.Y; }; + + mouse.MouseDown += (_, btn) => + { + if (btn == MouseButton.Right && _playerMode + && _cameraController?.IsChaseMode == true) + { + _rmbHeld = true; + } + }; + + mouse.MouseUp += (_, btn) => + { + if (btn == MouseButton.Right) + { + // Camera stays at the orbited position — no snap back. + _rmbHeld = false; + } + }; + mouse.Scroll += (_, scroll) => { - if (_cameraController is null || _cameraController.IsFlyMode) return; - _cameraController.Orbit.Distance = Math.Clamp( - _cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f); + if (_cameraController is null) return; + + // Chase mode: mouse wheel zooms (adjusts camera distance). + if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) + { + _chaseCamera.AdjustDistance(-scroll.Y * 0.8f); + } + // Fly mode: no scroll action (could adjust move speed later). + else if (_cameraController.IsFlyMode) + { + // no-op + } + // Orbit mode: wheel zooms the orbit camera. + else + { + _cameraController.Orbit.Distance = Math.Clamp( + _cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f); + } }; } @@ -338,6 +526,25 @@ public sealed class GameWindow : IDisposable Path.Combine(shadersDir, "mesh_instanced.vert"), Path.Combine(shadersDir, "mesh_instanced.frag")); + _debugLines = new DebugLineRenderer(_gl, shadersDir); + + // Debug HUD: load a system monospace font and set up the text overlay. + // Skips silently if no font is available (the rest of the client still works). + var fontBytes = BitmapFont.TryLoadSystemMonospaceFont(); + if (fontBytes is not null) + { + _debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512); + _textRenderer = new TextRenderer(_gl, shadersDir); + _debugOverlay = new DebugOverlay(_textRenderer, _debugFont); + Console.WriteLine($"debug overlay: loaded {fontBytes.Length / 1024}KB font, " + + $"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " + + $"lineHeight={_debugFont.LineHeight:F1}px"); + } + else + { + Console.WriteLine("debug overlay: no system monospace font found; HUD disabled"); + } + var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y }; var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y }; _cameraController = new CameraController(orbit, fly); @@ -902,28 +1109,59 @@ public sealed class GameWindow : IDisposable /// Bilinear sample of the landblock heightmap at (x, y) in landblock-local /// world units. Matches the x-major indexing convention of LandblockMesh. /// - private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY) + private float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY) { + // Exact port of WorldBuilder TerrainUtils.GetHeight (line 59-108). + // Barycentric interpolation over the cell's triangle pair, respecting + // the cell's split direction (SWtoNE vs SEtoNW). const float CellSize = 24f; - const int VerticesPerSide = 9; - float fx = Math.Clamp(worldX / CellSize, 0f, VerticesPerSide - 1); - float fy = Math.Clamp(worldY / CellSize, 0f, VerticesPerSide - 1); - int x0 = (int)MathF.Floor(fx); - int y0 = (int)MathF.Floor(fy); - int x1 = Math.Min(x0 + 1, VerticesPerSide - 1); - int y1 = Math.Min(y0 + 1, VerticesPerSide - 1); - float tx = fx - x0; - float ty = fy - y0; + uint cellX = (uint)(worldX / CellSize); + uint cellY = (uint)(worldY / CellSize); + if (cellX >= 8) cellX = 7; + if (cellY >= 8) cellY = 7; - // Heightmap is packed x-major (Height[x*9+y]) matching LandblockMesh. - float h00 = heightTable[block.Height[x0 * 9 + y0]]; - float h10 = heightTable[block.Height[x1 * 9 + y0]]; - float h01 = heightTable[block.Height[x0 * 9 + y1]]; - float h11 = heightTable[block.Height[x1 * 9 + y1]]; - float hx0 = h00 * (1 - tx) + h10 * tx; - float hx1 = h01 * (1 - tx) + h11 * tx; - return hx0 * (1 - ty) + hx1 * ty; + uint landblockX = (block.Id >> 24) & 0xFFu; + uint landblockY = (block.Id >> 16) & 0xFFu; + var splitDirection = AcDream.Core.Terrain.TerrainBlending.CalculateSplitDirection( + landblockX, cellX, landblockY, cellY); + + // 4 cell corners (heightmap x-major: Height[x*9 + y]) + float h0 = heightTable[block.Height[cellX * 9 + cellY]]; // BL + float h1 = heightTable[block.Height[(cellX + 1) * 9 + cellY]]; // BR + float h2 = heightTable[block.Height[(cellX + 1) * 9 + (cellY + 1)]]; // TR + float h3 = heightTable[block.Height[cellX * 9 + (cellY + 1)]]; // TL + + float lx = worldX - cellX * CellSize; + float ly = worldY - cellY * CellSize; + float s = lx / CellSize; + float t = ly / CellSize; + + if (splitDirection == AcDream.Core.Terrain.CellSplitDirection.SWtoNE) + { + if (s + t <= 1f) + { + return h0 * (1f - s - t) + h1 * s + h3 * t; + } + else + { + float u = s + t - 1f; + float v = 1f - s; + float w = 1f - u - v; + return h1 * w + h2 * u + h3 * v; + } + } + else // SEtoNW + { + if (s >= t) + { + return h0 * (1f - s) + h1 * (s - t) + h2 * t; + } + else + { + return h0 * (1f - t) + h2 * s + h3 * (t - s); + } + } } /// @@ -1234,13 +1472,11 @@ public sealed class GameWindow : IDisposable (lb.LandblockId & 0xFFFF0000u) | 0xFFFEu); if (lbInfo is not null) { + // Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are + // static scenery placeholders themselves (rocks, tree clusters) that + // retail does NOT use to suppress scenery generation. Including them + // here over-suppressed scenery in town landblocks. buildingCells = new HashSet(); - foreach (var stab in lbInfo.Objects) - { - int cx = Math.Clamp((int)(stab.Frame.Origin.X / 24f), 0, 8); - int cy = Math.Clamp((int)(stab.Frame.Origin.Y / 24f), 0, 8); - buildingCells.Add(cx * 9 + cy); - } foreach (var bldg in lbInfo.Buildings) { int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8); @@ -1258,11 +1494,14 @@ public sealed class GameWindow : IDisposable (lbY - _liveCenterY) * 192f, 0f); - // Per-landblock id namespace: 0x80000000 | (lbId & 0x00FFFF00) | local_index. - // The landblock coord occupies bits 16-23 (X) and 8-15 (Y) — both fit in the - // 0x00FFFF00 mask. Local index uses bits 0-7 (256 slots per landblock), which - // is enough because SceneryGenerator caps at ~200 spawns per block in practice. - uint sceneryIdBase = 0x80000000u | (lb.LandblockId & 0x00FFFF00u); + // Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF + // where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23). + // Both must go into our ID so landblocks don't collide. + // Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II. + // 256 slots per landblock is enough (SceneryGenerator caps ~200). + uint lbXByte = (lb.LandblockId >> 24) & 0xFFu; + uint lbYByte = (lb.LandblockId >> 16) & 0xFFu; + uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8); uint localIndex = 0; foreach (var spawn in spawns) @@ -1305,18 +1544,33 @@ public sealed class GameWindow : IDisposable if (meshRefs.Count == 0) continue; - // Sample terrain Z at (localX, localY) to lift scenery onto the ground. + // Sample terrain Z at (localX, localY) to lift scenery onto the + // ground. Add BaseLoc.Z from the scenery ObjectDesc (passed in as + // spawn.LocalPosition.Z) so meshes that specify a vertical offset + // from the ground (e.g., flowers at -0.1m, roots below terrain) + // settle properly. float localX = spawn.LocalPosition.X; float localY = spawn.LocalPosition.Y; - float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); + // Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ) + // — it uses the same AC2D render split-direction formula the + // TerrainChunkRenderer uses for the visible terrain mesh. This + // guarantees trees are placed on the SAME Z height the player + // walks on. If physics hasn't registered this landblock yet, + // fall back to the local bilinear sample. + var worldPx = localX + lbOffset.X; + var worldPy = localY + lbOffset.Y; + float groundZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy) + ?? SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); + float finalZ = groundZ + spawn.LocalPosition.Z; var hydrated = new AcDream.Core.World.WorldEntity { Id = sceneryIdBase + localIndex++, SourceGfxObjOrSetupId = spawn.ObjectId, - Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset, + Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset, Rotation = spawn.Rotation, MeshRefs = meshRefs, + Scale = spawn.Scale, }; result.Add(hydrated); } @@ -1765,8 +2019,22 @@ public sealed class GameWindow : IDisposable // 1. GfxObj: use the BSP root bounding sphere radius if available. // 2. Setup: use Setup.Radius (the capsule radius) if available. // 3. Fallback: 1.0m (conservative default for trees / small objects). + int lbBspCount = 0, lbCylCount = 0, lbNoneCount = 0; + int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0; foreach (var entity in lb.Entities) { + int entityBsp = 0, entityCyl = 0; + // Treat both procedural scenery (0x80000000+) AND LandBlockInfo + // stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities + // that should use visual-mesh-AABB collision. Stabs include landscape + // trees placed by Turbine (not procedural scenery) that otherwise + // have no collision shape registered. + uint _srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u; + bool _isOutdoorMesh = ((entity.Id & 0x80000000u) != 0) // scenery + || ((entity.Id < 0x40000000u) // stab + && (_srcPrefix == 0x01000000u || _srcPrefix == 0x02000000u)); + bool _isScenery = _isOutdoorMesh; + if (_isScenery) scTried++; // Register EACH physics-enabled part so multi-part Setups // (buildings, trees) have all their collision geometry registered. // Each part gets its own ShadowEntry with its world-space transform. @@ -1782,70 +2050,334 @@ public sealed class GameWindow : IDisposable // Compute the part's world-space position from its transform. var partWorld = meshRef.PartTransform * entityRoot; - var partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43); - // Extract rotation from the world matrix. + // Decompose to extract scale (scenery objects have it baked + // into PartTransform), rotation, and translation. + System.Numerics.Vector3 partScale3; System.Numerics.Quaternion partRot; + System.Numerics.Vector3 partPos; if (System.Numerics.Matrix4x4.Decompose(partWorld, - out _, out partRot, out _)) + out partScale3, out partRot, out partPos)) { /* decompose succeeded */ } else + { + partScale3 = System.Numerics.Vector3.One; partRot = entity.Rotation; + partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43); + } - float partRadius = partCached.BoundingSphere?.Radius ?? 1f; + // Use uniform scale (X component) — AC objects are uniformly scaled. + float partScale = partScale3.X; + if (partScale <= 0f) partScale = 1f; + + // Local bounding sphere radius × world scale = world-space radius + // for the broad phase. The BSPQuery will also use `partScale` to + // transform player spheres into the unscaled BSP coordinate space. + float localRadius = partCached.BoundingSphere?.Radius ?? 1f; + float worldRadius = localRadius * partScale; // Use a unique sub-ID per part: entity.Id * 256 + partIndex. uint partId = entity.Id * 256u + partIndex; _physicsEngine.ShadowObjects.Register( partId, meshRef.GfxObjId, - partPos, partRot, partRadius, - origin.X, origin.Y, lb.LandblockId); + partPos, partRot, worldRadius, + origin.X, origin.Y, lb.LandblockId, + AcDream.Core.Physics.ShadowCollisionType.BSP, 0f, + partScale); + entityBsp++; partIndex++; } - // ALWAYS register CylSphere when the Setup has one, even if BSP parts - // were also registered. Retail tests both: CylSphere as the broad - // collision volume (trunk) plus BSP parts for precise polygon collision. - // The CylSphere catches the base/trunk that BSP parts might miss. + // Register collision shapes from the Setup (if this entity has one). + // Retail uses CylSpheres for trunks/pillars, Spheres for blob-shaped + // collision volumes. We register both as "cylinder" shadow entries + // because our collision system only has BSP and Cylinder types; a + // Sphere is handled as a short cylinder. + // + // SCALE + ROTATION handling: + // - Radius, Height, and the local Origin offset are ALL scaled by + // entity.Scale so they match the visually-scaled mesh. + // - The Origin offset is ROTATED by entity.Rotation so rotated + // scenery has its collision cylinder in the correct world spot. + // + // Keying: + // entity.Id → the primary CylSphere (if any) + // entity.Id + K*0x10000000u → additional CylSpheres/Spheres + // This ensures uniqueness per shape so ShadowObjectRegistry doesn't + // clobber entries via Deregister. { var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); - if (setup is not null && setup.CylSpheres.Count > 0) + if (setup is not null) { - var cyl = setup.CylSpheres[0]; - // Use the LARGER of CylSphere.Radius and Setup.Radius. - // Setup.Radius is the overall bounding radius of the object. - float cylRadius = MathF.Max(cyl.Radius, setup.Radius); - if (cylRadius <= 0) cylRadius = 1f; - float cylHeight = cyl.Height > 0 ? cyl.Height : setup.Height; - if (cylHeight <= 0) cylHeight = cylRadius * 4f; + float entScale = entity.Scale > 0f ? entity.Scale : 1f; + uint shapeIndex = 0; - if (cylRadius > 0) + // Register every CylSphere the Setup defines. + for (int ci = 0; ci < setup.CylSpheres.Count; ci++) { + var cyl = setup.CylSpheres[ci]; + float cylRadius = cyl.Radius * entScale; + float baseHeight = cyl.Height > 0 ? cyl.Height : cyl.Radius * 4f; + float cylHeight = baseHeight * entScale; + if (cylRadius <= 0f) continue; + + // Rotate the local origin offset by entity rotation, + // then scale it before adding to entity.Position. + var localOffset = new System.Numerics.Vector3( + cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale; + var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation); + + uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u; _physicsEngine.ShadowObjects.Register( - entity.Id, entity.SourceGfxObjOrSetupId, - entity.Position + new System.Numerics.Vector3(cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z), + shapeId, entity.SourceGfxObjOrSetupId, + entity.Position + worldOffset, entity.Rotation, cylRadius, origin.X, origin.Y, lb.LandblockId, AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight); + entityCyl++; } - } - else if (setup is not null && setup.Spheres.Count > 0 && partIndex == 0) - { - // Fallback: use bounding sphere only if no BSP and no CylSphere. - var sph = setup.Spheres[0]; - if (sph.Radius > 0) + + // Register every Sphere as a short cylinder when no + // CylSphere claimed the object. + if (setup.CylSpheres.Count == 0) { + for (int si = 0; si < setup.Spheres.Count; si++) + { + var sph = setup.Spheres[si]; + if (sph.Radius <= 0f) continue; + + float sphRadius = sph.Radius * entScale; + float sphHeight = sphRadius * 2f; + + // Rotate + scale the local origin, then offset the + // cylinder base down by the scaled radius so the + // short cylinder is centered on the sphere. + var localOffset = new System.Numerics.Vector3( + sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale; + var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation); + worldOffset.Z -= sphRadius; + + uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u; + _physicsEngine.ShadowObjects.Register( + shapeId, entity.SourceGfxObjOrSetupId, + entity.Position + worldOffset, + entity.Rotation, sphRadius, + origin.X, origin.Y, lb.LandblockId, + AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight); + entityCyl++; + } + } + + // Setup.Radius fallback: the Setup has NO CylSpheres and NO + // Spheres but has a positive Radius/Height. Use the overall + // bounding cylinder scaled by entity.Scale. + if (setup.CylSpheres.Count == 0 && setup.Spheres.Count == 0 + && setup.Radius > 0f && entityBsp == 0) + { + float fr = setup.Radius * entScale; + float fh = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale; + uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u; _physicsEngine.ShadowObjects.Register( - entity.Id, entity.SourceGfxObjOrSetupId, - entity.Position + new System.Numerics.Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z), - entity.Rotation, sph.Radius, + shapeId, entity.SourceGfxObjOrSetupId, + entity.Position, entity.Rotation, fr, origin.X, origin.Y, lb.LandblockId, - AcDream.Core.Physics.ShadowCollisionType.Cylinder, 0f); + AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh); + entityCyl++; } } } + + // VISUAL mesh-bounds collision: for SCENERY entities (IDs with + // 0x80000000 bit set, indicating procedurally-placed scenery), + // ALWAYS compute a cylinder from the world-space mesh AABB. + // This catches trees whose BSP is only on the canopy (player + // walks under) AND corrects CylSphere positioning issues caused + // by mesh files having vertices offset from the mesh origin. + // + // For stabs (low IDs) and live entities, keep the existing Setup + // CylSphere / BSP registrations — those are placed with precise + // frame data and don't have the scenery offset issue. + if ((_isOutdoorMesh || (entityBsp == 0 && entityCyl == 0)) && entity.MeshRefs.Count > 0) + { + float entScale = entity.Scale > 0f ? entity.Scale : 1f; + bool haveBounds = false; + var worldMin = new System.Numerics.Vector3(float.MaxValue); + var worldMax = new System.Numerics.Vector3(float.MinValue); + + var entRoot = + System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) * + System.Numerics.Matrix4x4.CreateTranslation(entity.Position); + + // First pass: compute overall vertical extent in world Z. + float overallMinZ = float.MaxValue; + float overallMaxZ = float.MinValue; + foreach (var mr in entity.MeshRefs) + { + var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); + if (vb is null || vb.Radius <= 0f) continue; + var partWorld = mr.PartTransform * entRoot; + for (int bi = 0; bi < 8; bi++) + { + var corner = new System.Numerics.Vector3( + (bi & 1) != 0 ? vb.Max.X : vb.Min.X, + (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y, + (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z); + var w = System.Numerics.Vector3.Transform(corner, partWorld); + if (w.Z < overallMinZ) overallMinZ = w.Z; + if (w.Z > overallMaxZ) overallMaxZ = w.Z; + } + } + + // Second pass: use TRUNK HEIGHT ONLY (bottom 25% of the mesh + // or first 2.5m, whichever is smaller) for horizontal radius. + // This gives us the trunk thickness — not the canopy spread. + // The Z extent still uses the full mesh (so tall trees have + // tall collision cylinders). + float trunkHeight = MathF.Min(2.5f, (overallMaxZ - overallMinZ) * 0.25f); + if (trunkHeight < 0.5f) trunkHeight = 0.5f; + float trunkTopZ = overallMinZ + trunkHeight; + + foreach (var mr in entity.MeshRefs) + { + var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); + if (vb is null || vb.Radius <= 0f) continue; + + var partWorld = mr.PartTransform * entRoot; + // Only accumulate horizontal extents from corners within the + // trunk height range. Pass the full vertical extent through. + for (int bi = 0; bi < 8; bi++) + { + var corner = new System.Numerics.Vector3( + (bi & 1) != 0 ? vb.Max.X : vb.Min.X, + (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y, + (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z); + var w = System.Numerics.Vector3.Transform(corner, partWorld); + + // Always track vertical extent + if (w.Z < worldMin.Z) worldMin.Z = w.Z; + if (w.Z > worldMax.Z) worldMax.Z = w.Z; + + // Only track horizontal extent for TRUNK-level vertices + if (w.Z <= trunkTopZ) + { + if (w.X < worldMin.X) worldMin.X = w.X; + if (w.Y < worldMin.Y) worldMin.Y = w.Y; + if (w.X > worldMax.X) worldMax.X = w.X; + if (w.Y > worldMax.Y) worldMax.Y = w.Y; + haveBounds = true; + } + } + } + + if (haveBounds) + { + if (_isScenery) scHaveBounds++; + + // RADIUS: prefer the Setup's CylSphere radius (the retail + // trunk radius — thin, matches tree trunks). Fall back to + // Setup.Radius or mesh AABB if CylSphere is unavailable. + // Always scale by entity.Scale. + float entScaleLocal = entity.Scale > 0f ? entity.Scale : 1f; + float cylRadius = -1f; + float cylHeight; + + var setupInfo = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (setupInfo is not null) + { + if (setupInfo.CylSpheres.Count > 0 && setupInfo.CylSpheres[0].Radius > 0f) + { + // Retail CylSphere — the definitive trunk collision. + cylRadius = setupInfo.CylSpheres[0].Radius * entScaleLocal; + } + else if (setupInfo.Radius > 0f) + { + // Setup.Radius — the overall bounding radius. For + // thin trunks this might be the full tree radius + // (canopy included) but often it's the trunk. + cylRadius = setupInfo.Radius * entScaleLocal; + } + } + + // Fall back to mesh AABB trunk-level radius if no Setup data. + if (cylRadius < 0f) + { + float halfX = (worldMax.X - worldMin.X) * 0.5f; + float halfY = (worldMax.Y - worldMin.Y) * 0.5f; + cylRadius = MathF.Max(halfX, halfY); + } + + // Clamp: retail AC trunks are 0.3-1.0m. Bigger radii (from + // the AABB fallback for canopy-heavy meshes) are clearly + // wrong; clamp to a reasonable tree-trunk maximum. + if (cylRadius < 0.3f) cylRadius = 0.3f; + if (cylRadius > 1.5f) cylRadius = 1.5f; + + // HEIGHT: use Setup.Height scaled, or mesh AABB vertical extent. + if (setupInfo is not null && setupInfo.Height > 0f) + cylHeight = setupInfo.Height * entScaleLocal; + else + cylHeight = MathF.Max(worldMax.Z - entity.Position.Z, cylRadius); + + // CENTER: entity.Position (the rendered root). + var baseCenter = new System.Numerics.Vector3( + entity.Position.X, entity.Position.Y, entity.Position.Z); + + _physicsEngine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + baseCenter, entity.Rotation, cylRadius, + origin.X, origin.Y, lb.LandblockId, + AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight); + entityCyl++; + if (_isScenery) scRegistered++; + } + else if (_isScenery) scNoBounds++; + } + + // Tally per-entity collision presence (debug counter — optional). + if (entityBsp > 0) lbBspCount++; + if (entityCyl > 0) lbCylCount++; + if (entityBsp == 0 && entityCyl == 0) + { + // Only count as "none" if it's an OUTDOOR entity (0x01/0x02 source). + // EnvCell entities (src = cell ID like 0xAABBxxxx) use BSP collision + // via CellPhysics and don't need cylinder registration. + uint srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u; + if (srcPrefix == 0x01000000u || srcPrefix == 0x02000000u) + lbNoneCount++; + } } + if (scTried > 0) + Console.WriteLine( + $"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " + + $"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})"); + + // Find scenery WITHOUT any cached visual bounds at all + int sceneryNoCache = 0; + var sampleMissing = new List(); + foreach (var entity in lb.Entities) + { + if ((entity.Id & 0x80000000u) == 0) continue; // not scenery + bool anyHaveBounds = false; + foreach (var mr in entity.MeshRefs) + { + var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); + if (vb is not null && vb.Radius > 0f) { anyHaveBounds = true; break; } + } + if (!anyHaveBounds) + { + sceneryNoCache++; + if (sampleMissing.Count < 3) + sampleMissing.Add(entity.SourceGfxObjOrSetupId); + } + } + if (sceneryNoCache > 0) + { + string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}")); + Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}"); + } + // Register each stab as a plugin snapshot so the plugin host has // visibility into the streaming world state. @@ -2145,6 +2677,81 @@ public sealed class GameWindow : IDisposable neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds); + // Debug: draw collision shapes as wireframe cylinders around the + // player so we can visually verify alignment with scenery meshes. + if (_debugCollisionVisible && _debugLines is not null) + { + _debugLines.Begin(); + + // Pick the center for the debug radius. Prefer player + // position in player mode, otherwise use camPos. + System.Numerics.Vector3 center; + if (_playerMode && _playerController is not null) + center = _playerController.Position; + else + center = camPos; + + // Draw ALL registered shadow objects regardless of distance — + // if it has collision, it gets a wireframe. This lets the user + // see exactly what's in the collision registry at any moment. + int drawn = 0; + foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug()) + { + var dx = obj.Position.X - center.X; + var dy = obj.Position.Y - center.Y; + + if (obj.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder) + { + float h = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 2f; + _debugLines.AddCylinder( + obj.Position, obj.Radius, h, + new System.Numerics.Vector3(0f, 1f, 0f)); // green cylinders + } + else + { + // BSP: show a bounding sphere as a cylinder for visibility + _debugLines.AddCylinder( + obj.Position - new System.Numerics.Vector3(0, 0, obj.Radius), + obj.Radius, obj.Radius * 2f, + new System.Numerics.Vector3(1f, 0.5f, 0f)); // orange BSP + } + drawn++; + } + + // Draw the player's collision sphere as a red cylinder (0.48m radius, 1.8m tall) + if (_playerMode && _playerController is not null) + { + var pp = _playerController.Position; + _debugLines.AddCylinder( + new System.Numerics.Vector3(pp.X, pp.Y, pp.Z - 0.0f), + 0.48f, 1.8f, + new System.Numerics.Vector3(1f, 0f, 0f)); // red player + } + + if (_debugDrawLogOnce < 5 && _playerMode && _playerController is not null) + { + var pp = _playerController.Position; + Console.WriteLine( + $"debug frame {_debugDrawLogOnce}: player=({pp.X:F1},{pp.Y:F1},{pp.Z:F1}) drew={drawn} " + + $"totalReg={_physicsEngine.ShadowObjects.TotalRegistered}"); + // Sample 3 nearest shadow objects + int logged = 0; + foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) + { + var dx = o.Position.X - pp.X; + var dy = o.Position.Y - pp.Y; + float dh = MathF.Sqrt(dx * dx + dy * dy); + if (dh < 10f) + { + Console.WriteLine($" near id=0x{o.EntityId:X8} type={o.CollisionType} pos=({o.Position.X:F1},{o.Position.Y:F1},{o.Position.Z:F1}) r={o.Radius:F2} h={o.CylHeight:F2} dh={dh:F2}"); + if (++logged >= 5) break; + } + } + _debugDrawLogOnce++; + } + _debugLines.Flush(camera.View, camera.Projection); + } + // Count visible vs total for the perf overlay. foreach (var entry in _worldState.LandblockEntries) { @@ -2152,6 +2759,96 @@ public sealed class GameWindow : IDisposable if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax)) visibleLandblocks++; } + + // ── Debug HUD overlay ──────────────────────────────────────────── + // Build a per-frame snapshot of state we want to show and hand it + // to the overlay. Drawn after all 3D passes so it sits on top. + if (_debugOverlay is not null && _textRenderer is not null && _debugFont is not null) + { + System.Numerics.Vector3 playerPos; + float headingDeg; + uint cellId; + bool onGround; + float vVel; + if (_playerMode && _playerController is not null) + { + playerPos = _playerController.Position; + // Yaw in math convention: 0 = +X east, PI/2 = +Y north. + // Convert to degrees in [0, 360). + headingDeg = _playerController.Yaw * (180f / MathF.PI); + headingDeg %= 360f; + if (headingDeg < 0f) headingDeg += 360f; + cellId = _playerController.CellId; + onGround = !_playerController.IsAirborne; + vVel = _playerController.VerticalVelocity; + } + else + { + playerPos = camPos; + var camFwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33); + headingDeg = MathF.Atan2(camFwd.Y, camFwd.X) * (180f / MathF.PI); + if (headingDeg < 0f) headingDeg += 360f; + cellId = 0u; + onGround = false; + vVel = 0f; + } + + // Nearest shadow object — surface-to-surface distance in XY + // (subtract player radius + obj radius). Negative == penetrating. + const float playerRadius = 0.48f; + float bestDist = float.PositiveInfinity; + string bestLabel = "-"; + foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug()) + { + float dx = obj.Position.X - playerPos.X; + float dy = obj.Position.Y - playerPos.Y; + float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius; + if (d < bestDist) + { + bestDist = d; + bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}"; + } + } + bool colliding = bestDist < 0.05f; + if (bestDist < 0f) bestDist = 0f; + + // Select the active-mode sensitivity to display. + float activeSens; + if (_playerMode && _cameraController?.IsChaseMode == true) + activeSens = _sensChase; + else if (_cameraController?.IsFlyMode == true) + activeSens = _sensFly; + else + activeSens = _sensOrbit; + + var snapshot = new DebugOverlay.Snapshot( + Fps: (float)_lastFps, + FrameTimeMs: (float)_lastFrameMs, + PlayerPos: playerPos, + HeadingDeg: headingDeg, + CellId: cellId, + OnGround: onGround, + InPlayerMode: _playerMode, + InFlyMode: _cameraController?.IsFlyMode ?? false, + VerticalVelocity: vVel, + EntityCount: _worldState.Entities.Count, + AnimatedCount: _animatedEntities.Count, + LandblocksVisible: visibleLandblocks, + LandblocksTotal: totalLandblocks, + ShadowObjectCount: _physicsEngine.ShadowObjects.TotalRegistered, + NearestObjDist: bestDist, + NearestObjLabel: bestLabel, + Colliding: colliding, + DebugWireframes: _debugCollisionVisible, + StreamingRadius: _streamingRadius, + MouseSensitivity: activeSens, + ChaseDistance: _chaseCamera?.Distance ?? 0f, + RmbOrbit: _rmbHeld); + + _debugOverlay.Update((float)deltaSeconds); + var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y); + _debugOverlay.Draw(snapshot, size); + } } // Update the window title with performance stats every ~0.5s. @@ -2167,6 +2864,8 @@ public sealed class GameWindow : IDisposable _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + $"lb {visibleLandblocks}/{totalLandblocks} visible | " + $"ent {entityCount} | anim {animatedCount}"; + _lastFps = fps; + _lastFrameMs = avgFrameTime; _perfAccum = 0; _perfFrameCount = 0; } @@ -2387,6 +3086,9 @@ public sealed class GameWindow : IDisposable _meshShader?.Dispose(); _terrain?.Dispose(); _shader?.Dispose(); + _debugLines?.Dispose(); + _textRenderer?.Dispose(); + _debugFont?.Dispose(); _dats?.Dispose(); _input?.Dispose(); _gl?.Dispose(); diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs index e2deded..bcad4f0 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -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); } diff --git a/src/AcDream.App/Rendering/Shaders/debug_line.frag b/src/AcDream.App/Rendering/Shaders/debug_line.frag new file mode 100644 index 0000000..1667ee6 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/debug_line.frag @@ -0,0 +1,7 @@ +#version 430 core +in vec3 vColor; +out vec4 FragColor; + +void main() { + FragColor = vec4(vColor, 1.0); +} diff --git a/src/AcDream.App/Rendering/Shaders/debug_line.vert b/src/AcDream.App/Rendering/Shaders/debug_line.vert new file mode 100644 index 0000000..f634013 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/debug_line.vert @@ -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); +} diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.frag b/src/AcDream.App/Rendering/Shaders/ui_text.frag new file mode 100644 index 0000000..7740ea1 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/ui_text.frag @@ -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; +} diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.vert b/src/AcDream.App/Rendering/Shaders/ui_text.vert new file mode 100644 index 0000000..0cc6c93 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/ui_text.vert @@ -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; +} diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs new file mode 100644 index 0000000..ad04da1 --- /dev/null +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -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; + +/// +/// 2D batched quad renderer for text + solid rectangles. Coordinates are in +/// screen pixels with origin top-left, +X right, +Y down. Call +/// at the start of a HUD pass, queue geometry via +/// / , then . +/// +/// 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. +/// +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 _textBuf = new(8192); + private readonly List _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); + } + + /// Begin a HUD pass. Call once per frame before any Draw* calls. + public void Begin(Vector2 screenSize) + { + _screenSize = screenSize; + _textBuf.Clear(); + _rectBuf.Clear(); + _textVerts = 0; + _rectVerts = 0; + } + + /// Draw a filled rectangle in screen pixel space. + 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; + } + + /// Draw a 1-pixel-thick outline rect. + 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); + } + + /// + /// 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. + /// + 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 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); + } + + /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. + 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 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(); + } +} diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index fceddb6..dfef08b 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1453,19 +1453,46 @@ public static class BSPQuery } // ---------------------------------------------------------------- - // Path 5: Contact — sphere_intersects_poly + step_sphere_up / slide - // ACE transforms collision normal from local→global before step_up/slide + // Path 5: Contact — sphere_intersects_poly + wall-slide + // ACE retail uses StepSphereUp here, deferring to a retry loop that + // executes the step-up motion. We haven't ported that execution, so + // we apply the same wall-slide response as Path 6 — this at least + // gives correct blocking + sliding behavior for walls, buildings, + // and tree trunks while the player is on the ground. // ---------------------------------------------------------------- if (obj.State.HasFlag(ObjectInfoState.Contact)) { ResolvedPolygon? hitPoly0 = null; Vector3 contact0 = Vector3.Zero; - if (SphereIntersectsPolyInternal(root, resolved, sphere0, movement, - ref hitPoly0, ref contact0)) + bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement, + ref hitPoly0, ref contact0); + + if (hit0 || hitPoly0 is not null) { - var worldNormal = L2W(hitPoly0!.Plane.Normal); - return StepSphereUp(transition, worldNormal); + // Wall-slide response (same as Path 6 below). + var localNormal = hitPoly0!.Plane.Normal; + var localMovement = sphere0.Center - localCurrCenter; + + float movementIntoWall = Vector3.Dot(localMovement, localNormal); + Vector3 projectedMovement = localMovement - localNormal * movementIntoWall; + + Vector3 slidPos = localCurrCenter + projectedMovement; + float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D; + float minDist = sphere0.Radius + 0.01f; + if (slidDist < minDist) + { + slidPos += localNormal * (minDist - slidDist); + } + + Vector3 localDelta = slidPos - sphere0.Center; + Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale; + path.AddOffsetToCheckPos(worldDelta); + + var worldNormal = L2W(localNormal); + collisions.SetCollisionNormal(worldNormal); + collisions.SetSlidingNormal(worldNormal); + return TransitionState.Slid; } if (sphere1 is not null) @@ -1473,17 +1500,34 @@ public static class BSPQuery ResolvedPolygon? hitPoly1 = null; Vector3 contact1 = Vector3.Zero; - if (SphereIntersectsPolyInternal(root, resolved, sphere1, movement, - ref hitPoly1, ref contact1)) - { - var worldNormal = L2W(hitPoly1!.Plane.Normal); - return SlideSphere(transition, worldNormal); - } + bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement, + ref hitPoly1, ref contact1); - if (hitPoly1 is not null) - return NegPolyHitDispatch(path, hitPoly1, false, localToWorld); - if (hitPoly0 is not null) - return NegPolyHitDispatch(path, hitPoly0, true, localToWorld); + if (hit1 || hitPoly1 is not null) + { + var localNormal = hitPoly1!.Plane.Normal; + var localMovement = sphere1.Center - localCurrCenter; + + float movementIntoWall = Vector3.Dot(localMovement, localNormal); + Vector3 projectedMovement = localMovement - localNormal * movementIntoWall; + + Vector3 slidPos = localCurrCenter + projectedMovement; + float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D; + float minDist = sphere1.Radius + 0.01f; + if (slidDist < minDist) + { + slidPos += localNormal * (minDist - slidDist); + } + + Vector3 localDelta = slidPos - sphere1.Center; + Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale; + path.AddOffsetToCheckPos(worldDelta); + + var worldNormal = L2W(localNormal); + collisions.SetCollisionNormal(worldNormal); + collisions.SetSlidingNormal(worldNormal); + return TransitionState.Slid; + } } return TransitionState.OK; @@ -1509,11 +1553,50 @@ public static class BSPQuery hitPoly0!, contact0, scale, localToWorld); } - var worldNormal = L2W(hitPoly0!.Plane.Normal); + // ─── Wall-slide response ───────────────────────────────── + // Instead of just pushing the sphere out of penetration + // (which undoes the whole step), compute the wall-slide + // position: where the sphere WOULD be if the movement had + // been projected along the wall tangent. + // + // In local space: + // curr = localCurrCenter + // target = sphere0.Center + // movement = target - curr + // normal = polygon plane normal (outward) + // projectedMovement = movement - (movement · normal) * normal + // slidPos = curr + projectedMovement + // + // Then ensure slidPos is outside the plane by at least radius+eps. + var localNormal = hitPoly0!.Plane.Normal; + var localMovement = sphere0.Center - localCurrCenter; + + // Project movement along wall tangent + float movementIntoWall = Vector3.Dot(localMovement, localNormal); + Vector3 projectedMovement = localMovement - localNormal * movementIntoWall; + + // Slid position in local space + Vector3 slidPos = localCurrCenter + projectedMovement; + + // Ensure slid position is OUTSIDE the plane by radius + epsilon + float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly0.Plane.D; + float minDist = sphere0.Radius + 0.01f; + if (slidDist < minDist) + { + slidPos += localNormal * (minDist - slidDist); + } + + // Delta from current CheckPos sphere center to slid position (local) + Vector3 localDelta = slidPos - sphere0.Center; + // Transform to world and apply + Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale; + path.AddOffsetToCheckPos(worldDelta); + + var worldNormal = L2W(localNormal); path.WalkableAllowance = PhysicsGlobals.LandingZ; - path.Collide = true; collisions.SetCollisionNormal(worldNormal); - return TransitionState.Adjusted; + collisions.SetSlidingNormal(worldNormal); + return TransitionState.Slid; } if (sphere1 is not null) @@ -1526,9 +1609,29 @@ public static class BSPQuery if (hit1 || hitPoly1 is not null) { - var worldNormal = L2W(hitPoly1!.Plane.Normal); + // Head sphere hit: apply the same wall-slide as above. + var localNormal = hitPoly1!.Plane.Normal; + var localMovement = sphere1.Center - localCurrCenter; + + float movementIntoWall = Vector3.Dot(localMovement, localNormal); + Vector3 projectedMovement = localMovement - localNormal * movementIntoWall; + + Vector3 slidPos = localCurrCenter + projectedMovement; + float slidDist = Vector3.Dot(slidPos, localNormal) + hitPoly1.Plane.D; + float minDist = sphere1.Radius + 0.01f; + if (slidDist < minDist) + { + slidPos += localNormal * (minDist - slidDist); + } + + Vector3 localDelta = slidPos - sphere1.Center; + Vector3 worldDelta = Vector3.Transform(localDelta, localToWorld) * scale; + path.AddOffsetToCheckPos(worldDelta); + + var worldNormal = L2W(localNormal); collisions.SetCollisionNormal(worldNormal); - return TransitionState.Collided; + collisions.SetSlidingNormal(worldNormal); + return TransitionState.Slid; } } } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 5efeab6..225bf3f 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -16,18 +16,32 @@ namespace AcDream.Core.Physics; public sealed class PhysicsDataCache { private readonly ConcurrentDictionary _gfxObj = new(); + private readonly ConcurrentDictionary _visualBounds = new(); private readonly ConcurrentDictionary _setup = new(); private readonly ConcurrentDictionary _cellStruct = new(); /// - /// 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. /// 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 }; } + /// + /// Get the cached visual AABB for a GfxObj, or null if not cached. + /// + public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) => + _visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null; + + /// + /// 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. + /// + 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, + }; + } + /// /// 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; } +/// +/// 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. +/// +public sealed class GfxObjVisualBounds +{ + /// Local-space minimum corner of the mesh AABB. + public required Vector3 Min { get; init; } + /// Local-space maximum corner of the mesh AABB. + public required Vector3 Max { get; init; } + /// Center of the local-space AABB. + public required Vector3 Center { get; init; } + /// Local-space radius (diagonal half-length) — loose bound. + public required float Radius { get; init; } + /// Local-space half-extents ((Max - Min) * 0.5). + public required Vector3 HalfExtents { get; init; } +} + /// /// 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 diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index b9fb084..e7c2950 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -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 lbPrefix = landblockId & 0xFFFF0000u; @@ -166,6 +169,24 @@ public sealed class ShadowObjectRegistry } public int TotalRegistered => _entityToCells.Count; + + /// + /// 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. + /// + public IEnumerable AllEntriesForDebug() + { + var seen = new HashSet(); + foreach (var kvp in _cells) + { + foreach (var entry in kvp.Value) + { + if (seen.Add(entry.EntityId)) + yield return entry; + } + } + } } /// @@ -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); diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 2b30b34..d4ac066 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -353,15 +353,13 @@ public sealed class Transition for (int i = 0; i < numSteps; i++) { - // Reset per-step collision state. - CollisionInfo.SlidingNormalValid = false; - CollisionInfo.ContactPlaneValid = false; - CollisionInfo.ContactPlaneIsWater = false; - - // Project the step offset through any existing contact / slide plane. + // Per ACE order: AdjustOffset FIRST (uses state from previous step), + // THEN clear the state. This lets the sliding/contact normals from + // the previous step's collision project the current step's offset. sp.GlobalOffset = AdjustOffset(offsetPerStep); - // Abort if adjusted offset is negligible (we're stuck against a wall). + // Abort if adjusted offset is negligible (stuck against a wall + // with no slide tangent available). if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq) return i != 0 && transitionState == TransitionState.OK; @@ -372,6 +370,12 @@ public sealed class Transition sp.CheckOrientation = Quaternion.Slerp(sp.BeginOrientation, sp.EndOrientation, delta); } + // Clear collision state AFTER AdjustOffset reads it. TransitionalInsert + // will set new state that the next step's AdjustOffset will consume. + CollisionInfo.SlidingNormalValid = false; + CollisionInfo.ContactPlaneValid = false; + CollisionInfo.ContactPlaneIsWater = false; + // Apply the offset, then check collisions. sp.AddOffsetToCheckPos(sp.GlobalOffset); @@ -391,108 +395,155 @@ public sealed class Transition // ----------------------------------------------------------------------- /// - /// 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. + /// + /// + /// 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. + /// + /// + /// + /// 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) + /// + /// + /// + /// 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. + /// /// - private TransitionState TransitionalInsert(int maxAttempts, PhysicsEngine engine) + private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine) { if (SpherePath.CheckCellId == 0) return TransitionState.OK; - if (maxAttempts <= 0) return TransitionState.Invalid; + if (numAttempts <= 0) return TransitionState.Invalid; var sp = SpherePath; var ci = CollisionInfo; var oi = ObjectInfo; - TransitionState transitState = TransitionState.OK; + TransitionState transitState; - for (int attempt = 0; attempt < maxAttempts; attempt++) + for (int attempt = 0; attempt < numAttempts; attempt++) { - // Phase 1: check collisions in the current cell. + // ── Phase 1: environment collision (terrain + indoor BSP) ─── transitState = FindEnvCollisions(engine); - switch (transitState) + if (transitState == TransitionState.Collided) + return TransitionState.Collided; + + if (transitState == TransitionState.Slid) { - case TransitionState.OK: - // Outdoor path: no neighboring cell enumeration needed for MVP. - break; - - case TransitionState.Collided: - return TransitionState.Collided; - - case TransitionState.Adjusted: - sp.NegPolyHit = false; - break; - - case TransitionState.Slid: - ci.ContactPlaneValid = false; - ci.ContactPlaneIsWater = false; - sp.NegPolyHit = false; - break; + // Env collision slid the sphere. Clear state and retry at + // the new CheckPos to see if we hit anything else. + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + sp.NegPolyHit = false; + continue; } - // Phase 1b: check object (static BSP) collisions when OK so far. - if (transitState == TransitionState.OK) + if (transitState == TransitionState.Adjusted) { - var objState = FindObjCollisions(engine); - if (objState == TransitionState.Slid) - { - transitState = TransitionState.Slid; - ci.ContactPlaneValid = false; - ci.ContactPlaneIsWater = false; - sp.NegPolyHit = false; - } - else if (objState == TransitionState.Collided) - { - return TransitionState.Collided; - } + // Env modified CheckPos. Retry at new position. + sp.NegPolyHit = false; + continue; } - // Phase 2: post-collision response. - if (transitState == TransitionState.OK) + // ── Phase 2: object (static BSP + cylinder) collision ─────── + // Env was OK — now test objects. + var objState = FindObjCollisions(engine); + + if (objState == TransitionState.Collided) + return TransitionState.Collided; + + if (objState == TransitionState.Slid) { - // Handle step-down when in contact but no ground plane found. - if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown - && sp.CheckCellId != 0 && oi.StepDown) + // Object collision applied a push-out and set sliding normal. + // Retry at the new CheckPos — we may have slid into another + // object, or need to re-verify env at the new position. + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + sp.NegPolyHit = false; + continue; + } + + if (objState == TransitionState.Adjusted) + { + // Object modified CheckPos (e.g. PerfectClip adjust_to_plane). + // Retry at the new position. + sp.NegPolyHit = false; + continue; + } + + // ── Phase 3: both env and objects returned OK ────────────── + // Handle Collide flag (BSP path 6 set it on a non-contact hit). + // ACE: if Collide is set, re-test as Placement to confirm position. + // Simplified: just clear it and accept. + if (sp.Collide) + { + sp.Collide = false; + } + + // Handle neg-poly hit (backward-facing polygon contact). + if (sp.NegPolyHit && !sp.StepDown && !sp.StepUp) + { + sp.NegPolyHit = false; + // ACE: dispatch to StepUp or SlideSphere based on NegStepUp flag. + // Simplified: accept current position. + } + + // Handle step-down when in contact but no ground plane found. + // This happens when the player is on a slope edge: they're marked + // as in contact with the ground, but the current CheckPos has no + // terrain contact (walked off an edge). Attempt a step-down to + // maintain ground contact. + if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown + && sp.CheckCellId != 0 && oi.StepDown) + { + float zVal = PhysicsGlobals.LandingZ; + float stepDownHeight = oi.StepDownHeight; + sp.WalkableAllowance = zVal; + sp.SaveCheckPos(); + + float radsum = sp.GlobalSphere[0].Radius * 2f; + + if (radsum >= stepDownHeight) { - float zVal = PhysicsGlobals.LandingZ; - float stepDownHeight = oi.StepDownHeight; - sp.WalkableAllowance = zVal; - sp.SaveCheckPos(); - - float radsum = sp.GlobalSphere[0].Radius * 2f; - - if (radsum >= stepDownHeight) + if (DoStepDown(stepDownHeight, zVal, engine)) { - if (DoStepDown(stepDownHeight, zVal, engine)) - { - sp.WalkableValid = false; - return TransitionState.OK; - } + sp.WalkableValid = false; + return TransitionState.OK; } - else - { - stepDownHeight *= 0.5f; - if (DoStepDown(stepDownHeight, zVal, engine) - || DoStepDown(stepDownHeight, zVal, engine)) - { - sp.WalkableValid = false; - return TransitionState.OK; - } - } - - // Step-down failed: stay at current position. - sp.RestoreCheckPos(); - return TransitionState.OK; } else { - return TransitionState.OK; + stepDownHeight *= 0.5f; + if (DoStepDown(stepDownHeight, zVal, engine) + || DoStepDown(stepDownHeight, zVal, engine)) + { + sp.WalkableValid = false; + return TransitionState.OK; + } } + + // Step-down failed: stay at current position. + sp.RestoreCheckPos(); + return TransitionState.OK; } + + return TransitionState.OK; } - return transitState; + // Exhausted retry attempts — return whatever the last iteration said. + // (Defaults to Slid in practice since that's the only case that retries.) + return TransitionState.Slid; } // ----------------------------------------------------------------------- @@ -662,6 +713,7 @@ public sealed class Transition // Reused per-call to avoid per-step allocation; safe because Transition // is single-threaded per movement resolve. private readonly List _nearbyObjs = new(); + private static int _debugQueryCount = 0; /// /// 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 } /// - /// 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. /// 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; } // ----------------------------------------------------------------------- diff --git a/src/AcDream.Core/World/SceneryGenerator.cs b/src/AcDream.Core/World/SceneryGenerator.cs index 2ff0dee..2dfcd77 100644 --- a/src/AcDream.Core/World/SceneryGenerator.cs +++ b/src/AcDream.Core/World/SceneryGenerator.cs @@ -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 /// public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; + /// + /// 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. + /// + private const float RoadHalfWidth = 5.0f; + + /// + /// 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. + /// + /// + /// 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). + /// + 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; + /// /// 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 diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs index 479f5cd..33a4b2c 100644 --- a/src/AcDream.Core/World/WorldEntity.cs +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -43,4 +43,16 @@ public sealed class WorldEntity /// Null for outdoor entities (stabs, scenery, live server spawns). /// public uint? ParentCellId { get; init; } + + /// + /// Uniform scale applied to this entity's mesh by the scenery pipeline. + /// For scenery objects this is spawn.Scale (typically 0.8–1.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. + /// + public float Scale { get; init; } = 1.0f; }