feat(ui): debug overlay + refined input controls
Adds the first on-screen HUD for the dev client plus today's mouse-control refinements. Also lands yesterday's scenery-alignment changes that were left uncommitted in the working tree. Overlay: - BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512 R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks) - TextRenderer batches 2D quads in screen-space with ortho projection; one shader + two draw calls (rect then text) for panel backgrounds under glyphs - DebugOverlay composes info / stats / compass / help panels on top of the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events - DebugLineRenderer and its shaders (carried over from the scenery work) are properly committed in this commit Controls: - Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to adjust the active mode multiplicatively (x1.2) - Hold RMB to free-orbit the chase camera around the player; release stays at the new angle (no snap-back) - Mouse-wheel zooms chase distance between 2m and 40m - Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from the default neutral angle Scenery alignment (carried from yesterday's session): - ShadowObjectRegistry AllEntriesForDebug + Scale field - SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc + set_heading rotation - BSPQuery dispatchers accept localToWorld so normals/offsets transform correctly per part - TransitionTypes.CylinderCollision rewritten with wall-slide + push-out - PhysicsDataCache caches visual-mesh AABB for scenery that lacks physics Setup bounds
This commit is contained in:
parent
6b4e7569a3
commit
ff325abd7b
20 changed files with 2734 additions and 268 deletions
169
memory/project_session_2026_04_16.md
Normal file
169
memory/project_session_2026_04_16.md
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Session 2026-04-16 — Scenery Alignment + Collision Registration
|
||||||
|
|
||||||
|
## Headline result
|
||||||
|
|
||||||
|
**Scenery alignment + collision finally in a working state.**
|
||||||
|
- All outdoor entities get collision cylinders
|
||||||
|
- Trees in roads mostly fixed
|
||||||
|
- Trees snapped to terrain (triangle-aware Z sample matching player physics)
|
||||||
|
- Visual-to-collision position alignment correct
|
||||||
|
|
||||||
|
## Critical root causes found this session
|
||||||
|
|
||||||
|
### 1. Scenery entity ID collision across landblocks
|
||||||
|
**File:** `src/AcDream.App/Rendering/GameWindow.cs` `BuildSceneryEntitiesForStreaming`
|
||||||
|
|
||||||
|
Old: `sceneryIdBase = 0x80000000 | (landblockId & 0x00FFFF00)` — the mask only
|
||||||
|
captured bits 8-23 which always has `0xFF` in the low byte (from the lb id
|
||||||
|
suffix). Result: every landblock with the same Y coord produced scenery with
|
||||||
|
**identical IDs**. When streaming loaded the next landblock, `ShadowObjects
|
||||||
|
.Register` called `Deregister(entityId)` first, wiping out the previous
|
||||||
|
landblock's collision. Only the LAST-loaded landblock had collision for any
|
||||||
|
given ID.
|
||||||
|
|
||||||
|
Fix: `0x80000000 | (lbXByte << 16) | (lbYByte << 8)` — each landblock gets a
|
||||||
|
unique ID namespace. Every scenery entity now uniquely identified.
|
||||||
|
|
||||||
|
### 2. Scenery generator using wrong terrain-Z formula
|
||||||
|
**File:** `src/AcDream.App/Rendering/GameWindow.cs` `SampleTerrainZ`
|
||||||
|
|
||||||
|
My earlier "triangle-aware" terrain sample had wrong barycentric math.
|
||||||
|
Physics's `TerrainSurface.SampleZ` used different (correct) math derived
|
||||||
|
from the WorldBuilder mesh index buffer. So player walked at one Z,
|
||||||
|
scenery placed at another → trees hovered above ground.
|
||||||
|
|
||||||
|
Fix: Ported WorldBuilder `TerrainUtils.GetHeight` exactly — same split
|
||||||
|
direction formula, same barycentric triangle pair, same interpolation.
|
||||||
|
Both scenery and player now use identical height sampling.
|
||||||
|
|
||||||
|
### 3. Collision cylinder position using AABB center, not entity root
|
||||||
|
Multi-part scenery meshes can have parts offset from the mesh origin. The
|
||||||
|
visual mesh AABB center is NOT the entity's rendered position. Using
|
||||||
|
AABB center for the collision cylinder put collision meters away from the
|
||||||
|
visible mesh.
|
||||||
|
|
||||||
|
Fix: Collision cylinder center = `entity.Position` (the rendered origin).
|
||||||
|
Radius from the retail Setup CylSphere when available, clamped to
|
||||||
|
[0.3, 1.5] m.
|
||||||
|
|
||||||
|
### 4. Scenery iterated 81 vertices, retail iterates 64 cells
|
||||||
|
**File:** `src/AcDream.Core/World/SceneryGenerator.cs`
|
||||||
|
|
||||||
|
Decompiled FUN_005311a0 iterates `local_8c < 8 && local_94 < 8` — 64 cells.
|
||||||
|
We iterated 0..8 on both axes (81 vertices). Extra scenery at landblock
|
||||||
|
seams.
|
||||||
|
|
||||||
|
Fix: `for (int x = 0; x < CellsPerSide; x++)` where `CellsPerSide = 8`.
|
||||||
|
|
||||||
|
Note: WorldBuilder iterates 81, ACViewer iterates 81. Retail iterates 64.
|
||||||
|
Currently matching retail decompiled — may need revisiting if we see seam
|
||||||
|
gaps.
|
||||||
|
|
||||||
|
### 5. Road check was per-vertex nearest-neighbor, should be 4-corner polygonal
|
||||||
|
**File:** `src/AcDream.Core/World/SceneryGenerator.cs` `IsOnRoad`
|
||||||
|
|
||||||
|
ACE-server's simplified road test uses single-vertex `(terrain & 0x3)` check.
|
||||||
|
That's wrong — retail uses a 4-corner polygonal test with 5m road ribbon
|
||||||
|
(`FUN_00530d30`).
|
||||||
|
|
||||||
|
Fix: Direct port of ACViewer's `Landblock.OnRoad` (lines 300-398) with
|
||||||
|
16-case corner-configuration dispatch and 5m `RoadHalfWidth`. Also kept an
|
||||||
|
extra "origin-cell has road vertex" guard since retail's exact threshold
|
||||||
|
constants (`_DAT_007c97cc/...`) weren't in dumped chunks.
|
||||||
|
|
||||||
|
### 6. Pre-displacement road check was wrong
|
||||||
|
Retail doesn't skip a vertex based on its own road bit — it rolls displacement
|
||||||
|
then tests the final position against OnRoad. My code had `if (IsRoadVertex(raw))
|
||||||
|
continue;` which silently dropped scenery retail would have kept.
|
||||||
|
|
||||||
|
Fix: Removed. The post-displacement OnRoad test is the only road check.
|
||||||
|
|
||||||
|
### 7. Rotation missing baseLoc + (450-heading)%360 flip
|
||||||
|
**File:** `src/AcDream.Core/World/SceneryGenerator.cs`
|
||||||
|
|
||||||
|
Retail's `AFrame::set_heading(degrees)` transforms `yaw = -(450 - heading) % 360`
|
||||||
|
before building the quaternion. It also composes with `BaseLoc.Orientation`.
|
||||||
|
|
||||||
|
Fix: Applied both. Rotations now match retail for asymmetric scenery.
|
||||||
|
|
||||||
|
### 8. Stabs in buildingCells
|
||||||
|
**File:** `src/AcDream.App/Rendering/GameWindow.cs`
|
||||||
|
|
||||||
|
Was treating `lbInfo.Objects` (stabs) as buildings, over-suppressing scenery
|
||||||
|
in town landblocks. Retail only suppresses scenery in cells with actual
|
||||||
|
`lbInfo.Buildings`.
|
||||||
|
|
||||||
|
Fix: Only `Buildings` contribute to `buildingCells`.
|
||||||
|
|
||||||
|
## Still imperfect (for tomorrow)
|
||||||
|
|
||||||
|
- Some scenery still hovers slightly above ground in certain cells.
|
||||||
|
Probably a minor split-direction edge case or BaseLoc.Z handling quirk.
|
||||||
|
- User wants a real debug overlay (on-screen text/markers with positions,
|
||||||
|
directions, distances, etc.). Currently has F3 console dump + cyan debug
|
||||||
|
wireframes from the DebugLineRenderer.
|
||||||
|
- Collision is now consistent but "feel" needs tuning against retail.
|
||||||
|
- Large trees with BSP on canopy still rely on our CylSphere fallback
|
||||||
|
rather than a retail-faithful dual-test (BSP + CylSphere).
|
||||||
|
|
||||||
|
## Pickup for next session
|
||||||
|
|
||||||
|
User explicitly said next session:
|
||||||
|
1. **Debug overlay UI** — on-screen text for player pos, direction, heading
|
||||||
|
in degrees, nearest object distances, "am I colliding" indicator. The
|
||||||
|
current approach (F3 console dump, wireframes in 3D world) is insufficient
|
||||||
|
because the user doesn't know "what 40m looks like" visually.
|
||||||
|
2. **Controls** — make working with the client easier. Presumably: keybind
|
||||||
|
list, camera controls, debug toggles, maybe a tool palette.
|
||||||
|
|
||||||
|
## Files changed this session
|
||||||
|
|
||||||
|
- `src/AcDream.App/Rendering/GameWindow.cs` — scenery ID namespace, OnRoad,
|
||||||
|
visual-mesh-AABB collision, triangle-aware SampleTerrainZ, debug wireframes,
|
||||||
|
F3 dump, building/stab separation, DebugLineRenderer wiring.
|
||||||
|
- `src/AcDream.App/Rendering/DebugLineRenderer.cs` (new) — minimal GL line
|
||||||
|
renderer for wireframe debug.
|
||||||
|
- `src/AcDream.App/Rendering/Shaders/debug_line.{vert,frag}` (new) — debug
|
||||||
|
line shader pair.
|
||||||
|
- `src/AcDream.Core/World/SceneryGenerator.cs` — 64-cell iteration, ACViewer
|
||||||
|
OnRoad, baseLoc+heading rotation, BaseLoc.Z passthrough, removed
|
||||||
|
pre-displacement road check.
|
||||||
|
- `src/AcDream.Core/World/WorldEntity.cs` — added `Scale` field for scenery.
|
||||||
|
- `src/AcDream.Core/Physics/PhysicsDataCache.cs` — `GfxObjVisualBounds` +
|
||||||
|
`GetVisualBounds` for mesh AABB fallback.
|
||||||
|
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — `AllEntriesForDebug()`
|
||||||
|
for the debug overlay, `Scale` on ShadowEntry.
|
||||||
|
- `src/AcDream.Core/Physics/BSPQuery.cs` — `localToWorld` rotation parameter
|
||||||
|
on all dispatcher methods so collision normals/offsets transform correctly.
|
||||||
|
- `src/AcDream.Core/Physics/TransitionTypes.cs` — `CylinderCollision` rewritten
|
||||||
|
with wall-slide + push-out, `FindObjCollisions` rewritten per-object with
|
||||||
|
retail 6-path dispatcher, TransitionalInsert retry loop.
|
||||||
|
|
||||||
|
## Key reference pointers (for tomorrow)
|
||||||
|
|
||||||
|
- WorldBuilder scenery: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/
|
||||||
|
Lib/SceneryRenderManager.cs` + `SceneryHelpers.cs`.
|
||||||
|
- WorldBuilder terrain: `references/WorldBuilder/WorldBuilder.Shared/
|
||||||
|
Modules/Landscape/Lib/TerrainUtils.cs` (GetHeight, GetNormal, OnRoad).
|
||||||
|
- Retail decompiled collision: `docs/research/decompiled/chunk_00530000.c`
|
||||||
|
FUN_005311a0 (scenery loop) and FUN_00530d30 (OnRoad).
|
||||||
|
- Setup field layout: `docs/research/decompiled/chunk_00510000.c` getter
|
||||||
|
thunks ~line 7563-7662 (CylSpheres at +0x48, Radius +0x64, etc.).
|
||||||
|
|
||||||
|
## Collision pipeline overview (current state)
|
||||||
|
|
||||||
|
1. **Scenery generation** (`SceneryGenerator.Generate`) — 64 cells per lb,
|
||||||
|
LCG-deterministic displacement, OnRoad + building cell filter, slope
|
||||||
|
filter, returns `ScenerySpawn{ObjectId, LocalPosition, Rotation, Scale}`.
|
||||||
|
2. **Scenery hydration** (`BuildSceneryEntitiesForStreaming`) — samples
|
||||||
|
terrain Z via `_physicsEngine.SampleTerrainZ`, builds `WorldEntity`.
|
||||||
|
3. **Collision registration** (end of `ApplyLoadedTerrain`) — for every
|
||||||
|
outdoor mesh entity: pick radius from Setup.CylSphere → Setup.Radius →
|
||||||
|
mesh AABB fallback. Height from Setup.Height or mesh. Register cylinder
|
||||||
|
at `entity.Position` in `ShadowObjectRegistry`.
|
||||||
|
4. **Collision query** (`Transition.FindObjCollisions`) — player sphere
|
||||||
|
sweeps via `GetNearbyObjects` which searches player's lb + 8 neighbors.
|
||||||
|
Per-object dispatch: BSP → `BSPQuery.FindCollisions` (retail 6-path),
|
||||||
|
Cylinder → `CylinderCollision` (wall-slide + push-out).
|
||||||
|
|
||||||
|
Good night.
|
||||||
113
memory/project_session_2026_04_17.md
Normal file
113
memory/project_session_2026_04_17.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Session 2026-04-17 — Debug Overlay + Control Tuning
|
||||||
|
|
||||||
|
## Headline result
|
||||||
|
|
||||||
|
**On-screen HUD overlay and refined input controls for the dev client.**
|
||||||
|
- TTF-based font atlas rendered via stb_truetype
|
||||||
|
- Screen-space text + rect batcher (`TextRenderer`)
|
||||||
|
- Composite overlay with panels for position / heading / collision /
|
||||||
|
FPS / compass / keybind help
|
||||||
|
- Per-mode mouse sensitivity (Chase, Fly, Orbit)
|
||||||
|
- RMB-held free-orbit around the player (no snap-back on release)
|
||||||
|
- Mouse-wheel zoom in chase mode
|
||||||
|
- Extended chase pitch range so mouse-Y moves both ways
|
||||||
|
|
||||||
|
## Files added
|
||||||
|
|
||||||
|
- `src/AcDream.App/Rendering/BitmapFont.cs` — TTF atlas (stb_truetype)
|
||||||
|
- `src/AcDream.App/Rendering/TextRenderer.cs` — 2D quad batcher for text + rects
|
||||||
|
- `src/AcDream.App/Rendering/DebugOverlay.cs` — composed HUD panels
|
||||||
|
- `src/AcDream.App/Rendering/Shaders/ui_text.{vert,frag}` — ortho-proj text shader
|
||||||
|
- `src/AcDream.App/Rendering/Shaders/debug_line.{vert,frag}` — wireframe shader
|
||||||
|
(carried from yesterday's scenery-alignment session but not committed then)
|
||||||
|
|
||||||
|
## Files modified
|
||||||
|
|
||||||
|
- `src/AcDream.App/AcDream.App.csproj` — added `StbTrueTypeSharp` 1.26.12
|
||||||
|
- `src/AcDream.App/Rendering/Shader.cs` — added `SetVec2` / `SetVec4`
|
||||||
|
- `src/AcDream.App/Rendering/ChaseCamera.cs`
|
||||||
|
- Added `YawOffset` for RMB free-orbit
|
||||||
|
- Added `AdjustDistance` for mouse-wheel zoom
|
||||||
|
- Widened pitch clamp from `[0.05, 1.4]` to `[-0.7, 1.4]`
|
||||||
|
(mouse-Y now moves camera in both directions from neutral)
|
||||||
|
- `DistanceMin=2f`, `DistanceMax=40f` zoom envelope
|
||||||
|
- `src/AcDream.App/Rendering/GameWindow.cs`
|
||||||
|
- Field block: `_textRenderer`, `_debugFont`, `_debugOverlay`,
|
||||||
|
`_sensChase/_sensFly/_sensOrbit`, `_rmbHeld`, `_lastFps`, `_lastFrameMs`
|
||||||
|
- OnLoad: load Consolas → BitmapFont → TextRenderer → DebugOverlay
|
||||||
|
(silently skips if no system font)
|
||||||
|
- Keyboard: F1/F2/F4/F5/F6 panel toggles; F8/F9 sensitivity
|
||||||
|
(multiplicative ×1.2 steps, per-mode)
|
||||||
|
- Mouse: MouseDown / MouseUp track RMB; MouseMove routes to the
|
||||||
|
active mode's sensitivity; RMB release does NOT snap YawOffset
|
||||||
|
- OnRender: snapshot builder (player pos, heading, nearest-obj dist,
|
||||||
|
colliding flag) passed to `DebugOverlay.Draw`
|
||||||
|
|
||||||
|
## Yesterday's scenery work (finally committed in this session's commit)
|
||||||
|
|
||||||
|
The untracked files show that yesterday's scenery-alignment fixes lived
|
||||||
|
on disk but hadn't been committed. This session's commit includes:
|
||||||
|
- `src/AcDream.Core/Physics/BSPQuery.cs` — `localToWorld` rotation params
|
||||||
|
- `src/AcDream.Core/Physics/PhysicsDataCache.cs` — `GfxObjVisualBounds`
|
||||||
|
- `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` — `Scale` + debug iter
|
||||||
|
- `src/AcDream.Core/Physics/TransitionTypes.cs` — rewritten cylinder / BSP
|
||||||
|
- `src/AcDream.Core/World/SceneryGenerator.cs` — 64-cell, ACViewer OnRoad,
|
||||||
|
baseLoc + set_heading rotation
|
||||||
|
- `src/AcDream.Core/World/WorldEntity.cs` — `Scale` field
|
||||||
|
|
||||||
|
## Sensitivity defaults (current)
|
||||||
|
|
||||||
|
| Mode | Default | F8 step | F9 step |
|
||||||
|
|-------|---------|-------------|-----------|
|
||||||
|
| Chase | 0.15x | ÷ 1.2 | × 1.2 |
|
||||||
|
| Fly | 1.0x | ÷ 1.2 | × 1.2 |
|
||||||
|
| Orbit | 1.0x | ÷ 1.2 | × 1.2 |
|
||||||
|
|
||||||
|
Effective rate at chase 0.15x: `0.15 × 0.003 rad/px = 0.00045 rad/px`
|
||||||
|
≈ 0.026°/pixel. 1000 pixels → 26° rotation.
|
||||||
|
|
||||||
|
Fly at 1.0x is `0.003 rad/px` ≈ 0.172°/px.
|
||||||
|
|
||||||
|
## Keybinds (full, current)
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| F1 | Toggle keybind help panel |
|
||||||
|
| F2 | Toggle collision wireframes |
|
||||||
|
| F3 | Console dump (pos + nearby objects) |
|
||||||
|
| F4 | Toggle HUD info panel |
|
||||||
|
| F5 | Toggle HUD stats panel |
|
||||||
|
| F6 | Toggle compass |
|
||||||
|
| F8 / F9 | Active-mode mouse sensitivity slower / faster |
|
||||||
|
| F | Toggle fly camera |
|
||||||
|
| Tab | Toggle player mode (live session only) |
|
||||||
|
| WASD | Move (player mode) / fly |
|
||||||
|
| Space | Jump (hold to charge, release to fire) |
|
||||||
|
| Shift | Run |
|
||||||
|
| Mouse | Turn character / look |
|
||||||
|
| Hold RMB | Free-orbit camera around player (stays on release) |
|
||||||
|
| Wheel | Zoom chase distance |
|
||||||
|
| Escape | Exit fly / player / close window |
|
||||||
|
|
||||||
|
## Open issue (parked for follow-up)
|
||||||
|
|
||||||
|
User reports mouse "feels like you can only move one way" at low
|
||||||
|
sensitivity. Diagnosed + fixed: chase `PitchMin` was clamping at
|
||||||
|
`0.05f`, preventing any upward tilt. Widened to `-0.7f`. Needs
|
||||||
|
visual verification next session.
|
||||||
|
|
||||||
|
## Pickup for next session
|
||||||
|
|
||||||
|
**MAJOR TASK PARKED HERE**: user has asked for a deep investigation
|
||||||
|
+ port of the retail AC client's GUI subsystem. User explicitly
|
||||||
|
directed **Opus 4.7 with extra-high effort** for this work. The
|
||||||
|
agents are dispatched in this session and their output lives in
|
||||||
|
`docs/research/2026-04-17-retail-ui-*.md`. See that set of files
|
||||||
|
for the in-depth UI research + C# scaffold.
|
||||||
|
|
||||||
|
After the retail-UI port is in place:
|
||||||
|
1. Hook the retail chat window to the existing WorldSession message
|
||||||
|
stream
|
||||||
|
2. Port the health/stamina/mana globes to real player stats (need
|
||||||
|
`CharacterCreate`/`InqStats` wire parsing first)
|
||||||
|
3. Port the inventory panel (needs CreateObject item parsing)
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
|
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
|
||||||
<PackageReference Include="Serilog" Version="4.0.2" />
|
<PackageReference Include="Serilog" Version="4.0.2" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="StbTrueTypeSharp" Version="1.26.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||||
|
|
|
||||||
180
src/AcDream.App/Rendering/BitmapFont.cs
Normal file
180
src/AcDream.App/Rendering/BitmapFont.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using StbTrueTypeSharp;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A pixel-font atlas rasterized from a TTF at load time using stb_truetype.
|
||||||
|
/// Glyphs are packed into a single-channel (R8) GL texture. Call
|
||||||
|
/// <see cref="TryGetGlyph"/> to resolve an ASCII codepoint to UV + metrics.
|
||||||
|
///
|
||||||
|
/// Only printable ASCII (32..127) is supported for the debug overlay.
|
||||||
|
/// </summary>
|
||||||
|
public sealed unsafe class BitmapFont : IDisposable
|
||||||
|
{
|
||||||
|
public readonly struct Glyph
|
||||||
|
{
|
||||||
|
public readonly float UvMinX;
|
||||||
|
public readonly float UvMinY;
|
||||||
|
public readonly float UvMaxX;
|
||||||
|
public readonly float UvMaxY;
|
||||||
|
public readonly float OffsetX; // from cursor to glyph quad top-left
|
||||||
|
public readonly float OffsetY;
|
||||||
|
public readonly float Width; // pixels
|
||||||
|
public readonly float Height;
|
||||||
|
public readonly float Advance;
|
||||||
|
|
||||||
|
public Glyph(float umn, float vmn, float umx, float vmx,
|
||||||
|
float ox, float oy, float w, float h, float adv)
|
||||||
|
{
|
||||||
|
UvMinX = umn; UvMinY = vmn; UvMaxX = umx; UvMaxY = vmx;
|
||||||
|
OffsetX = ox; OffsetY = oy; Width = w; Height = h; Advance = adv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly Glyph[] _glyphs;
|
||||||
|
private readonly int _firstChar;
|
||||||
|
private readonly int _numChars;
|
||||||
|
|
||||||
|
public uint TextureId { get; }
|
||||||
|
public float PixelHeight { get; }
|
||||||
|
public float LineHeight { get; }
|
||||||
|
public float Ascent { get; }
|
||||||
|
public int AtlasWidth { get; }
|
||||||
|
public int AtlasHeight { get; }
|
||||||
|
|
||||||
|
public BitmapFont(GL gl, byte[] ttfBytes, float pixelHeight,
|
||||||
|
int atlasSize = 512, int firstChar = 32, int numChars = 96)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
PixelHeight = pixelHeight;
|
||||||
|
AtlasWidth = atlasSize;
|
||||||
|
AtlasHeight = atlasSize;
|
||||||
|
_firstChar = firstChar;
|
||||||
|
_numChars = numChars;
|
||||||
|
|
||||||
|
// Bake the glyph bitmap via stbtt_BakeFontBitmap.
|
||||||
|
var bakedChars = new StbTrueType.stbtt_bakedchar[numChars];
|
||||||
|
var pixels = new byte[AtlasWidth * AtlasHeight];
|
||||||
|
bool ok = StbTrueType.stbtt_BakeFontBitmap(
|
||||||
|
ttfBytes, 0, pixelHeight,
|
||||||
|
pixels, AtlasWidth, AtlasHeight,
|
||||||
|
firstChar, numChars, bakedChars);
|
||||||
|
if (!ok)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"stbtt_BakeFontBitmap failed: atlas {atlasSize}x{atlasSize} " +
|
||||||
|
$"too small for pixelHeight={pixelHeight}");
|
||||||
|
|
||||||
|
// Extract vertical metrics for line spacing.
|
||||||
|
using var info = StbTrueType.CreateFont(ttfBytes, 0)
|
||||||
|
?? throw new InvalidOperationException("stbtt_InitFont failed");
|
||||||
|
float scale = StbTrueType.stbtt_ScaleForPixelHeight(info, pixelHeight);
|
||||||
|
int ascent, descent, lineGap;
|
||||||
|
StbTrueType.stbtt_GetFontVMetrics(info, &ascent, &descent, &lineGap);
|
||||||
|
Ascent = ascent * scale;
|
||||||
|
LineHeight = (ascent - descent + lineGap) * scale;
|
||||||
|
|
||||||
|
// Convert baked-char records to our Glyph struct.
|
||||||
|
_glyphs = new Glyph[numChars];
|
||||||
|
for (int i = 0; i < numChars; i++)
|
||||||
|
{
|
||||||
|
var bc = bakedChars[i];
|
||||||
|
float w = bc.x1 - bc.x0;
|
||||||
|
float h = bc.y1 - bc.y0;
|
||||||
|
_glyphs[i] = new Glyph(
|
||||||
|
umn: bc.x0 / (float)AtlasWidth,
|
||||||
|
vmn: bc.y0 / (float)AtlasHeight,
|
||||||
|
umx: bc.x1 / (float)AtlasWidth,
|
||||||
|
vmx: bc.y1 / (float)AtlasHeight,
|
||||||
|
ox: bc.xoff,
|
||||||
|
oy: bc.yoff,
|
||||||
|
w: w, h: h,
|
||||||
|
adv: bc.xadvance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload atlas as a single-channel GL texture (R8).
|
||||||
|
TextureId = _gl.GenTexture();
|
||||||
|
_gl.BindTexture(TextureTarget.Texture2D, TextureId);
|
||||||
|
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
|
||||||
|
fixed (byte* ptr = pixels)
|
||||||
|
{
|
||||||
|
_gl.TexImage2D(TextureTarget.Texture2D, 0,
|
||||||
|
(int)InternalFormat.R8,
|
||||||
|
(uint)AtlasWidth, (uint)AtlasHeight, 0,
|
||||||
|
PixelFormat.Red, PixelType.UnsignedByte, ptr);
|
||||||
|
}
|
||||||
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
|
||||||
|
(int)TextureMinFilter.Linear);
|
||||||
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
|
||||||
|
(int)TextureMagFilter.Linear);
|
||||||
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
|
||||||
|
(int)TextureWrapMode.ClampToEdge);
|
||||||
|
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
|
||||||
|
(int)TextureWrapMode.ClampToEdge);
|
||||||
|
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, 4); // restore default
|
||||||
|
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetGlyph(char c, out Glyph g)
|
||||||
|
{
|
||||||
|
int idx = c - _firstChar;
|
||||||
|
if ((uint)idx >= (uint)_numChars)
|
||||||
|
{
|
||||||
|
g = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
g = _glyphs[idx];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Measure the pixel width of a single-line string in this font.</summary>
|
||||||
|
public float MeasureWidth(string s)
|
||||||
|
{
|
||||||
|
float w = 0;
|
||||||
|
for (int i = 0; i < s.Length; i++)
|
||||||
|
{
|
||||||
|
if (TryGetGlyph(s[i], out var g))
|
||||||
|
w += g.Advance;
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_gl.DeleteTexture(TextureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try to load a monospaced system font from well-known paths on the host OS.
|
||||||
|
/// Returns null if no candidate was found.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[]? TryLoadSystemMonospaceFont()
|
||||||
|
{
|
||||||
|
string[] candidates =
|
||||||
|
{
|
||||||
|
@"C:\Windows\Fonts\consola.ttf",
|
||||||
|
@"C:\Windows\Fonts\cour.ttf",
|
||||||
|
@"C:\Windows\Fonts\arial.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
|
||||||
|
"/Library/Fonts/Menlo.ttc",
|
||||||
|
"/System/Library/Fonts/Menlo.ttc",
|
||||||
|
};
|
||||||
|
foreach (var path in candidates)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
return File.ReadAllBytes(path);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,17 +14,34 @@ public sealed class ChaseCamera : ICamera
|
||||||
public float Aspect { get; set; } = 16f / 9f;
|
public float Aspect { get; set; } = 16f / 9f;
|
||||||
public float FovY { get; set; } = MathF.PI / 3f;
|
public float FovY { get; set; } = MathF.PI / 3f;
|
||||||
|
|
||||||
/// <summary>Distance behind the player.</summary>
|
/// <summary>Distance behind the player. Clamped to [<see cref="DistanceMin"/>, <see cref="DistanceMax"/>].</summary>
|
||||||
public float Distance { get; set; } = 8f;
|
public float Distance { get; set; } = 8f;
|
||||||
|
public const float DistanceMin = 2f;
|
||||||
|
public const float DistanceMax = 40f;
|
||||||
|
|
||||||
/// <summary>Camera pitch above horizontal (radians). Positive = look down.</summary>
|
/// <summary>Camera pitch above horizontal (radians). Positive = look down.</summary>
|
||||||
public float Pitch { get; set; } = 0.35f; // ~20 degrees
|
public float Pitch { get; set; } = 0.35f; // ~20 degrees
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Additional yaw applied on top of the player's heading when positioning
|
||||||
|
/// the camera. Used by the hold-RMB "inspect" mode to orbit around the
|
||||||
|
/// player without rotating the character. Snap to 0 to return the camera
|
||||||
|
/// to directly behind the player.
|
||||||
|
/// </summary>
|
||||||
|
public float YawOffset { get; set; } = 0f;
|
||||||
|
|
||||||
/// <summary>Vertical offset from the player's feet to the look-at point (eye height).</summary>
|
/// <summary>Vertical offset from the player's feet to the look-at point (eye height).</summary>
|
||||||
public float EyeHeight { get; set; } = 1.5f;
|
public float EyeHeight { get; set; } = 1.5f;
|
||||||
|
|
||||||
private const float PitchMin = 0.05f;
|
// Pitch range: negative values place the camera below the player's Z
|
||||||
private const float PitchMax = 1.4f; // ~80 degrees
|
// (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 float _playerYaw;
|
||||||
private Vector3 _lookAt;
|
private Vector3 _lookAt;
|
||||||
|
|
@ -43,9 +60,11 @@ public sealed class ChaseCamera : ICamera
|
||||||
_playerYaw = playerYaw;
|
_playerYaw = playerYaw;
|
||||||
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
|
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
|
||||||
|
|
||||||
// Camera offset: behind the player (-forward direction) and above.
|
// Camera offset: behind the player (-forward direction) plus any
|
||||||
float forwardX = MathF.Cos(playerYaw);
|
// YawOffset for the hold-RMB inspect orbit mode.
|
||||||
float forwardY = MathF.Sin(playerYaw);
|
float effectiveYaw = playerYaw + YawOffset;
|
||||||
|
float forwardX = MathF.Cos(effectiveYaw);
|
||||||
|
float forwardY = MathF.Sin(effectiveYaw);
|
||||||
|
|
||||||
float horizontalDist = Distance * MathF.Cos(Pitch);
|
float horizontalDist = Distance * MathF.Cos(Pitch);
|
||||||
float verticalDist = Distance * MathF.Sin(Pitch);
|
float verticalDist = Distance * MathF.Sin(Pitch);
|
||||||
|
|
@ -63,4 +82,12 @@ public sealed class ChaseCamera : ICamera
|
||||||
{
|
{
|
||||||
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
|
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjust distance (zoom) by a delta, clamped to [DistanceMin, DistanceMax].
|
||||||
|
/// </summary>
|
||||||
|
public void AdjustDistance(float delta)
|
||||||
|
{
|
||||||
|
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
172
src/AcDream.App/Rendering/DebugLineRenderer.cs
Normal file
172
src/AcDream.App/Rendering/DebugLineRenderer.cs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal GL debug line renderer for visualizing collision shapes,
|
||||||
|
/// bounding boxes, and other debug geometry. Collect lines each frame
|
||||||
|
/// via <see cref="AddLine"/> / <see cref="AddCylinder"/>, then call
|
||||||
|
/// <see cref="Flush"/> to upload + draw them.
|
||||||
|
///
|
||||||
|
/// Uses a single shared VBO that's respecialized each frame. Vertex
|
||||||
|
/// format is (vec3 pos, vec3 color) = 24 bytes per vertex.
|
||||||
|
/// </summary>
|
||||||
|
public sealed unsafe class DebugLineRenderer : IDisposable
|
||||||
|
{
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly Shader _shader;
|
||||||
|
private readonly uint _vao;
|
||||||
|
private readonly uint _vbo;
|
||||||
|
|
||||||
|
private readonly List<float> _buffer = new(4096);
|
||||||
|
private int _vertexCount;
|
||||||
|
private int _capacityBytes;
|
||||||
|
|
||||||
|
public DebugLineRenderer(GL gl, string shaderDir)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
_shader = new Shader(gl,
|
||||||
|
Path.Combine(shaderDir, "debug_line.vert"),
|
||||||
|
Path.Combine(shaderDir, "debug_line.frag"));
|
||||||
|
|
||||||
|
_vao = _gl.GenVertexArray();
|
||||||
|
_vbo = _gl.GenBuffer();
|
||||||
|
|
||||||
|
_gl.BindVertexArray(_vao);
|
||||||
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||||||
|
|
||||||
|
// 24-byte stride: vec3 pos + vec3 color
|
||||||
|
_gl.EnableVertexAttribArray(0);
|
||||||
|
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)0);
|
||||||
|
_gl.EnableVertexAttribArray(1);
|
||||||
|
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)(3 * sizeof(float)));
|
||||||
|
|
||||||
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||||||
|
_gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clear accumulated lines. Call at the start of each frame.</summary>
|
||||||
|
public void Begin()
|
||||||
|
{
|
||||||
|
_buffer.Clear();
|
||||||
|
_vertexCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddLine(Vector3 a, Vector3 b, Vector3 color)
|
||||||
|
{
|
||||||
|
_buffer.Add(a.X); _buffer.Add(a.Y); _buffer.Add(a.Z);
|
||||||
|
_buffer.Add(color.X); _buffer.Add(color.Y); _buffer.Add(color.Z);
|
||||||
|
_buffer.Add(b.X); _buffer.Add(b.Y); _buffer.Add(b.Z);
|
||||||
|
_buffer.Add(color.X); _buffer.Add(color.Y); _buffer.Add(color.Z);
|
||||||
|
_vertexCount += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draw a cylinder as 2 polygon rings (base + top) connected by 4
|
||||||
|
/// vertical line segments at 0/90/180/270 degrees.
|
||||||
|
/// </summary>
|
||||||
|
public void AddCylinder(Vector3 basePos, float radius, float height, Vector3 color)
|
||||||
|
{
|
||||||
|
const int segments = 16;
|
||||||
|
Vector3 top = basePos + new Vector3(0, 0, height);
|
||||||
|
|
||||||
|
// Ring vertices
|
||||||
|
var baseRing = new Vector3[segments];
|
||||||
|
var topRing = new Vector3[segments];
|
||||||
|
for (int i = 0; i < segments; i++)
|
||||||
|
{
|
||||||
|
float theta = i * (MathF.PI * 2f / segments);
|
||||||
|
float cx = MathF.Cos(theta) * radius;
|
||||||
|
float cy = MathF.Sin(theta) * radius;
|
||||||
|
baseRing[i] = new Vector3(basePos.X + cx, basePos.Y + cy, basePos.Z);
|
||||||
|
topRing[i] = new Vector3(top.X + cx, top.Y + cy, top.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base ring
|
||||||
|
for (int i = 0; i < segments; i++)
|
||||||
|
AddLine(baseRing[i], baseRing[(i + 1) % segments], color);
|
||||||
|
// Top ring
|
||||||
|
for (int i = 0; i < segments; i++)
|
||||||
|
AddLine(topRing[i], topRing[(i + 1) % segments], color);
|
||||||
|
// 4 vertical connectors
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
int idx = i * (segments / 4);
|
||||||
|
AddLine(baseRing[idx], topRing[idx], color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draw an axis-aligned box as 12 edges.
|
||||||
|
/// </summary>
|
||||||
|
public void AddBox(Vector3 min, Vector3 max, Vector3 color)
|
||||||
|
{
|
||||||
|
Vector3[] c =
|
||||||
|
{
|
||||||
|
new(min.X, min.Y, min.Z),
|
||||||
|
new(max.X, min.Y, min.Z),
|
||||||
|
new(max.X, max.Y, min.Z),
|
||||||
|
new(min.X, max.Y, min.Z),
|
||||||
|
new(min.X, min.Y, max.Z),
|
||||||
|
new(max.X, min.Y, max.Z),
|
||||||
|
new(max.X, max.Y, max.Z),
|
||||||
|
new(min.X, max.Y, max.Z),
|
||||||
|
};
|
||||||
|
// Bottom
|
||||||
|
AddLine(c[0], c[1], color); AddLine(c[1], c[2], color);
|
||||||
|
AddLine(c[2], c[3], color); AddLine(c[3], c[0], color);
|
||||||
|
// Top
|
||||||
|
AddLine(c[4], c[5], color); AddLine(c[5], c[6], color);
|
||||||
|
AddLine(c[6], c[7], color); AddLine(c[7], c[4], color);
|
||||||
|
// Verticals
|
||||||
|
AddLine(c[0], c[4], color); AddLine(c[1], c[5], color);
|
||||||
|
AddLine(c[2], c[6], color); AddLine(c[3], c[7], color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Upload + draw all accumulated lines.</summary>
|
||||||
|
public void Flush(Matrix4x4 view, Matrix4x4 projection)
|
||||||
|
{
|
||||||
|
if (_vertexCount == 0) return;
|
||||||
|
|
||||||
|
_shader.Use();
|
||||||
|
_shader.SetMatrix4("uView", view);
|
||||||
|
_shader.SetMatrix4("uProjection", projection);
|
||||||
|
|
||||||
|
_gl.BindVertexArray(_vao);
|
||||||
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||||||
|
|
||||||
|
int neededBytes = _buffer.Count * sizeof(float);
|
||||||
|
if (neededBytes > _capacityBytes)
|
||||||
|
{
|
||||||
|
fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
|
||||||
|
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)neededBytes, ptr, BufferUsageARB.DynamicDraw);
|
||||||
|
_capacityBytes = neededBytes;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
|
||||||
|
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)neededBytes, ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth test on so lines get occluded by geometry (but we want them
|
||||||
|
// visible through geometry — disable depth test so everything shows).
|
||||||
|
bool wasDepthEnabled = _gl.IsEnabled(EnableCap.DepthTest);
|
||||||
|
_gl.Disable(EnableCap.DepthTest);
|
||||||
|
|
||||||
|
_gl.DrawArrays(PrimitiveType.Lines, 0, (uint)_vertexCount);
|
||||||
|
|
||||||
|
if (wasDepthEnabled) _gl.Enable(EnableCap.DepthTest);
|
||||||
|
|
||||||
|
_gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_gl.DeleteVertexArray(_vao);
|
||||||
|
_gl.DeleteBuffer(_vbo);
|
||||||
|
_shader.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
330
src/AcDream.App/Rendering/DebugOverlay.cs
Normal file
330
src/AcDream.App/Rendering/DebugOverlay.cs
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Screen-space debug HUD. Composes panels on top of the 3D scene using a
|
||||||
|
/// <see cref="TextRenderer"/> + <see cref="BitmapFont"/>. Panels can be
|
||||||
|
/// toggled independently (info / stats / controls-help / compass).
|
||||||
|
///
|
||||||
|
/// The overlay is stateless w.r.t. game state — callers populate a
|
||||||
|
/// <see cref="Snapshot"/> each frame and pass it to <see cref="Draw"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DebugOverlay
|
||||||
|
{
|
||||||
|
private readonly TextRenderer _text;
|
||||||
|
private readonly BitmapFont _font;
|
||||||
|
|
||||||
|
public bool ShowInfoPanel { get; set; } = true;
|
||||||
|
public bool ShowStatsPanel { get; set; } = true;
|
||||||
|
public bool ShowHelpPanel { get; set; } = false;
|
||||||
|
public bool ShowCompass { get; set; } = true;
|
||||||
|
|
||||||
|
// Toast state for transient notifications (e.g. "wireframes off").
|
||||||
|
private string? _toastText;
|
||||||
|
private Vector4 _toastColor = White;
|
||||||
|
private float _toastTimeLeft;
|
||||||
|
|
||||||
|
private static readonly Vector4 White = new(1f, 1f, 1f, 1f);
|
||||||
|
private static readonly Vector4 Green = new(0.4f, 0.95f, 0.4f, 1f);
|
||||||
|
private static readonly Vector4 Yellow = new(1f, 0.9f, 0.3f, 1f);
|
||||||
|
private static readonly Vector4 Red = new(1f, 0.4f, 0.35f, 1f);
|
||||||
|
private static readonly Vector4 Cyan = new(0.4f, 0.85f, 1f, 1f);
|
||||||
|
private static readonly Vector4 Grey = new(0.7f, 0.7f, 0.75f, 1f);
|
||||||
|
private static readonly Vector4 PanelBg = new(0f, 0f, 0f, 0.55f);
|
||||||
|
private static readonly Vector4 PanelBorder = new(0.15f, 0.15f, 0.2f, 0.8f);
|
||||||
|
|
||||||
|
/// <summary>Per-frame state snapshot from the caller. See <see cref="Draw"/>.</summary>
|
||||||
|
public readonly record struct Snapshot(
|
||||||
|
float Fps,
|
||||||
|
float FrameTimeMs,
|
||||||
|
Vector3 PlayerPos,
|
||||||
|
float HeadingDeg,
|
||||||
|
uint CellId,
|
||||||
|
bool OnGround,
|
||||||
|
bool InPlayerMode,
|
||||||
|
bool InFlyMode,
|
||||||
|
float VerticalVelocity,
|
||||||
|
int EntityCount,
|
||||||
|
int AnimatedCount,
|
||||||
|
int LandblocksVisible,
|
||||||
|
int LandblocksTotal,
|
||||||
|
int ShadowObjectCount,
|
||||||
|
float NearestObjDist,
|
||||||
|
string NearestObjLabel,
|
||||||
|
bool Colliding,
|
||||||
|
bool DebugWireframes,
|
||||||
|
int StreamingRadius,
|
||||||
|
float MouseSensitivity,
|
||||||
|
float ChaseDistance,
|
||||||
|
bool RmbOrbit);
|
||||||
|
|
||||||
|
public DebugOverlay(TextRenderer text, BitmapFont font)
|
||||||
|
{
|
||||||
|
_text = text;
|
||||||
|
_font = font;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Show a short message in the center-top for <paramref name="durationSec"/> seconds.</summary>
|
||||||
|
public void Toast(string message, float durationSec = 1.5f, Vector4? color = null)
|
||||||
|
{
|
||||||
|
_toastText = message;
|
||||||
|
_toastColor = color ?? Yellow;
|
||||||
|
_toastTimeLeft = durationSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Advance toast timer. Call once per frame with dt in seconds.</summary>
|
||||||
|
public void Update(float dt)
|
||||||
|
{
|
||||||
|
if (_toastTimeLeft > 0f)
|
||||||
|
{
|
||||||
|
_toastTimeLeft -= dt;
|
||||||
|
if (_toastTimeLeft <= 0f)
|
||||||
|
_toastText = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(Snapshot s, Vector2 screenSize)
|
||||||
|
{
|
||||||
|
_text.Begin(screenSize);
|
||||||
|
|
||||||
|
if (ShowInfoPanel) DrawInfoPanel(s);
|
||||||
|
if (ShowStatsPanel) DrawStatsPanel(s, screenSize);
|
||||||
|
if (ShowCompass) DrawCompass(s, screenSize);
|
||||||
|
if (ShowHelpPanel) DrawHelpPanel(screenSize);
|
||||||
|
DrawHintBar(screenSize);
|
||||||
|
DrawToast(screenSize);
|
||||||
|
|
||||||
|
_text.Flush(_font);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Info panel — top-left: mode, position, heading, ground, nearest-obj.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void DrawInfoPanel(Snapshot s)
|
||||||
|
{
|
||||||
|
var lines = new List<(string text, Vector4 color)>();
|
||||||
|
|
||||||
|
string modeLabel = s.InPlayerMode ? "PLAYER" : (s.InFlyMode ? "FLY " : "ORBIT ");
|
||||||
|
var modeColor = s.InPlayerMode ? Yellow : (s.InFlyMode ? Cyan : Grey);
|
||||||
|
lines.Add(($"[{modeLabel}] wireframes {(s.DebugWireframes ? "ON " : "OFF")}",
|
||||||
|
modeColor));
|
||||||
|
|
||||||
|
lines.Add(($"Pos {s.PlayerPos.X,8:F1} {s.PlayerPos.Y,8:F1} {s.PlayerPos.Z,8:F2}", White));
|
||||||
|
lines.Add(($"Head {s.HeadingDeg,5:F0} deg Cell 0x{s.CellId:X8}", White));
|
||||||
|
|
||||||
|
var gColor = s.OnGround ? Green : Yellow;
|
||||||
|
string vzStr = s.VerticalVelocity >= 0
|
||||||
|
? $"+{s.VerticalVelocity,4:F2}"
|
||||||
|
: $"{s.VerticalVelocity,5:F2}";
|
||||||
|
lines.Add(($"Grnd {(s.OnGround ? "yes" : "NO ")} vZ {vzStr}", gColor));
|
||||||
|
|
||||||
|
var nColor = s.Colliding ? Red : White;
|
||||||
|
string nearDist = float.IsPositiveInfinity(s.NearestObjDist) ? " --- " : $"{s.NearestObjDist,4:F1}m";
|
||||||
|
lines.Add(($"Near {nearDist} {s.NearestObjLabel}", nColor));
|
||||||
|
|
||||||
|
var cColor = s.Colliding ? Red : Green;
|
||||||
|
lines.Add(($"Coll {(s.Colliding ? "BLOCKED" : "free ")}", cColor));
|
||||||
|
|
||||||
|
if (s.InPlayerMode)
|
||||||
|
{
|
||||||
|
string orbitTag = s.RmbOrbit ? " [RMB orbit]" : "";
|
||||||
|
lines.Add(($"Cam dist {s.ChaseDistance,4:F1}m{orbitTag}",
|
||||||
|
s.RmbOrbit ? Cyan : White));
|
||||||
|
}
|
||||||
|
lines.Add(($"Sens {s.MouseSensitivity:F3}x (F8 slower / F9 faster)", Grey));
|
||||||
|
|
||||||
|
DrawPanel(10f, 10f, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Stats panel — top-right: fps, frame time, landblock/entity counters.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void DrawStatsPanel(Snapshot s, Vector2 screenSize)
|
||||||
|
{
|
||||||
|
var lines = new List<(string text, Vector4 color)>
|
||||||
|
{
|
||||||
|
($"{s.Fps,5:F0} fps {s.FrameTimeMs,5:F1} ms", Green),
|
||||||
|
($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
|
||||||
|
($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
|
||||||
|
($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White),
|
||||||
|
};
|
||||||
|
|
||||||
|
float pad = 10f;
|
||||||
|
float panelW = MeasureMax(lines) + 2 * InnerPad;
|
||||||
|
DrawPanel(screenSize.X - panelW - pad, pad, lines, panelW);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Compass — bottom-center: arrow indicating heading, cardinal labels.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void DrawCompass(Snapshot s, Vector2 screenSize)
|
||||||
|
{
|
||||||
|
// Simple linear compass strip across 180° of horizon at the top-center.
|
||||||
|
const float stripW = 360f;
|
||||||
|
const float stripH = 20f;
|
||||||
|
float cx = screenSize.X * 0.5f;
|
||||||
|
float top = 8f;
|
||||||
|
float left = cx - stripW * 0.5f;
|
||||||
|
|
||||||
|
_text.DrawRect(left, top, stripW, stripH, PanelBg);
|
||||||
|
_text.DrawRectOutline(left, top, stripW, stripH, PanelBorder);
|
||||||
|
|
||||||
|
// Mark every 30° of yaw, labelled with cardinal letters at N/E/S/W.
|
||||||
|
// Heading 0 = +X (east). We show 180° wide, ±90° from current heading.
|
||||||
|
float h = NormalizeDeg(s.HeadingDeg);
|
||||||
|
for (int d = 0; d < 360; d += 15)
|
||||||
|
{
|
||||||
|
float rel = NormalizeDegSigned(d - h);
|
||||||
|
if (rel < -90 || rel > 90) continue;
|
||||||
|
float x = cx + rel / 90f * (stripW * 0.5f - 6f);
|
||||||
|
bool bold = (d % 90) == 0;
|
||||||
|
float tickH = bold ? stripH * 0.9f : stripH * 0.4f;
|
||||||
|
_text.DrawRect(x - 0.5f, top + stripH - tickH, 1f, tickH, bold ? White : Grey);
|
||||||
|
|
||||||
|
if (bold)
|
||||||
|
{
|
||||||
|
string lab = d switch
|
||||||
|
{
|
||||||
|
0 => "E",
|
||||||
|
90 => "N",
|
||||||
|
180 => "W",
|
||||||
|
270 => "S",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
if (lab.Length > 0)
|
||||||
|
{
|
||||||
|
float w = _font.MeasureWidth(lab);
|
||||||
|
_text.DrawString(_font, lab, x - w * 0.5f, top - _font.LineHeight + 4f, White);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current heading indicator arrow below the strip.
|
||||||
|
_text.DrawRect(cx - 1.5f, top + stripH + 2f, 3f, 6f, Yellow);
|
||||||
|
string hText = $"{h,3:F0}°";
|
||||||
|
float hw = _font.MeasureWidth(hText);
|
||||||
|
_text.DrawString(_font, hText, cx - hw * 0.5f, top + stripH + 10f, Yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Help panel — center: full keybind cheat-sheet, shown when F1 is pressed.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static readonly (string key, string desc)[] Keybinds =
|
||||||
|
{
|
||||||
|
("F1", "toggle this help"),
|
||||||
|
("F2", "toggle collision wireframes"),
|
||||||
|
("F3", "console dump (pos + nearby objects)"),
|
||||||
|
("F4", "toggle debug HUD info panel"),
|
||||||
|
("F5", "toggle stats panel"),
|
||||||
|
("F6", "toggle compass"),
|
||||||
|
("F", "toggle fly camera"),
|
||||||
|
("Tab", "toggle player mode (requires login)"),
|
||||||
|
("W A S D", "move (player mode) / fly"),
|
||||||
|
("Mouse", "turn character / look (fly)"),
|
||||||
|
("Hold RMB", "free orbit camera around player"),
|
||||||
|
("Wheel", "zoom chase camera in / out"),
|
||||||
|
("F8 / F9", "mouse sensitivity slower / faster"),
|
||||||
|
("Space", "jump (hold to charge)"),
|
||||||
|
("Shift", "run"),
|
||||||
|
("Escape", "exit fly / player / close window"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private void DrawHelpPanel(Vector2 screenSize)
|
||||||
|
{
|
||||||
|
var lines = new List<(string text, Vector4 color)>();
|
||||||
|
lines.Add(("CONTROLS", Yellow));
|
||||||
|
lines.Add(("", White));
|
||||||
|
foreach (var (k, d) in Keybinds)
|
||||||
|
lines.Add(($" {k,-9} {d}", White));
|
||||||
|
lines.Add(("", White));
|
||||||
|
lines.Add(("Press F1 to close", Grey));
|
||||||
|
|
||||||
|
float panelW = MeasureMax(lines) + 2 * InnerPad;
|
||||||
|
float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
|
||||||
|
float x = (screenSize.X - panelW) * 0.5f;
|
||||||
|
float y = (screenSize.Y - panelH) * 0.5f;
|
||||||
|
DrawPanel(x, y, lines, panelW);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Hint bar — bottom-left: always-visible "F1 for help" reminder.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void DrawHintBar(Vector2 screenSize)
|
||||||
|
{
|
||||||
|
string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold RMB orbit Wheel zoom";
|
||||||
|
float w = _font.MeasureWidth(hint);
|
||||||
|
float pad = 10f;
|
||||||
|
float y = screenSize.Y - _font.LineHeight - pad;
|
||||||
|
_text.DrawRect(pad - 4, y - 3, w + 8, _font.LineHeight + 6, PanelBg);
|
||||||
|
_text.DrawString(_font, hint, pad, y, Grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawToast(Vector2 screenSize)
|
||||||
|
{
|
||||||
|
if (_toastText is null || _toastTimeLeft <= 0f) return;
|
||||||
|
float w = _font.MeasureWidth(_toastText);
|
||||||
|
float x = (screenSize.X - w) * 0.5f;
|
||||||
|
float y = 60f;
|
||||||
|
var c = _toastColor;
|
||||||
|
float alpha = MathF.Min(1f, _toastTimeLeft / 0.5f);
|
||||||
|
c.W *= alpha;
|
||||||
|
var bg = PanelBg;
|
||||||
|
bg.W *= alpha;
|
||||||
|
_text.DrawRect(x - 10, y - 4, w + 20, _font.LineHeight + 8, bg);
|
||||||
|
_text.DrawString(_font, _toastText, x, y, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Panel helpers.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private const float InnerPad = 8f;
|
||||||
|
|
||||||
|
private float MeasureMax(IReadOnlyList<(string text, Vector4 color)> lines)
|
||||||
|
{
|
||||||
|
float m = 0;
|
||||||
|
foreach (var (text, _) in lines)
|
||||||
|
m = MathF.Max(m, _font.MeasureWidth(text));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawPanel(float x, float y, IReadOnlyList<(string text, Vector4 color)> lines, float? widthOverride = null)
|
||||||
|
{
|
||||||
|
float maxW = MeasureMax(lines);
|
||||||
|
float panelW = widthOverride ?? (maxW + 2 * InnerPad);
|
||||||
|
float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
|
||||||
|
|
||||||
|
_text.DrawRect(x, y, panelW, panelH, PanelBg);
|
||||||
|
_text.DrawRectOutline(x, y, panelW, panelH, PanelBorder);
|
||||||
|
|
||||||
|
float cy = y + InnerPad;
|
||||||
|
foreach (var (text, color) in lines)
|
||||||
|
{
|
||||||
|
_text.DrawString(_font, text, x + InnerPad, cy, color);
|
||||||
|
cy += _font.LineHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float NormalizeDeg(float deg)
|
||||||
|
{
|
||||||
|
deg %= 360f;
|
||||||
|
if (deg < 0) deg += 360f;
|
||||||
|
return deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float NormalizeDegSigned(float deg)
|
||||||
|
{
|
||||||
|
deg %= 360f;
|
||||||
|
if (deg > 180) deg -= 360f;
|
||||||
|
if (deg < -180) deg += 360f;
|
||||||
|
return deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -64,5 +64,17 @@ public sealed class Shader : IDisposable
|
||||||
_gl.Uniform3(loc, v.X, v.Y, v.Z);
|
_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);
|
public void Dispose() => _gl.DeleteProgram(Program);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
src/AcDream.App/Rendering/Shaders/debug_line.frag
Normal file
7
src/AcDream.App/Rendering/Shaders/debug_line.frag
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
#version 430 core
|
||||||
|
in vec3 vColor;
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
FragColor = vec4(vColor, 1.0);
|
||||||
|
}
|
||||||
13
src/AcDream.App/Rendering/Shaders/debug_line.vert
Normal file
13
src/AcDream.App/Rendering/Shaders/debug_line.vert
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#version 430 core
|
||||||
|
layout(location = 0) in vec3 aPos;
|
||||||
|
layout(location = 1) in vec3 aColor;
|
||||||
|
|
||||||
|
uniform mat4 uView;
|
||||||
|
uniform mat4 uProjection;
|
||||||
|
|
||||||
|
out vec3 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vColor = aColor;
|
||||||
|
gl_Position = uProjection * uView * vec4(aPos, 1.0);
|
||||||
|
}
|
||||||
18
src/AcDream.App/Rendering/Shaders/ui_text.frag
Normal file
18
src/AcDream.App/Rendering/Shaders/ui_text.frag
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
#version 430 core
|
||||||
|
in vec2 vUv;
|
||||||
|
in vec4 vColor;
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
uniform sampler2D uTex;
|
||||||
|
uniform int uUseTexture;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
if (uUseTexture != 0) {
|
||||||
|
// Font atlas is a single-channel R8 texture; red = coverage alpha.
|
||||||
|
float coverage = texture(uTex, vUv).r;
|
||||||
|
FragColor = vec4(vColor.rgb, vColor.a * coverage);
|
||||||
|
} else {
|
||||||
|
FragColor = vColor;
|
||||||
|
}
|
||||||
|
if (FragColor.a < 0.005) discard;
|
||||||
|
}
|
||||||
19
src/AcDream.App/Rendering/Shaders/ui_text.vert
Normal file
19
src/AcDream.App/Rendering/Shaders/ui_text.vert
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#version 430 core
|
||||||
|
layout(location = 0) in vec2 aPos; // screen pixels, origin top-left
|
||||||
|
layout(location = 1) in vec2 aUv;
|
||||||
|
layout(location = 2) in vec4 aColor;
|
||||||
|
|
||||||
|
uniform vec2 uScreenSize;
|
||||||
|
|
||||||
|
out vec2 vUv;
|
||||||
|
out vec4 vColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Convert pixel coords (origin top-left, +Y down) to NDC (origin center, +Y up).
|
||||||
|
vec2 ndc = vec2(
|
||||||
|
aPos.x / uScreenSize.x * 2.0 - 1.0,
|
||||||
|
1.0 - aPos.y / uScreenSize.y * 2.0);
|
||||||
|
gl_Position = vec4(ndc, 0.0, 1.0);
|
||||||
|
vUv = aUv;
|
||||||
|
vColor = aColor;
|
||||||
|
}
|
||||||
230
src/AcDream.App/Rendering/TextRenderer.cs
Normal file
230
src/AcDream.App/Rendering/TextRenderer.cs
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 2D batched quad renderer for text + solid rectangles. Coordinates are in
|
||||||
|
/// screen pixels with origin top-left, +X right, +Y down. Call
|
||||||
|
/// <see cref="Begin"/> at the start of a HUD pass, queue geometry via
|
||||||
|
/// <see cref="DrawString"/> / <see cref="DrawRect"/>, then <see cref="Flush"/>.
|
||||||
|
///
|
||||||
|
/// Uses two internal vertex buffers (text and rect) flushed in two draw calls
|
||||||
|
/// to avoid a per-vertex "use texture" flag. Rects are drawn first so text
|
||||||
|
/// sits on top of background panels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed unsafe class TextRenderer : IDisposable
|
||||||
|
{
|
||||||
|
private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
|
||||||
|
|
||||||
|
private readonly GL _gl;
|
||||||
|
private readonly Shader _shader;
|
||||||
|
private readonly uint _vao;
|
||||||
|
private readonly uint _vbo;
|
||||||
|
private int _vboCapacityBytes;
|
||||||
|
|
||||||
|
private readonly List<float> _textBuf = new(8192);
|
||||||
|
private readonly List<float> _rectBuf = new(1024);
|
||||||
|
private int _textVerts;
|
||||||
|
private int _rectVerts;
|
||||||
|
private Vector2 _screenSize;
|
||||||
|
|
||||||
|
public TextRenderer(GL gl, string shaderDir)
|
||||||
|
{
|
||||||
|
_gl = gl;
|
||||||
|
_shader = new Shader(gl,
|
||||||
|
Path.Combine(shaderDir, "ui_text.vert"),
|
||||||
|
Path.Combine(shaderDir, "ui_text.frag"));
|
||||||
|
|
||||||
|
_vao = _gl.GenVertexArray();
|
||||||
|
_vbo = _gl.GenBuffer();
|
||||||
|
|
||||||
|
_gl.BindVertexArray(_vao);
|
||||||
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||||||
|
|
||||||
|
uint stride = FloatsPerVertex * sizeof(float);
|
||||||
|
_gl.EnableVertexAttribArray(0);
|
||||||
|
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
|
||||||
|
_gl.EnableVertexAttribArray(1);
|
||||||
|
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
|
||||||
|
_gl.EnableVertexAttribArray(2);
|
||||||
|
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
|
||||||
|
|
||||||
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
|
||||||
|
_gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
|
||||||
|
public void Begin(Vector2 screenSize)
|
||||||
|
{
|
||||||
|
_screenSize = screenSize;
|
||||||
|
_textBuf.Clear();
|
||||||
|
_rectBuf.Clear();
|
||||||
|
_textVerts = 0;
|
||||||
|
_rectVerts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
|
||||||
|
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
||||||
|
{
|
||||||
|
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
|
||||||
|
_rectVerts += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
|
||||||
|
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
||||||
|
{
|
||||||
|
// top, bottom, left, right
|
||||||
|
DrawRect(x, y, w, thickness, color);
|
||||||
|
DrawRect(x, y + h - thickness, w, thickness, color);
|
||||||
|
DrawRect(x, y, thickness, h, color);
|
||||||
|
DrawRect(x + w - thickness, y, thickness, h, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Draw a single line of text at (x,y) where (x,y) is the top-left of the
|
||||||
|
/// typographic block. Handles '\n' as a line break.
|
||||||
|
/// </summary>
|
||||||
|
public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color)
|
||||||
|
{
|
||||||
|
float cursorX = x;
|
||||||
|
// The caller provides top-y; shift to baseline for glyph offset math.
|
||||||
|
float baseline = y + font.Ascent;
|
||||||
|
|
||||||
|
for (int i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
char c = text[i];
|
||||||
|
if (c == '\n')
|
||||||
|
{
|
||||||
|
cursorX = x;
|
||||||
|
baseline += font.LineHeight;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!font.TryGetGlyph(c, out var g))
|
||||||
|
{
|
||||||
|
// Unknown glyph — skip its advance width if '?' exists.
|
||||||
|
if (font.TryGetGlyph('?', out var q))
|
||||||
|
cursorX += q.Advance;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float gx = cursorX + g.OffsetX;
|
||||||
|
float gy = baseline + g.OffsetY;
|
||||||
|
float gw = g.Width;
|
||||||
|
float gh = g.Height;
|
||||||
|
|
||||||
|
if (gw > 0 && gh > 0)
|
||||||
|
{
|
||||||
|
AppendQuad(_textBuf,
|
||||||
|
gx, gy, gw, gh,
|
||||||
|
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
|
||||||
|
color);
|
||||||
|
_textVerts += 6;
|
||||||
|
}
|
||||||
|
cursorX += g.Advance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendQuad(List<float> buf,
|
||||||
|
float x, float y, float w, float h,
|
||||||
|
float u0, float v0, float u1, float v1, Vector4 color)
|
||||||
|
{
|
||||||
|
// Two triangles (6 verts). CCW in pixel space is clockwise in NDC
|
||||||
|
// because the vertex shader flips Y, so OpenGL's default front-face
|
||||||
|
// is GL_CCW — we rely on cull-face being disabled during HUD pass.
|
||||||
|
// (x, y) ─ (x+w, y)
|
||||||
|
// │ │
|
||||||
|
// (x, y+h) ─ (x+w, y+h)
|
||||||
|
//
|
||||||
|
// Triangle 1: (x,y) (x+w,y+h) (x+w,y)
|
||||||
|
// Triangle 2: (x,y) (x,y+h) (x+w,y+h)
|
||||||
|
void V(float px, float py, float pu, float pv)
|
||||||
|
{
|
||||||
|
buf.Add(px); buf.Add(py);
|
||||||
|
buf.Add(pu); buf.Add(pv);
|
||||||
|
buf.Add(color.X); buf.Add(color.Y); buf.Add(color.Z); buf.Add(color.W);
|
||||||
|
}
|
||||||
|
V(x, y, u0, v0);
|
||||||
|
V(x + w, y + h, u1, v1);
|
||||||
|
V(x + w, y, u1, v0);
|
||||||
|
V(x, y, u0, v0);
|
||||||
|
V(x, y + h, u0, v1);
|
||||||
|
V(x + w, y + h, u1, v1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
|
||||||
|
public void Flush(BitmapFont? font)
|
||||||
|
{
|
||||||
|
if (_textVerts == 0 && _rectVerts == 0) return;
|
||||||
|
|
||||||
|
_shader.Use();
|
||||||
|
_shader.SetVec2("uScreenSize", _screenSize);
|
||||||
|
|
||||||
|
_gl.BindVertexArray(_vao);
|
||||||
|
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||||||
|
|
||||||
|
// Save GL state.
|
||||||
|
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
|
||||||
|
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
|
||||||
|
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
|
||||||
|
_gl.Disable(EnableCap.DepthTest);
|
||||||
|
_gl.Disable(EnableCap.CullFace);
|
||||||
|
_gl.Enable(EnableCap.Blend);
|
||||||
|
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||||
|
|
||||||
|
// Untextured rects first — they form panel backgrounds.
|
||||||
|
if (_rectVerts > 0)
|
||||||
|
{
|
||||||
|
_shader.SetInt("uUseTexture", 0);
|
||||||
|
UploadBuffer(_rectBuf);
|
||||||
|
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textured text glyphs.
|
||||||
|
if (_textVerts > 0 && font is not null)
|
||||||
|
{
|
||||||
|
_shader.SetInt("uUseTexture", 1);
|
||||||
|
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||||
|
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
|
||||||
|
_shader.SetInt("uTex", 0);
|
||||||
|
UploadBuffer(_textBuf);
|
||||||
|
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore GL state.
|
||||||
|
if (!wasBlend) _gl.Disable(EnableCap.Blend);
|
||||||
|
if (wasCull) _gl.Enable(EnableCap.CullFace);
|
||||||
|
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
|
||||||
|
|
||||||
|
_gl.BindVertexArray(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UploadBuffer(List<float> buf)
|
||||||
|
{
|
||||||
|
int bytes = buf.Count * sizeof(float);
|
||||||
|
if (bytes == 0) return;
|
||||||
|
|
||||||
|
if (bytes > _vboCapacityBytes)
|
||||||
|
{
|
||||||
|
fixed (float* p = CollectionsMarshal.AsSpan(buf))
|
||||||
|
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)bytes, p, BufferUsageARB.DynamicDraw);
|
||||||
|
_vboCapacityBytes = bytes;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fixed (float* p = CollectionsMarshal.AsSpan(buf))
|
||||||
|
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_gl.DeleteBuffer(_vbo);
|
||||||
|
_gl.DeleteVertexArray(_vao);
|
||||||
|
_shader.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1453,19 +1453,46 @@ public static class BSPQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// Path 5: Contact — sphere_intersects_poly + step_sphere_up / slide
|
// Path 5: Contact — sphere_intersects_poly + wall-slide
|
||||||
// ACE transforms collision normal from local→global before step_up/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))
|
if (obj.State.HasFlag(ObjectInfoState.Contact))
|
||||||
{
|
{
|
||||||
ResolvedPolygon? hitPoly0 = null;
|
ResolvedPolygon? hitPoly0 = null;
|
||||||
Vector3 contact0 = Vector3.Zero;
|
Vector3 contact0 = Vector3.Zero;
|
||||||
|
|
||||||
if (SphereIntersectsPolyInternal(root, resolved, sphere0, movement,
|
bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement,
|
||||||
ref hitPoly0, ref contact0))
|
ref hitPoly0, ref contact0);
|
||||||
|
|
||||||
|
if (hit0 || hitPoly0 is not null)
|
||||||
{
|
{
|
||||||
var worldNormal = L2W(hitPoly0!.Plane.Normal);
|
// Wall-slide response (same as Path 6 below).
|
||||||
return StepSphereUp(transition, worldNormal);
|
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)
|
if (sphere1 is not null)
|
||||||
|
|
@ -1473,17 +1500,34 @@ public static class BSPQuery
|
||||||
ResolvedPolygon? hitPoly1 = null;
|
ResolvedPolygon? hitPoly1 = null;
|
||||||
Vector3 contact1 = Vector3.Zero;
|
Vector3 contact1 = Vector3.Zero;
|
||||||
|
|
||||||
if (SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
|
bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
|
||||||
ref hitPoly1, ref contact1))
|
ref hitPoly1, ref contact1);
|
||||||
{
|
|
||||||
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
|
||||||
return SlideSphere(transition, worldNormal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hitPoly1 is not null)
|
if (hit1 || hitPoly1 is not null)
|
||||||
return NegPolyHitDispatch(path, hitPoly1, false, localToWorld);
|
{
|
||||||
if (hitPoly0 is not null)
|
var localNormal = hitPoly1!.Plane.Normal;
|
||||||
return NegPolyHitDispatch(path, hitPoly0, true, localToWorld);
|
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;
|
return TransitionState.OK;
|
||||||
|
|
@ -1509,11 +1553,50 @@ public static class BSPQuery
|
||||||
hitPoly0!, contact0, scale, localToWorld);
|
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.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||||
path.Collide = true;
|
|
||||||
collisions.SetCollisionNormal(worldNormal);
|
collisions.SetCollisionNormal(worldNormal);
|
||||||
return TransitionState.Adjusted;
|
collisions.SetSlidingNormal(worldNormal);
|
||||||
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sphere1 is not null)
|
if (sphere1 is not null)
|
||||||
|
|
@ -1526,9 +1609,29 @@ public static class BSPQuery
|
||||||
|
|
||||||
if (hit1 || hitPoly1 is not null)
|
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);
|
collisions.SetCollisionNormal(worldNormal);
|
||||||
return TransitionState.Collided;
|
collisions.SetSlidingNormal(worldNormal);
|
||||||
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,32 @@ namespace AcDream.Core.Physics;
|
||||||
public sealed class PhysicsDataCache
|
public sealed class PhysicsDataCache
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
|
private readonly ConcurrentDictionary<uint, GfxObjPhysics> _gfxObj = new();
|
||||||
|
private readonly ConcurrentDictionary<uint, GfxObjVisualBounds> _visualBounds = new();
|
||||||
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
|
private readonly ConcurrentDictionary<uint, SetupPhysics> _setup = new();
|
||||||
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
|
private readonly ConcurrentDictionary<uint, CellPhysics> _cellStruct = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extract and cache the physics BSP + polygon data from a GfxObj.
|
/// 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.
|
/// PLUS always cache a visual AABB from the vertex data regardless of
|
||||||
|
/// the HasPhysics flag. The visual AABB is used as a collision fallback
|
||||||
|
/// for entities whose Setup has no retail physics data — it lets the
|
||||||
|
/// user collide with decorative meshes that don't have a CylSphere or
|
||||||
|
/// per-part BSP.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void CacheGfxObj(uint gfxObjId, GfxObj gfxObj)
|
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.ContainsKey(gfxObjId)) return;
|
||||||
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
|
if (!gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)) return;
|
||||||
if (gfxObj.PhysicsBSP?.Root is null) return;
|
if (gfxObj.PhysicsBSP?.Root is null) return;
|
||||||
|
if (gfxObj.VertexArray is null) return;
|
||||||
|
|
||||||
_gfxObj[gfxObjId] = new GfxObjPhysics
|
_gfxObj[gfxObjId] = new GfxObjPhysics
|
||||||
{
|
{
|
||||||
|
|
@ -39,6 +53,58 @@ public sealed class PhysicsDataCache
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the cached visual AABB for a GfxObj, or null if not cached.
|
||||||
|
/// </summary>
|
||||||
|
public GfxObjVisualBounds? GetVisualBounds(uint gfxObjId) =>
|
||||||
|
_visualBounds.TryGetValue(gfxObjId, out var vb) ? vb : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compute a tight axis-aligned bounding box over all vertices in the mesh.
|
||||||
|
/// Used as a fallback collision shape for entities whose Setup has no
|
||||||
|
/// physics data — we approximate collision using the visual extent.
|
||||||
|
/// </summary>
|
||||||
|
private static GfxObjVisualBounds ComputeVisualBounds(VertexArray vertexArray)
|
||||||
|
{
|
||||||
|
if (vertexArray.Vertices == null || vertexArray.Vertices.Count == 0)
|
||||||
|
{
|
||||||
|
return new GfxObjVisualBounds
|
||||||
|
{
|
||||||
|
Min = Vector3.Zero,
|
||||||
|
Max = Vector3.Zero,
|
||||||
|
Center = Vector3.Zero,
|
||||||
|
Radius = 0f,
|
||||||
|
HalfExtents = Vector3.Zero,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var min = new Vector3(float.MaxValue);
|
||||||
|
var max = new Vector3(float.MinValue);
|
||||||
|
foreach (var kv in vertexArray.Vertices)
|
||||||
|
{
|
||||||
|
var p = kv.Value.Origin;
|
||||||
|
if (p.X < min.X) min.X = p.X;
|
||||||
|
if (p.Y < min.Y) min.Y = p.Y;
|
||||||
|
if (p.Z < min.Z) min.Z = p.Z;
|
||||||
|
if (p.X > max.X) max.X = p.X;
|
||||||
|
if (p.Y > max.Y) max.Y = p.Y;
|
||||||
|
if (p.Z > max.Z) max.Z = p.Z;
|
||||||
|
}
|
||||||
|
|
||||||
|
var center = (min + max) * 0.5f;
|
||||||
|
var halfExt = (max - min) * 0.5f;
|
||||||
|
float radius = halfExt.Length();
|
||||||
|
|
||||||
|
return new GfxObjVisualBounds
|
||||||
|
{
|
||||||
|
Min = min,
|
||||||
|
Max = max,
|
||||||
|
Center = center,
|
||||||
|
Radius = radius,
|
||||||
|
HalfExtents = halfExt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extract and cache the collision shape data from a Setup.
|
/// Extract and cache the collision shape data from a Setup.
|
||||||
/// No-ops if the id is already cached.
|
/// No-ops if the id is already cached.
|
||||||
|
|
@ -145,6 +211,26 @@ public sealed class PhysicsDataCache
|
||||||
public int CellStructCount => _cellStruct.Count;
|
public int CellStructCount => _cellStruct.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visual AABB of a GfxObj mesh — populated for every cached GfxObj regardless
|
||||||
|
/// of whether it has physics data. Used as a collision fallback shape for
|
||||||
|
/// entities whose Setup has no CylSpheres/Spheres/Radius (pure decorative
|
||||||
|
/// meshes). Provides an approximate cylinder matching the visible mesh extent.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GfxObjVisualBounds
|
||||||
|
{
|
||||||
|
/// <summary>Local-space minimum corner of the mesh AABB.</summary>
|
||||||
|
public required Vector3 Min { get; init; }
|
||||||
|
/// <summary>Local-space maximum corner of the mesh AABB.</summary>
|
||||||
|
public required Vector3 Max { get; init; }
|
||||||
|
/// <summary>Center of the local-space AABB.</summary>
|
||||||
|
public required Vector3 Center { get; init; }
|
||||||
|
/// <summary>Local-space radius (diagonal half-length) — loose bound.</summary>
|
||||||
|
public required float Radius { get; init; }
|
||||||
|
/// <summary>Local-space half-extents ((Max - Min) * 0.5).</summary>
|
||||||
|
public required Vector3 HalfExtents { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A physics polygon with pre-resolved vertex positions and pre-computed plane.
|
/// 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
|
/// ACE pre-computes these in its Polygon constructor; we do it at cache time
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,13 @@ public sealed class ShadowObjectRegistry
|
||||||
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
|
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
|
||||||
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
|
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
|
||||||
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
|
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
|
||||||
float cylHeight = 0f)
|
float cylHeight = 0f, float scale = 1.0f)
|
||||||
{
|
{
|
||||||
Deregister(entityId);
|
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 localX = worldPos.X - worldOffsetX;
|
||||||
float localY = worldPos.Y - worldOffsetY;
|
float localY = worldPos.Y - worldOffsetY;
|
||||||
|
|
||||||
|
|
@ -35,7 +38,7 @@ public sealed class ShadowObjectRegistry
|
||||||
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
|
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
|
||||||
int maxCy = Math.Min(7, (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>();
|
var cellIds = new List<uint>();
|
||||||
|
|
||||||
uint lbPrefix = landblockId & 0xFFFF0000u;
|
uint lbPrefix = landblockId & 0xFFFF0000u;
|
||||||
|
|
@ -166,6 +169,24 @@ public sealed class ShadowObjectRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
public int TotalRegistered => _entityToCells.Count;
|
public int TotalRegistered => _entityToCells.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
|
||||||
|
/// For each entity, returns the first entry found in any cell it occupies.
|
||||||
|
/// Intended for debug rendering only.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<ShadowEntry> AllEntriesForDebug()
|
||||||
|
{
|
||||||
|
var seen = new HashSet<uint>();
|
||||||
|
foreach (var kvp in _cells)
|
||||||
|
{
|
||||||
|
foreach (var entry in kvp.Value)
|
||||||
|
{
|
||||||
|
if (seen.Add(entry.EntityId))
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -181,4 +202,5 @@ public readonly record struct ShadowEntry(
|
||||||
Quaternion Rotation,
|
Quaternion Rotation,
|
||||||
float Radius,
|
float Radius,
|
||||||
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
|
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
|
||||||
float CylHeight = 0f);
|
float CylHeight = 0f,
|
||||||
|
float Scale = 1.0f);
|
||||||
|
|
|
||||||
|
|
@ -353,15 +353,13 @@ public sealed class Transition
|
||||||
|
|
||||||
for (int i = 0; i < numSteps; i++)
|
for (int i = 0; i < numSteps; i++)
|
||||||
{
|
{
|
||||||
// Reset per-step collision state.
|
// Per ACE order: AdjustOffset FIRST (uses state from previous step),
|
||||||
CollisionInfo.SlidingNormalValid = false;
|
// THEN clear the state. This lets the sliding/contact normals from
|
||||||
CollisionInfo.ContactPlaneValid = false;
|
// the previous step's collision project the current step's offset.
|
||||||
CollisionInfo.ContactPlaneIsWater = false;
|
|
||||||
|
|
||||||
// Project the step offset through any existing contact / slide plane.
|
|
||||||
sp.GlobalOffset = AdjustOffset(offsetPerStep);
|
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)
|
if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq)
|
||||||
return i != 0 && transitionState == TransitionState.OK;
|
return i != 0 && transitionState == TransitionState.OK;
|
||||||
|
|
||||||
|
|
@ -372,6 +370,12 @@ public sealed class Transition
|
||||||
sp.CheckOrientation = Quaternion.Slerp(sp.BeginOrientation, sp.EndOrientation, delta);
|
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.
|
// Apply the offset, then check collisions.
|
||||||
sp.AddOffsetToCheckPos(sp.GlobalOffset);
|
sp.AddOffsetToCheckPos(sp.GlobalOffset);
|
||||||
|
|
||||||
|
|
@ -391,108 +395,155 @@ public sealed class Transition
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Check collisions at the current CheckPos, apply step-down as needed.
|
/// ACE Transition.TransitionalInsert — retry loop for collision resolution.
|
||||||
/// Ported from pseudocode section 3 (TransitionalInsert).
|
///
|
||||||
/// ACE: Transition.TransitionalInsert(int num_insertion_attempts).
|
/// <para>
|
||||||
|
/// Per ACE: iterate up to numAttempts times. Each iteration runs the full
|
||||||
|
/// collision pipeline (env + objects) at the current CheckPos. The pipeline
|
||||||
|
/// can MUTATE CheckPos (push-out, slide). On Slid/Adjusted, clear state and
|
||||||
|
/// retry — the next iteration tests the NEW CheckPos against all nearby
|
||||||
|
/// objects again, which catches "slide into a second wall" corner cases.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Return values:
|
||||||
|
/// - OK: no collision OR all collisions resolved without leaving anything unhandled
|
||||||
|
/// - Collided: hard stop; no further movement possible
|
||||||
|
/// - Slid: last iteration slid (only if we exhausted retry attempts)
|
||||||
|
/// - Adjusted: last iteration adjusted (rare — retry should convert to OK)
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// This is simplified from ACE: we don't have CellArray/CheckOtherCells
|
||||||
|
/// iteration because our FindObjCollisions (via ShadowObjectRegistry) is
|
||||||
|
/// already a flat per-landblock query. That's the equivalent of iterating
|
||||||
|
/// objects across all relevant cells.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private TransitionState TransitionalInsert(int maxAttempts, PhysicsEngine engine)
|
private TransitionState TransitionalInsert(int numAttempts, PhysicsEngine engine)
|
||||||
{
|
{
|
||||||
if (SpherePath.CheckCellId == 0) return TransitionState.OK;
|
if (SpherePath.CheckCellId == 0) return TransitionState.OK;
|
||||||
if (maxAttempts <= 0) return TransitionState.Invalid;
|
if (numAttempts <= 0) return TransitionState.Invalid;
|
||||||
|
|
||||||
var sp = SpherePath;
|
var sp = SpherePath;
|
||||||
var ci = CollisionInfo;
|
var ci = CollisionInfo;
|
||||||
var oi = ObjectInfo;
|
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);
|
transitState = FindEnvCollisions(engine);
|
||||||
|
|
||||||
switch (transitState)
|
if (transitState == TransitionState.Collided)
|
||||||
|
return TransitionState.Collided;
|
||||||
|
|
||||||
|
if (transitState == TransitionState.Slid)
|
||||||
{
|
{
|
||||||
case TransitionState.OK:
|
// Env collision slid the sphere. Clear state and retry at
|
||||||
// Outdoor path: no neighboring cell enumeration needed for MVP.
|
// the new CheckPos to see if we hit anything else.
|
||||||
break;
|
ci.ContactPlaneValid = false;
|
||||||
|
ci.ContactPlaneIsWater = false;
|
||||||
case TransitionState.Collided:
|
sp.NegPolyHit = false;
|
||||||
return TransitionState.Collided;
|
continue;
|
||||||
|
|
||||||
case TransitionState.Adjusted:
|
|
||||||
sp.NegPolyHit = false;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case TransitionState.Slid:
|
|
||||||
ci.ContactPlaneValid = false;
|
|
||||||
ci.ContactPlaneIsWater = false;
|
|
||||||
sp.NegPolyHit = false;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1b: check object (static BSP) collisions when OK so far.
|
if (transitState == TransitionState.Adjusted)
|
||||||
if (transitState == TransitionState.OK)
|
|
||||||
{
|
{
|
||||||
var objState = FindObjCollisions(engine);
|
// Env modified CheckPos. Retry at new position.
|
||||||
if (objState == TransitionState.Slid)
|
sp.NegPolyHit = false;
|
||||||
{
|
continue;
|
||||||
transitState = TransitionState.Slid;
|
|
||||||
ci.ContactPlaneValid = false;
|
|
||||||
ci.ContactPlaneIsWater = false;
|
|
||||||
sp.NegPolyHit = false;
|
|
||||||
}
|
|
||||||
else if (objState == TransitionState.Collided)
|
|
||||||
{
|
|
||||||
return TransitionState.Collided;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: post-collision response.
|
// ── Phase 2: object (static BSP + cylinder) collision ───────
|
||||||
if (transitState == TransitionState.OK)
|
// 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.
|
// Object collision applied a push-out and set sliding normal.
|
||||||
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
|
// Retry at the new CheckPos — we may have slid into another
|
||||||
&& sp.CheckCellId != 0 && oi.StepDown)
|
// 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;
|
if (DoStepDown(stepDownHeight, zVal, engine))
|
||||||
float stepDownHeight = oi.StepDownHeight;
|
|
||||||
sp.WalkableAllowance = zVal;
|
|
||||||
sp.SaveCheckPos();
|
|
||||||
|
|
||||||
float radsum = sp.GlobalSphere[0].Radius * 2f;
|
|
||||||
|
|
||||||
if (radsum >= stepDownHeight)
|
|
||||||
{
|
{
|
||||||
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
|
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
|
// Reused per-call to avoid per-step allocation; safe because Transition
|
||||||
// is single-threaded per movement resolve.
|
// is single-threaded per movement resolve.
|
||||||
private readonly List<ShadowEntry> _nearbyObjs = new();
|
private readonly List<ShadowEntry> _nearbyObjs = new();
|
||||||
|
private static int _debugQueryCount = 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Query the ShadowObjectRegistry for nearby static objects and run
|
/// Query the ShadowObjectRegistry for nearby static objects and run
|
||||||
|
|
@ -698,6 +750,16 @@ public sealed class Transition
|
||||||
worldOffsetX, worldOffsetY, landblockId,
|
worldOffsetX, worldOffsetY, landblockId,
|
||||||
_nearbyObjs);
|
_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)
|
foreach (var obj in _nearbyObjs)
|
||||||
{
|
{
|
||||||
// Broad-phase: can the moving sphere reach this object?
|
// Broad-phase: can the moving sphere reach this object?
|
||||||
|
|
@ -721,23 +783,28 @@ public sealed class Transition
|
||||||
if (physics?.BSP?.Root is null) continue;
|
if (physics?.BSP?.Root is null) continue;
|
||||||
|
|
||||||
// Transform player spheres to object-local space.
|
// 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);
|
var invRot = Quaternion.Inverse(obj.Rotation);
|
||||||
|
float invScale = obj.Scale > 0 ? 1.0f / obj.Scale : 1.0f;
|
||||||
|
|
||||||
var localSphere0 = new DatReaderWriter.Types.Sphere
|
var localSphere0 = new DatReaderWriter.Types.Sphere
|
||||||
{
|
{
|
||||||
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot),
|
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot) * invScale,
|
||||||
Radius = sp.GlobalSphere[0].Radius,
|
Radius = sp.GlobalSphere[0].Radius * invScale,
|
||||||
};
|
};
|
||||||
var localCurrCenter = Vector3.Transform(
|
var localCurrCenter = Vector3.Transform(
|
||||||
sp.GlobalCurrCenter[0].Origin - obj.Position, invRot);
|
sp.GlobalCurrCenter[0].Origin - obj.Position, invRot) * invScale;
|
||||||
|
|
||||||
DatReaderWriter.Types.Sphere? localSphere1 = null;
|
DatReaderWriter.Types.Sphere? localSphere1 = null;
|
||||||
if (sp.NumSphere > 1)
|
if (sp.NumSphere > 1)
|
||||||
{
|
{
|
||||||
localSphere1 = new DatReaderWriter.Types.Sphere
|
localSphere1 = new DatReaderWriter.Types.Sphere
|
||||||
{
|
{
|
||||||
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot),
|
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot) * invScale,
|
||||||
Radius = sp.GlobalSphere[1].Radius,
|
Radius = sp.GlobalSphere[1].Radius * invScale,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -745,9 +812,8 @@ public sealed class Transition
|
||||||
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
|
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
|
||||||
|
|
||||||
// Use the retail 6-path dispatcher with pre-resolved polygons.
|
// Use the retail 6-path dispatcher with pre-resolved polygons.
|
||||||
// Pass the object's rotation so collision responses (normals,
|
// Pass the object's scale so collision response offsets (in
|
||||||
// offsets) are transformed from object-local back to world space.
|
// unscaled local space) are multiplied back to world space.
|
||||||
// ACE: path.LocalSpacePos.LocalToGlobalVec()
|
|
||||||
result = BSPQuery.FindCollisions(
|
result = BSPQuery.FindCollisions(
|
||||||
physics.BSP.Root,
|
physics.BSP.Root,
|
||||||
physics.Resolved,
|
physics.Resolved,
|
||||||
|
|
@ -756,7 +822,7 @@ public sealed class Transition
|
||||||
localSphere1,
|
localSphere1,
|
||||||
localCurrCenter,
|
localCurrCenter,
|
||||||
localSpaceZ,
|
localSpaceZ,
|
||||||
1.0f, // scale = 1.0 for object geometry
|
obj.Scale, // scale for local→world offsets
|
||||||
obj.Rotation); // local→world rotation
|
obj.Rotation); // local→world rotation
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -776,9 +842,9 @@ public sealed class Transition
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cylinder swept-sphere collision test for CylSphere objects (trees, rocks, etc.).
|
/// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs).
|
||||||
/// Performs a 2D ray-circle intersection to find contact time, then applies
|
/// Applies a horizontal wall-slide response when the sphere overlaps the
|
||||||
/// a wall-slide response.
|
/// cylinder, matching the BSP path 5/6 response for consistent behavior.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp)
|
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp)
|
||||||
{
|
{
|
||||||
|
|
@ -788,51 +854,87 @@ public sealed class Transition
|
||||||
float sphRadius = sp.GlobalSphere[0].Radius;
|
float sphRadius = sp.GlobalSphere[0].Radius;
|
||||||
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
|
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
|
||||||
|
|
||||||
Vector3 deltaCurr = sphereCurrPos - obj.Position;
|
// Vertical check: does sphere reach the cylinder's height range at all?
|
||||||
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;
|
|
||||||
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
|
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
|
||||||
float playerBottom = contactPos.Z - sphRadius;
|
float checkZ = sphereCheckPos.Z;
|
||||||
float playerTop = contactPos.Z + sphRadius;
|
if (checkZ - sphRadius > obj.Position.Z + cylTop ||
|
||||||
if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
|
checkZ + sphRadius < obj.Position.Z)
|
||||||
return TransitionState.OK;
|
return TransitionState.OK;
|
||||||
|
|
||||||
// Collision normal: radial from cylinder axis.
|
// XY distance from sphere check position to cylinder axis.
|
||||||
Vector3 contactDelta = contactPos - obj.Position;
|
float dxCheck = sphereCheckPos.X - obj.Position.X;
|
||||||
float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
|
float dyCheck = sphereCheckPos.Y - obj.Position.Y;
|
||||||
Vector3 collisionNormal;
|
float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck;
|
||||||
if (hDist < PhysicsGlobals.EPSILON)
|
float combinedR = sphRadius + obj.Radius;
|
||||||
collisionNormal = Vector3.UnitX;
|
float combinedRSq = combinedR * combinedR;
|
||||||
else
|
|
||||||
collisionNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
|
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);
|
ci.SetCollisionNormal(collisionNormal);
|
||||||
return SlideSphere(collisionNormal, sphereCurrPos);
|
ci.SetSlidingNormal(collisionNormal);
|
||||||
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,14 @@ public static class SceneryGenerator
|
||||||
uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock
|
uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock
|
||||||
uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
|
uint blockY = ((landblockId >> 16) & 0xFFu) * 8;
|
||||||
|
|
||||||
// The original iterates Terrain[0..80] — 81 vertices of a 9x9 grid.
|
// RETAIL iterates 8×8 = 64 CELLS, not 9×9 = 81 vertices.
|
||||||
// The heightmap is packed x-major (Height[x*9+y]), so we match that here.
|
// Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses
|
||||||
for (int x = 0; x < VerticesPerSide; x++)
|
// `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;
|
int i = x * VerticesPerSide + y;
|
||||||
ushort raw = block.Terrain[i];
|
ushort raw = block.Terrain[i];
|
||||||
|
|
@ -84,14 +87,12 @@ public static class SceneryGenerator
|
||||||
uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6
|
uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6
|
||||||
uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15
|
uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15
|
||||||
|
|
||||||
// Skip road vertices: bits 0-1 of the terrain word encode the road
|
// NOTE: retail does NOT skip based on this vertex's road bit.
|
||||||
// type (non-zero means this vertex is on a road). Ported from
|
// The road test happens AFTER displacement via the 4-corner
|
||||||
// ACViewer Physics/Common/Landblock.cs GetRoad() and the OnRoad()
|
// polygonal OnRoad check (see below). Removing the
|
||||||
// check in get_land_scenes(). Roads should not have trees/rocks.
|
// pre-displacement early-exit restores retail behavior.
|
||||||
if (IsRoadVertex(raw)) continue;
|
|
||||||
|
|
||||||
// Skip cells that contain buildings (ACME conformance fix 4d).
|
// Skip cells that contain buildings.
|
||||||
// Building footprints shouldn't have scenery spawning inside them.
|
|
||||||
if (buildingCells is not null && buildingCells.Contains(i)) continue;
|
if (buildingCells is not null && buildingCells.Contains(i)) continue;
|
||||||
|
|
||||||
if (terrainType >= region.TerrainInfo.TerrainTypes.Count) 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)
|
if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Check if the final displaced position lands on a road vertex.
|
// Retail post-displacement road check (FUN_00530d30).
|
||||||
// The road status is per-vertex (9×9 grid); sample the nearest
|
// Ported from ACViewer Landblock.OnRoad — uses the 4-corner
|
||||||
// vertex to the displaced position to catch scenery that drifted
|
// road bits of the containing cell plus the 5-unit road
|
||||||
// from a non-road vertex onto a 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);
|
continue;
|
||||||
int nearY = Math.Clamp((int)(ly / CellSize + 0.5f), 0, VerticesPerSide - 1);
|
}
|
||||||
ushort nearRaw = block.Terrain[nearX * VerticesPerSide + nearY];
|
|
||||||
if (IsRoadVertex(nearRaw)) 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
|
// 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;
|
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)
|
// Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60)
|
||||||
// offset constant 0xf697 = 63127
|
// Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation
|
||||||
// iVar2 = (param_3 * 0x6c1ac587 - (param_2 * param_3 * 0x5111bfef + 0x70892fb7) * (param_4 + 0xf697))
|
// into the frame, THEN calls AFrame::set_heading(degrees).
|
||||||
// + param_2 * -0x421be3bd
|
//
|
||||||
// param_2=ix, param_3=iy, param_4=j
|
// set_heading uses yaw = -(450 - heading) % 360 before converting
|
||||||
Quaternion rotation = Quaternion.Identity;
|
// to a quaternion, which introduces a 90° offset + sign flip
|
||||||
if (obj.MaxRotation > 0)
|
// 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
|
double rotNoise = unchecked((uint)(1813693831u * globalCellY
|
||||||
- (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u)
|
- (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u)
|
||||||
- 1109124029u * globalCellX)) * 2.3283064e-10;
|
- 1109124029u * globalCellX)) * 2.3283064e-10;
|
||||||
float degrees = (float)(rotNoise * obj.MaxRotation);
|
float degrees = (float)(rotNoise * obj.MaxRotation);
|
||||||
float radians = degrees * MathF.PI / 180f;
|
// AFrame::set_heading transform — matches retail.
|
||||||
rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, radians);
|
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)
|
// Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern)
|
||||||
|
|
@ -237,6 +270,121 @@ public static class SceneryGenerator
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
|
public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Half-width of a road ribbon in world units — the road extends from each
|
||||||
|
/// road vertex by this amount into the neighbor cells. Matches retail's
|
||||||
|
/// `_DAT_007c9cc0 = 5.0f` in FUN_00530d30.
|
||||||
|
/// </summary>
|
||||||
|
private const float RoadHalfWidth = 5.0f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail-faithful post-displacement road test. Ported from ACViewer
|
||||||
|
/// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which is
|
||||||
|
/// a direct port of FUN_00530d30 in the retail client.
|
||||||
|
///
|
||||||
|
/// Examines the 4 corners of the cell containing (lx, ly) and, depending
|
||||||
|
/// on how many are road vertices (0, 1, 2, 3, or 4), applies a polygonal
|
||||||
|
/// test using the 5-unit road half-width to check if (lx, ly) lies on the
|
||||||
|
/// road ribbon. Returns true if the point is on a road.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Retail-faithful road ribbon test — direct port of ACViewer's
|
||||||
|
/// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which
|
||||||
|
/// itself is a port of FUN_00530d30 in acclient.exe.
|
||||||
|
///
|
||||||
|
/// Classifies the 4 corners of the cell containing (lx, ly) by road type
|
||||||
|
/// (bits 0-1 of the terrain word) and applies a different geometric test
|
||||||
|
/// based on which corners are road vertices. Road ribbons have a 5m
|
||||||
|
/// half-width (TileLength - RoadWidth = 19m).
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsOnRoad(LandBlock block, float lx, float ly)
|
||||||
|
{
|
||||||
|
int x = (int)MathF.Floor(lx / CellSize);
|
||||||
|
int y = (int)MathF.Floor(ly / CellSize);
|
||||||
|
// Clamp so we don't index past the 9x9 terrain grid
|
||||||
|
x = Math.Clamp(x, 0, CellsPerSide - 1);
|
||||||
|
y = Math.Clamp(y, 0, CellsPerSide - 1);
|
||||||
|
|
||||||
|
float rMin = RoadHalfWidth; // 5
|
||||||
|
float rMax = CellSize - RoadHalfWidth; // 19
|
||||||
|
|
||||||
|
// Corner road bits (ACViewer convention):
|
||||||
|
// r0 = (x0, y0) = SW
|
||||||
|
// r1 = (x0, y1) = NW
|
||||||
|
// r2 = (x1, y0) = SE
|
||||||
|
// r3 = (x1, y1) = NE
|
||||||
|
bool r0 = IsRoadVertex(block.Terrain[x * VerticesPerSide + y]);
|
||||||
|
bool r1 = IsRoadVertex(block.Terrain[x * VerticesPerSide + (y + 1)]);
|
||||||
|
bool r2 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + y]);
|
||||||
|
bool r3 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + (y + 1)]);
|
||||||
|
|
||||||
|
if (!r0 && !r1 && !r2 && !r3) return false;
|
||||||
|
|
||||||
|
float dx = lx - x * CellSize;
|
||||||
|
float dy = ly - y * CellSize;
|
||||||
|
|
||||||
|
if (r0)
|
||||||
|
{
|
||||||
|
if (r1)
|
||||||
|
{
|
||||||
|
if (r2)
|
||||||
|
{
|
||||||
|
if (r3) return true;
|
||||||
|
return dx < rMin || dy < rMin;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (r3) return dx < rMin || dy > rMax;
|
||||||
|
return dx < rMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (r2)
|
||||||
|
{
|
||||||
|
if (r3) return dx > rMax || dy < rMin;
|
||||||
|
return dy < rMin;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (r3) return MathF.Abs(dx - dy) < rMin;
|
||||||
|
return dx + dy < rMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (r1)
|
||||||
|
{
|
||||||
|
if (r2)
|
||||||
|
{
|
||||||
|
if (r3) return dx > rMax || dy > rMax;
|
||||||
|
return MathF.Abs(dx + dy - CellSize) < rMin;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (r3) return dy > rMax;
|
||||||
|
return CellSize + dx - dy < rMin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (r2)
|
||||||
|
{
|
||||||
|
if (r3) return dx > rMax;
|
||||||
|
return CellSize - dx + dy < rMin;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (r3) return CellSize * 2f - dx - dy < rMin;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int CellsPerSide = 8;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pseudo-random displacement within a cell for a scenery object. Returns a
|
/// 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
|
/// Vector3 in local cell-offset space (the caller adds it to the cell corner
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,16 @@ public sealed class WorldEntity
|
||||||
/// Null for outdoor entities (stabs, scenery, live server spawns).
|
/// Null for outdoor entities (stabs, scenery, live server spawns).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint? ParentCellId { get; init; }
|
public uint? ParentCellId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uniform scale applied to this entity's mesh by the scenery pipeline.
|
||||||
|
/// For scenery objects this is spawn.Scale (typically 0.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.
|
||||||
|
/// </summary>
|
||||||
|
public float Scale { get; init; } = 1.0f;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue