Compare commits
47 commits
1978ef9395
...
c1e31148bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e31148bb | ||
|
|
dd5ca3d2b2 | ||
|
|
c44536451d | ||
|
|
573526dae5 | ||
|
|
7b41efc281 | ||
|
|
943652dc97 | ||
|
|
fc80c252d6 | ||
|
|
5df9135e0e | ||
|
|
01cff4144f | ||
|
|
5b4fd4b61d | ||
|
|
16a36dda8f | ||
|
|
831f7d416b | ||
|
|
d30fcb2eb0 | ||
|
|
312d3b3ee0 | ||
|
|
c02c307bee | ||
|
|
ce72c574e9 | ||
|
|
9e1992e8a3 | ||
|
|
36f7a601c4 | ||
|
|
bf53cb4fce | ||
|
|
f4f0101d2c | ||
|
|
931a690c4c | ||
|
|
669768d9da | ||
|
|
dc6410b56f | ||
|
|
4f318bcbba | ||
|
|
05a458254a | ||
|
|
c49c6edde5 | ||
|
|
4ad7a985cf | ||
|
|
b1d48fac94 | ||
|
|
3d111e473e | ||
|
|
502c3a87e4 | ||
|
|
1030c69b3c | ||
|
|
ed73fc5040 | ||
|
|
46deed6019 | ||
|
|
81b5ed8c68 | ||
|
|
076a324eca | ||
|
|
506b86ba86 | ||
|
|
9bb6b254dc | ||
|
|
0fb93171e4 | ||
|
|
6d42744936 | ||
|
|
82a003cc65 | ||
|
|
1ede87a135 | ||
|
|
13132f9a5e | ||
|
|
c189ec0c40 | ||
|
|
8d166afc62 | ||
|
|
d467c4cf24 | ||
|
|
0a67254c5e | ||
|
|
2a491c6f92 |
40 changed files with 7940 additions and 166 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -29,6 +29,7 @@ references/*
|
|||
launch.log
|
||||
launch-*.log
|
||||
launch.utf8.log
|
||||
n4-verify*.log
|
||||
|
||||
# ImGui auto-saved window/docking state (per-user, not source)
|
||||
imgui.ini
|
||||
|
|
|
|||
65
CLAUDE.md
65
CLAUDE.md
|
|
@ -25,19 +25,54 @@ single source of truth for how the client is structured. All work must
|
|||
align with this document. When the architecture doc and reality diverge,
|
||||
update one or the other — never leave them out of sync.
|
||||
|
||||
**WorldBuilder is acdream's rendering + dat-handling base** as of
|
||||
2026-05-08. Before re-implementing any AC-specific rendering or
|
||||
dat-handling algorithm, **read `docs/architecture/worldbuilder-inventory.md`
|
||||
FIRST**. If WorldBuilder has it, port from WorldBuilder (or call into
|
||||
our fork once wired up), not from retail decomp. WorldBuilder is
|
||||
MIT-licensed, verified to render the world correctly, and uses the same
|
||||
Silk.NET stack we target. Re-porting from retail decomp when WB already
|
||||
has a tested port is how subtle bugs (the scenery edge-vertex bug, the
|
||||
**WorldBuilder is acdream's rendering + dat-handling base, integrated
|
||||
as of Phase N.4 ship (2026-05-08).** WB's `ObjectMeshManager` is the
|
||||
production mesh pipeline; `WbMeshAdapter` is the seam; `WbDrawDispatcher`
|
||||
is the production draw path (default-on, see `WbFoundationFlag`). Before
|
||||
re-implementing any AC-specific rendering or dat-handling algorithm,
|
||||
**read `docs/architecture/worldbuilder-inventory.md` FIRST**. If
|
||||
WorldBuilder has it, port from WorldBuilder (or call into our fork via
|
||||
the adapter), not from retail decomp. WorldBuilder is MIT-licensed,
|
||||
verified to render the world correctly, and uses the same Silk.NET
|
||||
stack we target. Re-porting from retail decomp when WB already has a
|
||||
tested port is how subtle bugs (the scenery edge-vertex bug, the
|
||||
triangle-Z bug) keep slipping in. Retail decomp remains the oracle for
|
||||
network, physics, animation, movement, UI, plugin, audio, chat — see
|
||||
the inventory doc's 🔴 list for the full scope of "we still write this
|
||||
ourselves".
|
||||
|
||||
**WB integration cribs:**
|
||||
- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` — single seam over WB's
|
||||
`ObjectMeshManager`. Owns the WB pipeline, drains its staged-upload
|
||||
queue per frame via `Tick()`, populates `AcSurfaceMetadataTable` with
|
||||
per-batch translucency / luminosity / fog metadata.
|
||||
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — production draw
|
||||
path. Groups all visible (entity, batch) pairs, single-uploads the
|
||||
matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance`
|
||||
per group with `BaseInstance` pointing at the slice. Per-entity
|
||||
frustum cull, opaque front-to-back sort, palette-hash memoization.
|
||||
- `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` /
|
||||
`EntitySpawnAdapter.cs` — bridge spawn lifecycle to WB ref-counts.
|
||||
Atlas tier (procedural) goes via Landblock; per-instance tier
|
||||
(server-spawned, palette/texture overrides) goes via Entity.
|
||||
- `WbFoundationFlag` is default-on. `ACDREAM_USE_WB_FOUNDATION=0`
|
||||
falls back to legacy `InstancedMeshRenderer` (kept as escape hatch
|
||||
until N.6 fully retires it).
|
||||
- **WB's modern rendering path** (GL 4.3 + bindless) packs every mesh
|
||||
into a single global VAO/VBO/IBO. Each batch references its slice
|
||||
via `FirstIndex` (offset into IBO) + `BaseVertex` (offset into VBO).
|
||||
Honor those offsets when issuing draws — `DrawElementsInstanced`
|
||||
with `indices=0` will draw every entity's first triangle from the
|
||||
global mesh, not the per-batch range. (This is exactly the
|
||||
exploded-character bug we hit during Task 26.)
|
||||
- **WB's `ObjectRenderBatch.SurfaceId` is unset** — the actual surface
|
||||
id lives in `batch.Key.SurfaceId` (the `TextureKey` struct).
|
||||
- **`ObjectMeshManager.IncrementRefCount` only bumps a counter** — it
|
||||
does NOT trigger mesh loading. You must explicitly call
|
||||
`PrepareMeshDataAsync(id, isSetup)` to fire the background decode.
|
||||
Result auto-enqueues to `_stagedMeshData` which `Tick()` drains.
|
||||
`WbMeshAdapter` does this for you on first registration.
|
||||
|
||||
**Execution phases:** R1→R8 in the architecture doc. Each phase has clear
|
||||
goals, test criteria, and builds on the previous. Don't skip phases.
|
||||
|
||||
|
|
@ -437,6 +472,20 @@ acdream's plan lives in two files committed to the repo:
|
|||
acceptance criteria. Do not drift from the spec without explicit user
|
||||
approval.
|
||||
|
||||
**Currently in flight: Phase N.5 — Modern Rendering Path.** Roadmap entry
|
||||
at [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md).
|
||||
Builds on N.4's `WbDrawDispatcher` to adopt WB's modern rendering primitives:
|
||||
bindless textures (eliminate `glBindTexture` calls) and
|
||||
`glMultiDrawElementsIndirect` (one GL call per pass instead of one per
|
||||
group). Together these target a 2-5× CPU win on draw-heavy scenes by
|
||||
eliminating the remaining per-group state changes. Plan + spec to be
|
||||
written when work begins.
|
||||
|
||||
**Phase N.4 (Rendering Pipeline Foundation) shipped 2026-05-08.** WB's
|
||||
`ObjectMeshManager` is integrated and is the default rendering path
|
||||
behind `ACDREAM_USE_WB_FOUNDATION` (default-on). Plan archived at
|
||||
[`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md).
|
||||
|
||||
**Rules:**
|
||||
|
||||
1. Before starting a new phase or sub-piece, re-read the roadmap and the
|
||||
|
|
|
|||
|
|
@ -46,6 +46,63 @@ Copy this block when adding a new issue:
|
|||
|
||||
# Active issues
|
||||
|
||||
## #51 — WB's terrain-split formula diverges from retail's `FSplitNESW`
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (blocks isolated N.2; affects sequencing of N-phase migration)
|
||||
**Filed:** 2026-05-08
|
||||
**Component:** terrain math / Phase N (WorldBuilder rendering migration)
|
||||
|
||||
**Description:** WB's `TerrainUtils.CalculateSplitDirection`
|
||||
([references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44](references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44))
|
||||
uses a different math expression from retail's `FSplitNESW`
|
||||
(documented in CLAUDE.md as **the** real AC terrain split formula,
|
||||
constants `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25`).
|
||||
Ours is a degree-2 polynomial in (x,y); WB's is linear in (x,y).
|
||||
They cannot be algebraically equivalent and disagree on a meaningful
|
||||
fraction of cells.
|
||||
|
||||
**Concrete impact:** On any cell where the formulas pick different
|
||||
diagonals, the same world position (X, Y) maps to different terrain
|
||||
heights — up to ~2m for a sloped cell with one elevated corner. If a
|
||||
caller mixes "WB-formula path" and "AC2D-formula path" for the same
|
||||
cell, the player physics floats above or sinks below the visible
|
||||
ground. This is the bug class fixed in
|
||||
[src/AcDream.Core/Physics/TerrainSurface.cs:113-120](src/AcDream.Core/Physics/TerrainSurface.cs:113)
|
||||
(diagonal-direction inversion).
|
||||
|
||||
**Files implicated:**
|
||||
- `src/AcDream.Core/Physics/TerrainSurface.cs` — uses AC2D formula via
|
||||
`IsSplitSWtoNE`
|
||||
- `src/AcDream.Core/World/TerrainBlending.cs` — visual mesh, also AC2D
|
||||
- `references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44`
|
||||
— WB's diverging formula
|
||||
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs`
|
||||
— WB's render mesh (presumably also uses WB's formula in lockstep)
|
||||
|
||||
**Sequencing implication:** Phase N.2 (terrain math helpers
|
||||
substitution) cannot be shipped in isolation — it must land alongside
|
||||
N.5 (visual terrain renderer migration), at which point both physics
|
||||
and visual mesh switch to WB's formula together. Roadmap N.2 entry
|
||||
flags this dependency.
|
||||
|
||||
**Research needed (when N.5 picks this up):**
|
||||
1. Quantify divergence: run WB's `CalculateSplitDirection` and our
|
||||
`IsSplitSWtoNE` across all (lbX, lbY, cellX, cellY) tuples for a
|
||||
representative landblock set; record disagreement rate.
|
||||
2. Confirm WB's `TerrainGeometryGenerator` uses WB's formula in its
|
||||
render mesh — if so, switching everything to WB's formula keeps
|
||||
visual + physics synced. (Highly likely.)
|
||||
3. Decide whether ANY retail-conformance test (e.g., physics matching
|
||||
server-authoritative Z within tolerance) is invalidated by the
|
||||
formula change.
|
||||
|
||||
**Acceptance:** Resolved when N.5 lands and both physics + visual
|
||||
mesh use WB's split formula, OR when we decide to keep the AC2D
|
||||
formula and patch WB's renderer in our fork.
|
||||
|
||||
---
|
||||
|
||||
## #50 — Road-edge tree at 0xA9B1 visible in acdream but not retail
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# acdream — strategic roadmap
|
||||
|
||||
**Status:** Living document. Updated 2026-05-02 for Phase M network-stack conformance planning.
|
||||
**Status:** Living document. Updated 2026-05-08 for Phase N.4 shipping (`WbMeshAdapter` + `WbDrawDispatcher` + `ACDREAM_USE_WB_FOUNDATION` default-on) + N.5 rebranded to "Modern rendering path" (bindless + multi-draw indirect on top of N.4's foundation).
|
||||
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
|
||||
|
||||
---
|
||||
|
|
@ -58,6 +58,8 @@
|
|||
| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |
|
||||
| C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ |
|
||||
| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ |
|
||||
| N.3 | WorldBuilder-backed texture decode — `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha → `FillA8Additive`, non-additive entity surfaces → `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Live ✓ |
|
||||
| N.4 | Rendering pipeline foundation — adopted WB's `ObjectMeshManager` as the production mesh pipeline behind `ACDREAM_USE_WB_FOUNDATION` (default-on). `WbMeshAdapter` is the single seam (owns `ObjectMeshManager`, drains the staged-upload queue per frame, populates `AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog metadata). `WbDrawDispatcher` is the production draw path: groups all visible (entity, batch) pairs, single-uploads the matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group with `BaseInstance` slicing into the shared instance VBO. `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge spawn lifecycle to WB ref-counts (atlas tier vs per-instance). Perf wins shipped as part of N.4: per-entity frustum cull, opaque front-to-back sort, palette-hash memoization (compute once per entity, reuse across batches). Visual verification at Holtburg passed: scenery + connected characters with full close-detail geometry (Issue #47 regression resolved). Legacy `InstancedMeshRenderer` retained as `ACDREAM_USE_WB_FOUNDATION=0` escape hatch until N.6. | Live ✓ |
|
||||
|
||||
Plus polish that doesn't get its own phase number:
|
||||
- FlyCamera default speed lowered + Shift-to-boost
|
||||
|
|
@ -573,52 +575,105 @@ for our deletions/additions; merge upstream `master` periodically.
|
|||
formula was ~180° off from retail's actual `Frame::set_heading` atan2
|
||||
round-trip). One known cosmetic difference filed in ISSUES.md
|
||||
(road-edge tree at landblock 0xA9B1).
|
||||
- **N.2 — Terrain math helpers.** Refactor `TerrainSurface.SampleZ` /
|
||||
`SampleNormal` / `SampleSurface` to call WB's `TerrainUtils.GetHeight`
|
||||
/ `GetNormal` internally. ~1-2 days. Smallest remaining N phase, low
|
||||
risk after N.1's conformance proof on GetNormal.
|
||||
- **N.3 — Texture decoding.** Replace our `TextureCache` decode
|
||||
pipeline (`src/AcDream.App/Rendering/TextureCache.cs`) with WB's
|
||||
`TextureHelpers` (INDEX16, P8, BGRA, DXT, alpha). Touches every
|
||||
texture path. **Realistic estimate: 3-5 days** (was 2-3) — the GL
|
||||
upload path needs adapting and we'll need conformance tests per
|
||||
texture format. Handoff doc:
|
||||
`docs/research/2026-05-08-phase-n3-handoff.md`.
|
||||
- **N.4 — Object meshing.** Replace `SetupMesh.cs` + `GfxObjMesh.cs`
|
||||
with calls to WB's `ObjectMeshManager`. Character-appearance
|
||||
behaviors (CreaturePalette / GfxObjRemapping / HiddenParts) remain
|
||||
ours — ACME is the secondary oracle. **Realistic estimate: 1.5-2
|
||||
weeks** (was 1) — character appearance edge cases like N.1's
|
||||
rotation bug will surface.
|
||||
- **N.5 — Terrain rendering.** Replace `TerrainChunkRenderer` +
|
||||
`TerrainAtlas` + `TerrainBlending` with WB's `TerrainRenderManager` +
|
||||
`LandSurfaceManager` + `TerrainGeometryGenerator`. **Realistic
|
||||
estimate: 3-4 weeks** (was 2) — largest single phase, GPU-buffer
|
||||
ownership shifts, integration with our streaming loader is
|
||||
non-trivial.
|
||||
- **N.6 — Static objects rendering.** Replace `StaticMeshRenderer` +
|
||||
`InstancedMeshRenderer` with WB's `StaticObjectRenderManager`.
|
||||
**Realistic estimate: 2-3 weeks** (was 2) — interacts with N.4
|
||||
output.
|
||||
- **N.2 — Terrain math helpers.** ⚠️ **Blocked on N.5 — do not attempt
|
||||
in isolation.** Originally scoped as a 1-2 day low-risk substitution
|
||||
of `TerrainSurface.SampleZ` / `SampleSurface` / `SampleSurfacePolygon`
|
||||
with WB's `TerrainUtils.GetHeight` / `GetNormal`. Audit during N.3
|
||||
follow-up uncovered that **WB's `CalculateSplitDirection` uses a
|
||||
different formula than retail's `FSplitNESW`** (the AC2D-cited
|
||||
polynomial `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25`
|
||||
that our visual terrain mesh and physics already share). The
|
||||
formulas pick different cell-diagonals on disputed cells, producing
|
||||
up to ~2m Z divergence at the same world position. Substituting
|
||||
physics-side alone would un-sync physics from the still-ours visual
|
||||
mesh — exactly the triangle-Z hover bug class. N.1's conformance
|
||||
test proved WB's `GetNormal` is good enough for slope-filtering
|
||||
(boolean walkable check) but NOT that WB's height formula matches
|
||||
retail. Resolution: fold this work into **N.5** when the visual
|
||||
mesh switches to WB's renderer in lockstep with physics. Until
|
||||
then, leave `TerrainSurface` alone. See ISSUE #51.
|
||||
- **✓ SHIPPED — N.3 — Texture decoding.** Shipped 2026-05-08. `SurfaceDecoder`
|
||||
now delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8 to WB's
|
||||
`TextureHelpers.Fill*`. The A8 divergence (our old code did R=G=B=A=val
|
||||
always; WB splits additive vs non-additive) was resolved by threading an
|
||||
`isAdditive` parameter through `DecodeRenderSurface`: terrain alpha masks
|
||||
pass `isAdditive: true` (matches our prior behavior, preserves the
|
||||
shader's `.r` blend-weight read), entity surfaces pass
|
||||
`surface.Type.HasFlag(SurfaceType.Additive)`. Bonus: R5G6B5 + A4R4G4B4
|
||||
formats now decode (previously fell to magenta). X8R8G8B8, DXT1/3/5, and
|
||||
SolidColor remain ours (no WB equivalent). **9 conformance tests prove
|
||||
byte-identical equivalence per format** before substitution; updated
|
||||
`SurfaceDecoderTests` to match the new A8 split semantics. Visual
|
||||
verification at Holtburg passed 2026-05-08 — no texture regressions.
|
||||
- **✓ SHIPPED — N.4 — Rendering pipeline foundation.** Shipped 2026-05-08.
|
||||
WB's `ObjectMeshManager` is integrated as the production mesh pipeline
|
||||
behind `ACDREAM_USE_WB_FOUNDATION=1` (default-on). The integration is
|
||||
three pieces: `WbMeshAdapter` (single seam owning the WB pipeline,
|
||||
drains the staged-upload queue per frame, populates
|
||||
`AcSurfaceMetadataTable` for translucency / luminosity / fog),
|
||||
`WbDrawDispatcher` (production draw path — groups all visible
|
||||
(entity, batch) pairs, uploads matrices in a single `glBufferData`,
|
||||
fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group
|
||||
with `BaseInstance` slicing the shared instance VBO), and the
|
||||
`LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge that wires our
|
||||
streaming loader to WB's `IncrementRefCount` / `PrepareMeshDataAsync`
|
||||
lifecycle (atlas tier vs per-instance customized).
|
||||
Issue #47 (close-detail mesh) preserved; sky pass structurally
|
||||
independent of the WB foundation. Perf wins shipped as part of N.4:
|
||||
per-entity AABB frustum cull, opaque front-to-back sort, palette-hash
|
||||
memoization. Legacy `InstancedMeshRenderer` retained as flag-off
|
||||
fallback until N.6 fully retires it. Plan archived at
|
||||
`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`.
|
||||
- **N.5 — Modern rendering path.** **Rebranded from "Terrain rendering"
|
||||
2026-05-08 after N.4 perf review.** N.4 left two big remaining wins
|
||||
on the table that pair naturally: (1) bindless textures via
|
||||
`GL_ARB_bindless_texture` (WB already populates
|
||||
`ObjectRenderBatch.BindlessTextureHandle`; switch our shader to
|
||||
consume per-instance handles, eliminate 100% of `glBindTexture`
|
||||
calls), and (2) `glMultiDrawElementsIndirect` (one GL call per pass
|
||||
instead of one per group; build a `DrawElementsIndirectCommand`
|
||||
buffer, fire one indirect draw, the driver pulls everything). Both
|
||||
require shader changes (same shader, in fact — bindless + indirect
|
||||
are the same modern path WB uses internally). Together they target a
|
||||
2-5× CPU win on draw-heavy scenes (Holtburg courtyard, Foundry,
|
||||
dense dungeons). Also folds in: persistent-mapped instance VBO
|
||||
(`glBufferStorage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT` + ring
|
||||
buffer + sync) and texture pre-warm at landblock load (smooths
|
||||
streaming-boundary hitches). **Estimate: 2-3 weeks.**
|
||||
- **N.5b — Terrain rendering on N.5 path.** Wire WB's
|
||||
`TerrainRenderManager` + `LandSurfaceManager` + `TerrainGeometryGenerator`
|
||||
onto the modern rendering path. Closes N.2's deferred terrain math
|
||||
substitution: visual mesh and physics both switch to WB's
|
||||
`CalculateSplitDirection` + `GetHeight` + `GetNormal` in lockstep,
|
||||
resolving ISSUE #51. **Estimate: 1-2 weeks** (was 2-3 — modern path
|
||||
primitives already in place from N.5).
|
||||
- **N.6 — Static objects rendering.** Wire WB's
|
||||
`StaticObjectRenderManager` onto the modern rendering path; **fully
|
||||
delete** legacy `StaticMeshRenderer` + `InstancedMeshRenderer` (they
|
||||
remain as `ACDREAM_USE_WB_FOUNDATION=0` escape hatches through N.5).
|
||||
Mostly draw orchestration at this point — most of the substance
|
||||
landed in N.4 + N.5. **Estimate: 1-2 weeks** (was 2-3).
|
||||
- **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's
|
||||
`EnvCellRenderManager` + `PortalRenderManager`. **Realistic
|
||||
estimate: 2-3 weeks** (was 2).
|
||||
`EnvCellRenderManager` + `PortalRenderManager` on top of N.4's
|
||||
foundation. **Estimate: 1-2 weeks** (was 2-3 — naturally smaller now
|
||||
that infrastructure is shared).
|
||||
- **N.8 — Sky + particles.** Replace sky rendering + particle pipeline
|
||||
(#36 / C.1 work) with WB's `SkyboxRenderManager` +
|
||||
`ParticleEmitterRenderer`. **Realistic estimate: 1.5-2 weeks**
|
||||
(was 1) — visual continuity matters; we just shipped C.1 and that
|
||||
work flows through here.
|
||||
`ParticleEmitterRenderer`. **Estimate: ~1 week** (was 1.5-2 — C.1
|
||||
already shipped most of this; N.8 is glue + sampler-object reuse).
|
||||
- **N.9 — Visibility / culling.** Replace `CellVisibility` +
|
||||
`FrustumCuller` with WB's `VisibilityManager`. **Realistic
|
||||
estimate: 1 week** (was 3-5 days) — affects perf and what gets
|
||||
drawn.
|
||||
`FrustumCuller` with WB's `VisibilityManager`. **Estimate: ~1 week**
|
||||
(was 3-5 days, slight bump for streaming-loader interaction).
|
||||
- **N.10 — GL infrastructure consolidation (optional).** Replace our
|
||||
`Shader` / `TextureCache` / `SamplerCache` plumbing with WB's
|
||||
`ManagedGL*` wrappers + `OpenGLGraphicsDevice`. ~1 week.
|
||||
`ManagedGL*` wrappers + `OpenGLGraphicsDevice`. **Largely subsumed by
|
||||
N.4** — `OpenGLGraphicsDevice` arrives as the host of `ObjectMeshManager`
|
||||
and atlas. May not need a dedicated phase; revisit after N.6.
|
||||
|
||||
**Estimated calendar:** **3-4 months / 10-12 engineering weeks for
|
||||
N.2-N.9 (skipping N.10).** (Was 2-3 months / 6-8 weeks — revised
|
||||
upward after N.1 landed; realistic per-phase numbers above.)
|
||||
**Estimated calendar:** **2.5-3 months / 9-13 engineering weeks for
|
||||
N.4-N.9 (N.10 likely subsumed; N.2 folded into N.5; N.3 shipped).**
|
||||
Revised 2026-05-08 after recognizing N.4-N.6 are one rendering rebuild
|
||||
on shared infrastructure rather than three independent substitutions.
|
||||
|
||||
**Each sub-phase:**
|
||||
- Ships behind `ACDREAM_USE_WB_<NAME>=1` flag.
|
||||
|
|
|
|||
318
docs/research/2026-05-08-phase-n4-week4-handoff.md
Normal file
318
docs/research/2026-05-08-phase-n4-week4-handoff.md
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
# Phase N.4 Week 4 handoff — full draw dispatcher + visual verification + ship
|
||||
|
||||
**Use this whole document as the prompt** when handing off to a fresh
|
||||
agent. Everything they need to pick up cold is below.
|
||||
|
||||
---
|
||||
|
||||
## Background you'll need
|
||||
|
||||
You're working in `acdream`, a from-scratch C# .NET 10 reimplementation
|
||||
of Asheron's Call's retail client. The project's house rule (in
|
||||
`CLAUDE.md`) is **the code is modern, the behavior is retail**.
|
||||
|
||||
acdream is in the middle of Phase N.4 — the rendering pipeline
|
||||
foundation migration to WorldBuilder's `ObjectMeshManager` +
|
||||
`TextureAtlasManager`. **Three of the four planned weeks have shipped
|
||||
this session (2026-05-08)**:
|
||||
|
||||
- Week 1 (commits up through `c49c6ed`): foundation types — feature
|
||||
flag, surface metadata side-table, mesh-extraction + setup-flatten
|
||||
conformance tests, `WbMeshAdapter` constructed against the real WB
|
||||
pipeline.
|
||||
- Week 2 (commits up through `36f7a60`): streaming integration —
|
||||
`LandblockSpawnAdapter` routes atlas-tier (procedural / `ServerGuid==0`)
|
||||
GfxObjs to WB's ref-count lifecycle. `WbMeshAdapter.Tick()` drains
|
||||
the WB pipeline's main-thread queues per frame (fixes a real memory
|
||||
leak).
|
||||
- Week 3 (commits up through `d30fcb2`): per-instance tier hookup —
|
||||
`AnimatedEntityState` holds per-server-spawned-entity overrides;
|
||||
`EntitySpawnAdapter` routes server-spawned entities through the
|
||||
existing `TextureCache.GetOrUploadWithPaletteOverride` decode path.
|
||||
|
||||
**Current state at `main`:** build green, **947 tests pass**, 8
|
||||
pre-existing failures only (unchanged from pre-N.4 main). Default-off
|
||||
behavior is byte-identical to pre-N.4 main; flag-on (`ACDREAM_USE_WB_FOUNDATION=1`)
|
||||
runs both rendering pipelines in parallel — WB silently prepares
|
||||
content, but nothing is yet drawn through it.
|
||||
|
||||
**Read first:**
|
||||
|
||||
- [docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md](../superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md) —
|
||||
the **living-document** plan. Top of file has a Progress table
|
||||
showing Tasks 1-21 ✅ shipped with commit SHAs. Adjustments 1-5
|
||||
document architectural surprises caught during execution. **Read the
|
||||
Adjustments before writing any Task 22 code** — they explain why the
|
||||
current architecture is what it is.
|
||||
- [docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md](../superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md) —
|
||||
the design spec. Architecture / two-tier split / animation handling /
|
||||
data-flow diagrams. Strategic source of truth for "how the pieces
|
||||
fit together."
|
||||
- [CLAUDE.md](../../CLAUDE.md) — project-wide rules. The "Currently in
|
||||
flight" section near the top points at the plan.
|
||||
|
||||
## What Week 4 is
|
||||
|
||||
Seven tasks (22-28). **Task 22 alone is the biggest single task in the
|
||||
entire 28-task plan** — it's the moment we flip from "WB is silently
|
||||
preparing content" to "WB is drawing content to your screen."
|
||||
|
||||
The remaining six tasks are smaller: surface-metadata side-table
|
||||
population, sky-pass preservation check, micro-tests round-out, visual
|
||||
verification at 5 named locations, flag default-on, delete legacy
|
||||
code, finalize plan + memory + ISSUES.
|
||||
|
||||
**Task 22 also unlocks the Adjustment 3 mitigation.** Right now
|
||||
flag-on has a real FPS regression because both rendering pipelines run
|
||||
in parallel (legacy renderer still does atlas-tier upload + draw,
|
||||
even though WB is also building atlas state). When Task 22 lands the
|
||||
dispatcher AND wires the legacy-renderer short-circuit for atlas-tier
|
||||
content, that double-work disappears.
|
||||
|
||||
## Two unresolved decisions before Task 22 starts
|
||||
|
||||
These need a brainstorm checkpoint at the start of Week 4, NOT a
|
||||
"just dispatch":
|
||||
|
||||
1. **Adjustment 4 plumbing.** `WorldEntity` doesn't carry
|
||||
`HiddenPartsMask` or `AnimPartChanges` — those live on the
|
||||
network-layer spawn record and don't make it to the render-side
|
||||
entity. Two options:
|
||||
- **A**: add `HiddenPartsMask` + `AnimPartChanges` fields to
|
||||
`WorldEntity`, populate at spawn time. Cleaner long-term; small
|
||||
network→render plumbing change.
|
||||
- **B**: thread them as separate parameters into
|
||||
`EntitySpawnAdapter.OnCreate(entity, hiddenMask, animPartChanges)`.
|
||||
Sidesteps the `WorldEntity` change but couples the spawn-handler
|
||||
to the adapter API.
|
||||
|
||||
Decide before writing Task 22 because the dispatcher reads from
|
||||
`AnimatedEntityState` which currently holds defaults (empty mask +
|
||||
empty override map). Without this resolved, hidden parts won't
|
||||
actually be hidden flag-on.
|
||||
|
||||
2. **Surface-metadata side-table population strategy** (Task 23). The
|
||||
spec proposes: when `WbMeshAdapter.IncrementRefCount(id)` is first
|
||||
called for a GfxObj, walk its sub-meshes via `GfxObjMesh.Build`,
|
||||
write each `(gfxObjId, surfaceIdx) → AcSurfaceMetadata` entry into
|
||||
the side-table. The `_metadataPopulated: HashSet<ulong>` field
|
||||
tracks which ids have been processed.
|
||||
|
||||
**But:** if the same GfxObj gets its ref count drop to zero and
|
||||
then re-incremented (LRU eviction + reload), do we re-populate?
|
||||
The metadata is invariant per-GfxObj (surface flags don't change
|
||||
with eviction), so probably no — the `HashSet` is fine. But
|
||||
verify before implementing.
|
||||
|
||||
## Watchouts (lessons from Weeks 1-3)
|
||||
|
||||
These are real, observed gotchas. Read each before going deeper.
|
||||
|
||||
- **The renderer is tier-blind by design (Adjustment 2).** Don't try
|
||||
to put routing decisions in `InstancedMeshRenderer` or any mesh
|
||||
uploader. Routing belongs at the **spawn-callback layer**:
|
||||
`LandblockSpawnAdapter` for atlas-tier, `EntitySpawnAdapter` for
|
||||
per-instance. Task 22's dispatcher reads from those adapters'
|
||||
per-entity state at draw time; it doesn't make tier decisions.
|
||||
|
||||
- **Flag-off must stay byte-identical to pre-N.4.** Every Task-22 code
|
||||
path must have a `WbFoundationFlag.IsEnabled` gate. Default-off path
|
||||
is what users see; we can't regress it.
|
||||
|
||||
- **WB's pipeline does work even when you're not draining its results.**
|
||||
Adjustment 3: `IncrementRefCount` triggers background mesh prep,
|
||||
texture decode, atlas allocation. `WbMeshAdapter.Tick()` already
|
||||
drains the upload queue per frame. The remaining FPS cost is
|
||||
pure dual-pipeline cost (legacy + WB doing the same upload work).
|
||||
Task 22's short-circuit fixes this.
|
||||
|
||||
- **`MeshRef.SurfaceOverrides`** is the per-surface texture-swap data
|
||||
carried by spawned entities. `GfxObjSubMesh.SurfaceId` is what gets
|
||||
swapped. Task 22's draw loop must consult both: the entity's
|
||||
`MeshRef.SurfaceOverrides` for explicit swaps, and otherwise the
|
||||
mesh's built-in `SurfaceId`.
|
||||
|
||||
- **Conformance tests catch divergences early.** Per N.1's rotation
|
||||
bug: write the conformance test BEFORE the substitution. The
|
||||
matrix-composition test (`(entityWorld) × (animation) × (restPose)`)
|
||||
is the load-bearing one for Task 22 — pin it before integrating.
|
||||
|
||||
- **`WbMeshAdapter.Tick()` is required.** It's already wired into
|
||||
`GameWindow.OnRender`. Task 22's dispatcher needs the upload queue
|
||||
drained BEFORE it tries to draw, so order in OnRender is:
|
||||
`_wbMeshAdapter?.Tick()` → `_wbDrawDispatcher?.Draw(...)` → other
|
||||
draw work.
|
||||
|
||||
- **Name retail decomp first; Phase N.4 doesn't change that rule.**
|
||||
Task 22's matrix composition uses standard graphics math — no AC-
|
||||
specific algorithms — so the "grep `named-retail/` first" workflow
|
||||
doesn't apply to the matrix code itself. But for any AC-specific
|
||||
question that surfaces during integration (e.g., "does retail
|
||||
render hidden parts as zero-alpha or skip them entirely?"), grep
|
||||
`docs/research/named-retail/acclient_2013_pseudo_c.txt` first.
|
||||
|
||||
## Acceptance criteria for Week 4
|
||||
|
||||
From the plan:
|
||||
|
||||
- [ ] All conformance tests pass (Tasks 3, 4, 20 — already shipped;
|
||||
verify still green after Task 22 lands).
|
||||
- [ ] All component micro-tests pass (Tasks 11, 17, 18, 19, 22 —
|
||||
Task 22 adds matrix-composition tests).
|
||||
- [ ] All existing tests still pass. 8 pre-existing failures don't
|
||||
count.
|
||||
- [ ] Build green throughout.
|
||||
- [ ] Visual verification at 5 named locations passes:
|
||||
1. Holtburg outdoor — terrain props, scenery, buildings, NPCs,
|
||||
characters all render correctly.
|
||||
2. Drudge Hideout (or comparable) — EnvCell + interior lighting +
|
||||
animated creatures.
|
||||
3. Foundry — heavy NPC traffic + customized appearances.
|
||||
4. A character with extreme palette overrides.
|
||||
5. Long roam (5+ minutes) — GPU memory stabilizes (LRU eviction
|
||||
fires).
|
||||
- [ ] Memory budget enforcement actually verified (Task 13 was
|
||||
deferred to here; Task 22 makes it testable because GL resources
|
||||
finally get allocated for LRU to evict).
|
||||
- [ ] Sky pass renders identically (load-bearing — sky's
|
||||
`Translucent+ClipMap` cloud sheet, raw-`Additive` fog skip,
|
||||
`Luminosity` keyframe handling all flow through the side-table
|
||||
via `AcSurfaceMetadata`).
|
||||
- [ ] Flag flipped to default-on at the end (Task 26).
|
||||
- [ ] Legacy code paths deleted (Task 27).
|
||||
- [ ] Roadmap + memory + ISSUES updated (Task 28).
|
||||
|
||||
## Tasks 22-28 — quick map
|
||||
|
||||
Full detail is in the plan. Brief here:
|
||||
|
||||
- **22 — `WbDrawDispatcher` full draw loop.** ~1-2 days. Atlas-tier
|
||||
+ per-instance-tier draw with matrix composition. Reads from
|
||||
`WbMeshAdapter.GetRenderData(id)` for atlas content; reads from
|
||||
`EntitySpawnAdapter.GetState(serverGuid)` for per-instance state;
|
||||
composes per-part `(entity × animation × rest-pose)` matrices;
|
||||
pushes uniforms; issues GL draws. **Also wires the legacy-
|
||||
renderer short-circuit** for atlas-tier content (the Adjustment 3
|
||||
fix).
|
||||
- **23 — Surface-metadata side-table population.** ~half day. Hook
|
||||
into `WbMeshAdapter.IncrementRefCount` so that on first registration
|
||||
of a GfxObj, the side-table gets populated with one
|
||||
`AcSurfaceMetadata` per surfaceIdx (using `GfxObjMesh.Build`'s
|
||||
metadata as the source of truth).
|
||||
- **24 — Sky-pass preservation check.** ~half day. Verify the sky
|
||||
pass's `NeedsUvRepeat` / `DisableFog` / `Luminosity` flow through
|
||||
the side-table to `SkyRenderer` correctly. Likely no code change;
|
||||
smoke-test sky rendering with flag on, weather/day-night cycle.
|
||||
- **25 — Component micro-tests round-out.** Audit existing tests
|
||||
against the spec's Testing section. Probably nothing to add since
|
||||
Tasks 11/17/18/19/22 already cover the listed micro-tests.
|
||||
- **26 — Visual verification + flag default-on.** Human-in-the-loop
|
||||
walk through the 5 named locations. If clean, flip
|
||||
`WbFoundationFlag.IsEnabled` from `== "1"` to `!= "0"` so flag-on
|
||||
becomes the default.
|
||||
- **27 — Delete legacy code paths.** Remove the now-unused legacy
|
||||
upload code in `StaticMeshRenderer` + `InstancedMeshRenderer`.
|
||||
N.6 fully replaces these files anyway.
|
||||
- **28 — Update roadmap + memory + ISSUES + finalize plan.** Mark
|
||||
N.4 shipped in the roadmap's Live ✓ table. File any cosmetic
|
||||
deltas as ISSUES. Add a memory note if a durable lesson emerged.
|
||||
Flip the plan's status header from "Living document — work in
|
||||
progress" to "Final state — phase shipped (merge `<sha>`)".
|
||||
|
||||
## Where to start
|
||||
|
||||
1. **Read the three "Read first" docs above end-to-end.** Especially
|
||||
the Adjustments section in the plan — those are the architectural
|
||||
constraints Task 22 must respect.
|
||||
2. **Decide Adjustment 4 plumbing** (option A vs B from above). This
|
||||
is a small brainstorm checkpoint, not a multi-question
|
||||
`superpowers:brainstorming` skill invocation. Document the choice
|
||||
inline in the plan as Adjustment 6.
|
||||
3. **Don't create a new worktree.** The existing branch
|
||||
`claude/quirky-jepsen-fd60f1` and worktree
|
||||
`.claude/worktrees/quirky-jepsen-fd60f1` are clean and ready.
|
||||
Submodule already initialized. Build green.
|
||||
4. **Use `superpowers:subagent-driven-development`** to execute Week 4
|
||||
task-by-task. Pattern from Weeks 1-3: dispatch one subagent per
|
||||
task (or batch of related tasks), use Sonnet for implementation,
|
||||
merge to main per logical chunk, update the plan's Progress table
|
||||
as commits land.
|
||||
5. **Pause for visual verification at Task 26.** This is a human-in-
|
||||
the-loop step — needs you to walk the 5 named locations.
|
||||
|
||||
## Open questions a fresh agent might hit
|
||||
|
||||
- **Q: Why did Adjustment 5 mark Task 20 (per-instance decode
|
||||
conformance) as "structural"?** Because both old and new paths call
|
||||
the same `TextureCache.GetOrUploadWithPaletteOverride` function. We
|
||||
preserved the decode logic exactly; the seam is at the call site,
|
||||
not at the algorithm. Byte-equality is automatic.
|
||||
|
||||
- **Q: Can I delete `InstancedMeshRenderer`?** Not in N.4. The plan
|
||||
marks it as "becomes a thin adapter in N.4, fully replaced in N.6."
|
||||
Task 27 deletes the legacy upload paths inside it but keeps the
|
||||
file as a draw-orchestration adapter until N.6.
|
||||
|
||||
- **Q: What's the memory budget check actually checking?** GPU memory
|
||||
stabilizes during long roam. WB's `_maxGpuMemory = 1 GB` triggers
|
||||
LRU eviction once the cache exceeds that. We verify by walking
|
||||
for 5+ minutes at radius 7 (49 landblocks visible at any time) and
|
||||
confirming GPU memory in the title bar plateaus rather than
|
||||
growing unboundedly.
|
||||
|
||||
- **Q: What happens if Task 22 takes longer than expected?** The
|
||||
living-document convention says document Adjustments inline. If
|
||||
Task 22 needs to split (e.g., atlas-tier draw lands first, per-
|
||||
instance tier in a follow-on commit), that's fine — just update
|
||||
the Progress table and add an Adjustment explaining the split.
|
||||
|
||||
## Useful greps and commands
|
||||
|
||||
- `dotnet build --verbosity quiet 2>&1 | tail -3` — quick build check.
|
||||
- `dotnet test --verbosity quiet 2>&1 | tail -3` — full test suite.
|
||||
- `git -C C:\Users\erikn\source\repos\acdream log --oneline -10` —
|
||||
recent main commits.
|
||||
- `grep -rn "WbFoundationFlag.IsEnabled" src/` — every place we gate
|
||||
on the flag (audit before flipping default-on in Task 26).
|
||||
- `grep -rn "_wbMeshAdapter\|_wbSpawnAdapter\|_wbEntitySpawnAdapter" src/` —
|
||||
every WB adapter wiring point.
|
||||
|
||||
## Smoke-test launch (PowerShell)
|
||||
|
||||
```powershell
|
||||
# Kill any stale processes first
|
||||
Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
Start-Sleep -Seconds 4
|
||||
|
||||
# Flag-on at radius 7 — Week 4 dev environment
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
$env:ACDREAM_USE_WB_FOUNDATION = "1"
|
||||
$env:ACDREAM_STREAM_RADIUS = "7"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
|
||||
Tee-Object -FilePath "n4-week4-smoke.log"
|
||||
```
|
||||
|
||||
(Drop the `ACDREAM_USE_WB_FOUNDATION` line for flag-off comparison.)
|
||||
|
||||
## Adjustments index — quick reference
|
||||
|
||||
For full text, see the plan document (each is a `### Adjustment N`
|
||||
subsection under Task 6's old position, in chronological order):
|
||||
|
||||
1. **`DefaultDatReaderWriter` discovery** (2026-05-08) — no
|
||||
dat-reader bridge needed; WB ships a usable concrete
|
||||
implementation.
|
||||
2. **Renderer is tier-blind** (2026-05-08) — routing belongs at
|
||||
spawn callbacks, not in the renderer.
|
||||
3. **FPS regression = dual-pipeline cost** (2026-05-08) — both
|
||||
pipelines run in parallel until Task 22's short-circuit lands.
|
||||
4. **`WorldEntity` lacks HiddenParts/AnimPartChange fields**
|
||||
(2026-05-08) — plumbing deferred; Task 22 needs to resolve
|
||||
(option A: add fields; option B: thread as separate args).
|
||||
5. **Task 20 is structural** (2026-05-08) — same function called
|
||||
both paths, byte-equality automatic, no test file needed.
|
||||
495
docs/research/2026-05-08-phase-n5-handoff.md
Normal file
495
docs/research/2026-05-08-phase-n5-handoff.md
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
# Phase N.5 — Modern Rendering Path — Cold-Start Handoff
|
||||
|
||||
**Created:** 2026-05-08, immediately after N.4 ship.
|
||||
**Audience:** the next agent picking up rendering perf work.
|
||||
**Purpose:** give you everything you need to start N.5 cold, without
|
||||
spelunking through five months of session history.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
N.4 just shipped: WB's `ObjectMeshManager` is now acdream's production
|
||||
mesh pipeline, and `WbDrawDispatcher` is the production draw path. It
|
||||
works (Holtburg renders correctly, FPS substantially improved over the
|
||||
naïve dual-pipeline state we hit during week 4 verification) but it's
|
||||
still doing per-group state changes (`glBindTexture`, `glBindBuffer`
|
||||
for the IBO, `glDrawElementsInstancedBaseVertexBaseInstance` per group)
|
||||
and a fresh `glBufferData` upload per frame.
|
||||
|
||||
**N.5's job: lift the dispatcher onto WB's modern rendering primitives
|
||||
that we're already paying GPU-feature-detection cost for.** Two big
|
||||
wins, paired:
|
||||
|
||||
1. **Bindless textures** (`GL_ARB_bindless_texture`) — WB already
|
||||
populates `ObjectRenderBatch.BindlessTextureHandle`. Switch our
|
||||
shader to read texture handles from a per-instance attribute
|
||||
(`uvec2` → `sampler2D` via the bindless extension). Eliminates
|
||||
100% of `glBindTexture` calls.
|
||||
2. **Multi-draw indirect** (`glMultiDrawElementsIndirect`) — build a
|
||||
buffer of `DrawElementsIndirectCommand` structs (one per group),
|
||||
upload once, fire ONE `glMultiDrawElementsIndirect` call per pass.
|
||||
The driver pulls everything from the indirect buffer.
|
||||
|
||||
Together they target a 2-5× CPU win on draw-heavy scenes (Holtburg
|
||||
courtyard, Foundry, dense dungeons). They're packaged together because
|
||||
both are "modern path" extensions we already gate on, both require
|
||||
the same shader rewrite, and they pair naturally — multi-draw indirect
|
||||
is a no-op CPU-win without bindless because per-group `glBindTexture`
|
||||
calls would still serialize.
|
||||
|
||||
**Estimated scope: 2-3 weeks.** Plan + spec to be written by the
|
||||
brainstorm + spec steps below.
|
||||
|
||||
---
|
||||
|
||||
## Where N.4 left things
|
||||
|
||||
### Branch state
|
||||
|
||||
If this handoff is being read on `main` after merging the N.4 worktree:
|
||||
N.4 commits land at the head of main. The relevant final commits:
|
||||
|
||||
- `c445364` — N.4 SHIP (flag default-on, plan final, roadmap, memory)
|
||||
- `573526d` — perf pass 1-4 (drop dead lookup, sort, cull, hash memo)
|
||||
- `7b41efc` — FirstIndex/BaseVertex + Issue #47 + grouped instanced
|
||||
- `943652d` — load triggers + `batch.Key.SurfaceId` source
|
||||
- `01cff41` — Tasks 22+23 (`WbDrawDispatcher` + side-table)
|
||||
|
||||
If the worktree branch (`claude/tender-mcclintock-a16839`) hasn't been
|
||||
merged yet, that's where the work is. Verify with `git log --oneline`.
|
||||
|
||||
### What works in N.4
|
||||
|
||||
- `ACDREAM_USE_WB_FOUNDATION=1` is default-on. WB's `ObjectMeshManager`
|
||||
loads, decodes, and uploads every entity mesh. Our existing
|
||||
`TextureCache` decodes textures (palette-aware, per-instance overrides
|
||||
via `GetOrUploadWithPaletteOverride`).
|
||||
- `WbDrawDispatcher.Draw`:
|
||||
- Walks visible entities (per-landblock AABB cull + per-entity AABB
|
||||
cull + portal visibility)
|
||||
- Buckets every (entity × meshRef × batch) tuple by
|
||||
`GroupKey(Ibo, FirstIndex, BaseVertex, IndexCount, TextureHandle, Translucency)`
|
||||
- Single `glBufferData` upload of all matrices for the frame
|
||||
- Per group: `glActiveTexture(0) + glBindTexture(2D, handle) + glBindBuffer(EBO, ibo) + glDrawElementsInstancedBaseVertexBaseInstance(..., FirstInstance)`
|
||||
- Two passes: opaque (front-to-back sorted) + translucent
|
||||
- 940/948 tests pass (8 pre-existing failures unrelated to rendering).
|
||||
- Visual verification at Holtburg passed: scenery + characters render
|
||||
correctly with full close-detail geometry (Issue #47 preserved).
|
||||
|
||||
### What N.5 inherits
|
||||
|
||||
These are levers N.5 will pull on:
|
||||
|
||||
- **WB's modern rendering is already active.** `OpenGLGraphicsDevice`
|
||||
detected GL 4.3 + bindless on first run; WB's `_useModernRendering`
|
||||
is true; every mesh lives in WB's single `GlobalMeshBuffer` (one VAO,
|
||||
one VBO, one IBO).
|
||||
- **Bindless handles are already populated.** `ObjectRenderBatch.BindlessTextureHandle`
|
||||
is non-zero for batches WB owns the texture for. (See gotcha #2
|
||||
below for entities with palette overrides — those use acdream's
|
||||
`TextureCache` which doesn't expose bindless handles yet.)
|
||||
- **The instance VBO is acdream-owned** (`WbDrawDispatcher._instanceVbo`)
|
||||
with locations 3-6 patched onto WB's global VAO. Stride 64 bytes
|
||||
(one mat4). N.5 expands this to (mat4 + uvec2 handle) = 80 bytes.
|
||||
|
||||
### Three load-bearing WB API gotchas N.4 surfaced
|
||||
|
||||
These bit us hard during Task 26 visual verification. Documented in
|
||||
CLAUDE.md "WB integration cribs" + plan adjustments 7-9 +
|
||||
`memory/project_phase_n4_state.md`. Re-stating here because they
|
||||
reshape the design space:
|
||||
|
||||
1. **`ObjectMeshManager.IncrementRefCount(id)` is NOT lifecycle-aware.**
|
||||
It only bumps a usage counter. Mesh loading is fired separately
|
||||
via `PrepareMeshDataAsync(id, isSetup)`. The result auto-enqueues
|
||||
to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`); our
|
||||
existing `WbMeshAdapter.Tick()` drains it. `WbMeshAdapter.IncrementRefCount`
|
||||
already calls `PrepareMeshDataAsync`. **N.5 doesn't need to change
|
||||
this — just don't break it.**
|
||||
|
||||
2. **`ObjectRenderBatch.SurfaceId` is unset.** WB constructs batches
|
||||
with `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct
|
||||
that has a `SurfaceId` field) but never populates the top-level
|
||||
`SurfaceId` property. Read `batch.Key.SurfaceId`. **N.5 keeps this
|
||||
pattern.**
|
||||
|
||||
3. **WB's modern rendering packs every mesh into ONE global
|
||||
VAO/VBO/IBO.** Each batch's `IBO` field points to the global IBO;
|
||||
the batch's actual slice is identified by `FirstIndex` (offset into
|
||||
IBO, in *indices*) and `BaseVertex` (offset into VBO, in *vertices*).
|
||||
N.4's draw uses `glDrawElementsInstancedBaseVertexBaseInstance`
|
||||
with those offsets. **N.5's `DrawElementsIndirectCommand` per-group
|
||||
record will carry `firstIndex` + `baseVertex` for the same reason.**
|
||||
|
||||
---
|
||||
|
||||
## What N.5 is — technical detail
|
||||
|
||||
### The two-feature pairing
|
||||
|
||||
**Bindless textures** (`GL_ARB_bindless_texture`):
|
||||
- Each texture handle is a 64-bit integer (`uvec2` in GLSL).
|
||||
- Shader declares `layout(bindless_sampler) uniform sampler2D ...` or
|
||||
receives the handle as a per-vertex-attribute `uvec2`.
|
||||
- No `glBindTexture` needed at draw time — the handle IS the binding.
|
||||
- Handle generation: `glGetTextureHandleARB(textureId)` followed by
|
||||
`glMakeTextureHandleResidentARB(handle)` (the texture must be
|
||||
resident on the GPU; non-resident handles produce GPU faults).
|
||||
|
||||
**Multi-draw indirect** (`glMultiDrawElementsIndirect`):
|
||||
- Indirect command struct layout (must match `DrawElementsIndirectCommand`):
|
||||
```c
|
||||
struct {
|
||||
uint count; // index count for this draw
|
||||
uint instanceCount; // number of instances
|
||||
uint firstIndex; // offset into IBO, in indices
|
||||
int baseVertex; // vertex offset into VBO
|
||||
uint baseInstance; // first instance ID (offsets per-instance attribs)
|
||||
};
|
||||
```
|
||||
- Build a buffer of N of these structs (one per group), upload once,
|
||||
fire one GL call: `glMultiDrawElementsIndirect(mode, indexType, ptr, drawcount, stride)`.
|
||||
- The driver issues all N draws in one shot. Effectively zero CPU
|
||||
overhead per draw beyond uploading the indirect buffer.
|
||||
|
||||
**Why pair them.** Multi-draw indirect doesn't let you change uniform
|
||||
state between draws. So if textures are bound via `glBindTexture` per
|
||||
group, you'd still need N CPU-side setup steps before each indirect
|
||||
call — defeating the purpose. Bindless removes that constraint by
|
||||
encoding the texture handle as per-instance data the shader reads
|
||||
directly. With both, the modern render loop becomes:
|
||||
|
||||
```
|
||||
1. Upload instance buffer (mat4 + uvec2 handle, per-instance) — once per frame
|
||||
2. Upload indirect command buffer (one DEIC per group) — once per frame
|
||||
3. glBindVertexArray(globalVAO) — once
|
||||
4. glMultiDrawElementsIndirect(...) — ONCE per pass
|
||||
```
|
||||
|
||||
That's it. No per-group state changes.
|
||||
|
||||
### Instance attribute layout
|
||||
|
||||
Currently (N.4): location 3-6 = mat4 model matrix (16 floats = 64 bytes).
|
||||
|
||||
N.5 (proposed): location 3-6 = mat4 + location 7 = uvec2 bindless
|
||||
handle = 16 floats + 2 uints = 72 bytes (16-aligned to 80 bytes per
|
||||
WB's `InstanceData` precedent).
|
||||
|
||||
Or use std140-aligned struct:
|
||||
```c
|
||||
struct InstanceData {
|
||||
mat4 transform; // locations 3-6
|
||||
uvec2 textureHandle; // location 7
|
||||
uvec2 _pad; // padding to 80
|
||||
};
|
||||
```
|
||||
|
||||
Brainstorm should decide if we copy WB's `InstanceData` struct (Pack=16,
|
||||
80 bytes including CellId/Flags fields we don't use) or define our own
|
||||
minimal version. The 80-byte stride matches WB's so global VAO state
|
||||
configured by WB stays compatible if the legacy WB draw path ever runs.
|
||||
|
||||
### Per-instance entity texture handles
|
||||
|
||||
Here's the wrinkle. N.4 uses `WbDrawDispatcher.ResolveTexture` to map
|
||||
each (entity, batch) to a GL texture handle:
|
||||
|
||||
- Tree (no overrides): `_textures.GetOrUpload(surfaceId)` → 2D texture handle
|
||||
- NPC with palette override: `_textures.GetOrUploadWithPaletteOverride(...)` → composite-cached 2D texture handle
|
||||
- Anything with surface override: `_textures.GetOrUploadWithOrigTextureOverride(...)` → composite-cached 2D texture handle
|
||||
|
||||
Those are all `GLuint` 32-bit GL texture *names*, not bindless handles.
|
||||
**N.5 needs `TextureCache` to publish bindless handles for everything
|
||||
it owns, not just WB-owned textures.**
|
||||
|
||||
Implementation sketch:
|
||||
- `TextureCache` adds a parallel cache keyed identically but storing
|
||||
64-bit bindless handles. On first request, generate via
|
||||
`glGetTextureHandleARB(textureId)` + make resident.
|
||||
- New API: `GetBindlessHandle(uint surfaceId, ...)` returns the handle.
|
||||
- Or: change every `GetOrUpload*` method to return both the GL name
|
||||
and the bindless handle (or just the handle; let GL name fall out
|
||||
if anyone needs it later).
|
||||
|
||||
WB's `ObjectRenderBatch.BindlessTextureHandle` covers the atlas-tier
|
||||
case. For per-instance entities, we use `TextureCache`'s handle.
|
||||
|
||||
### The new shader
|
||||
|
||||
Reuse WB's `StaticObjectModern.vert` / `StaticObjectModern.frag` as a
|
||||
template. Read those files cold. They already do bindless + the
|
||||
instance-data layout. Adapt to acdream's `mesh_instanced.vert/frag`
|
||||
conventions:
|
||||
|
||||
- Keep the `uViewProjection` uniform, lighting UBO at binding=1, fog
|
||||
uniforms.
|
||||
- Add `#version 430 core` + `#extension GL_ARB_bindless_texture : require`.
|
||||
- Replace `uniform sampler2D uDiffuse` with a `uvec2` per-vertex
|
||||
attribute (location 7) → reconstruct sampler in vertex shader OR
|
||||
pass through to fragment via flat varying.
|
||||
- Drop `uTranslucencyKind` uniform, OR keep it (still set per-pass —
|
||||
multi-draw indirect doesn't break uniforms; only state that varies
|
||||
per-draw is the constraint).
|
||||
|
||||
### Translucency
|
||||
|
||||
Multi-draw indirect can't change blend state mid-draw. Solution:
|
||||
**still use two passes** (opaque + translucent), but within translucent
|
||||
keep the per-blendfunc sub-passes (additive, alpha-blend, inv-alpha).
|
||||
Three sub-passes within translucent. Each sub-pass = one
|
||||
`glMultiDrawElementsIndirect` over its filtered groups.
|
||||
|
||||
Or: if perf allows, fold all four blend modes into the shader via
|
||||
per-instance blendmode int, sort all translucent groups by blendmode
|
||||
in the indirect buffer, switch blend state at sub-pass boundaries.
|
||||
Brainstorm decides the cleanest pattern.
|
||||
|
||||
---
|
||||
|
||||
## Files to read before brainstorming
|
||||
|
||||
In rough order:
|
||||
|
||||
1. **N.4 plan + spec** — `docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`
|
||||
(status: Final). Adjustments 7-10 capture the gotchas. Spec at
|
||||
`docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`.
|
||||
|
||||
2. **N.4 dispatcher source** — `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`.
|
||||
This is what you're modifying. Read end-to-end.
|
||||
|
||||
3. **WB's modern rendering shaders** — `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/StaticObjectModern.vert`
|
||||
+ `StaticObjectModern.frag`. The template you're adapting from.
|
||||
|
||||
4. **WB's `ObjectMeshManager.UploadGfxObjMeshData`** — lines ~1654-1780
|
||||
of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`.
|
||||
Shows how WB sets up the modern path's VBO/IBO/VAO. Especially note
|
||||
how it patches in instance attribute slots (locations 3-6) on the
|
||||
global VAO and configures location 7+ for bindless handles.
|
||||
|
||||
5. **WB's `ObjectRenderBatch`** — same file, lines ~166-184. Note the
|
||||
`BindlessTextureHandle` field — already populated when `_useModernRendering`
|
||||
is on.
|
||||
|
||||
6. **Our `TextureCache`** — `src/AcDream.App/Rendering/TextureCache.cs`.
|
||||
Three composite caches: by surface id, by surface+origTex, by
|
||||
surface+origTex+palette. N.5 adds parallel bindless-handle caches.
|
||||
|
||||
7. **CLAUDE.md "WB integration cribs"** section. Lines ~28-80. The
|
||||
three gotchas + the integration architecture in plain language.
|
||||
|
||||
8. **Memory: `project_phase_n4_state.md`** — same content from a
|
||||
different angle. Reading both helps lock in the gotchas.
|
||||
|
||||
---
|
||||
|
||||
## Brainstorm questions
|
||||
|
||||
These are the questions to resolve in the brainstorm step. Don't
|
||||
prejudge them — bring them to the user with options + recommendation:
|
||||
|
||||
1. **Instance attribute layout.** Match WB's `InstanceData` struct
|
||||
(80 bytes including CellId/Flags fields we don't use) for global
|
||||
VAO compatibility, or define a minimal acdream-specific version
|
||||
(mat4 + handle = ~72 bytes padded to 80)?
|
||||
|
||||
2. **Bindless handle generation strategy.**
|
||||
- At texture upload time? (Eager — every texture that lands in
|
||||
`TextureCache` gets a handle. Memory cost ~per-texture state.)
|
||||
- On first draw lookup? (Lazy — cache fills as scene exercises
|
||||
content. Possible first-use stall.)
|
||||
- At spawn time via the spawn adapter? (Tied to lifecycle. Cleanest
|
||||
but requires touching the spawn path.)
|
||||
|
||||
3. **Translucent pass structure.** Three sub-indirect-draws (one per
|
||||
blend mode) or a single sorted indirect buffer with per-instance
|
||||
blend mode + state-flip at sub-pass boundaries? Or: just iterate
|
||||
per-group like N.4 for translucent only (translucent groups are a
|
||||
small fraction of total)?
|
||||
|
||||
4. **Persistent-mapped indirect + instance buffers.** Use
|
||||
`GL_ARB_buffer_storage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT`?
|
||||
Triple-buffered ring + sync object? Or stick with `glBufferData`
|
||||
(still one upload per frame, just larger)? Persistent mapping is
|
||||
~2-5% per-frame win in our context but adds buffer-management
|
||||
complexity.
|
||||
|
||||
5. **Shader unification.** Keep `mesh_instanced` for legacy + add
|
||||
`mesh_indirect` for modern, or replace `mesh_instanced` entirely?
|
||||
Replacement requires the legacy `InstancedMeshRenderer` (escape
|
||||
hatch under `ACDREAM_USE_WB_FOUNDATION=0`) to also use the new
|
||||
shader, which... probably doesn't matter if we delete legacy in
|
||||
N.6 anyway. Brainstorm.
|
||||
|
||||
6. **Conformance test strategy.** N.4 used visual verification at
|
||||
Holtburg as the gate. N.5's gate is "no visual regression vs N.4
|
||||
AND measurable CPU win." How do we measure CPU? `[WB-DIAG]`
|
||||
counters give draw count + group count; we need frame-time
|
||||
counters too. Add to the dispatcher? Use a profiler?
|
||||
|
||||
7. **Per-instance entity bindless.** `TextureCache.GetOrUpload*`
|
||||
returns a GL name. The dispatcher (or `TextureCache` itself) needs
|
||||
to convert that to a bindless handle. Design questions:
|
||||
- Where does the conversion happen?
|
||||
- When is the texture made resident? (Residency is global state;
|
||||
too many resident textures hits driver limits.)
|
||||
- What about palette/surface overrides — same caching key as the
|
||||
name, just a parallel handle dictionary?
|
||||
|
||||
8. **Escape hatch.** N.4 keeps `ACDREAM_USE_WB_FOUNDATION=0` as a
|
||||
fallback. N.5 needs to decide: does the new shader REPLACE the
|
||||
N.4 dispatcher's draw path (so flag-on means N.5 modern path,
|
||||
flag-off means legacy `InstancedMeshRenderer`)? Or do we add a
|
||||
separate flag (`ACDREAM_USE_MODERN_DRAW`) so users can toggle
|
||||
N.4 vs N.5 vs legacy independently? Three-way flag is more
|
||||
complex but useful for A/B during rollout.
|
||||
|
||||
---
|
||||
|
||||
## Spec structure
|
||||
|
||||
After the brainstorm, the spec doc covers:
|
||||
|
||||
1. **Architecture diagram** — how `WbDrawDispatcher` changes shape.
|
||||
Where the indirect buffer lives. Where bindless handles flow from.
|
||||
2. **Instance data layout** — exact struct, byte offsets, GL attribute
|
||||
pointer setup.
|
||||
3. **TextureCache changes** — new methods, new cache, residency
|
||||
policy.
|
||||
4. **Shader files** — name(s), version, extensions, in/out variables.
|
||||
5. **Conformance tests** — what to write, what coverage to claim.
|
||||
6. **Acceptance criteria** — visual identity to N.4 + measured CPU
|
||||
delta.
|
||||
7. **Risks** — driver bugs in bindless / indirect, residency limits,
|
||||
shader compile issues on weird GPUs, the legacy escape hatch
|
||||
breaking.
|
||||
|
||||
Spec lives at: `docs/superpowers/specs/2026-05-XX-phase-n5-modern-rendering-design.md`.
|
||||
|
||||
## Plan structure
|
||||
|
||||
After the spec, the plan doc lays out the week-by-week task list.
|
||||
Match N.4's plan structure (living document, task checkboxes, commit
|
||||
SHAs appended, adjustments documented inline). Plan lives at:
|
||||
`docs/superpowers/plans/2026-05-XX-phase-n5-modern-rendering.md`.
|
||||
|
||||
Suggested initial breakdown (brainstorm + spec will refine):
|
||||
|
||||
- **Week 1** — Plumbing: bindless handle generation in `TextureCache`,
|
||||
shader rewrite (compile + bind), instance-attrib layout updated to
|
||||
mat4+handle. Dispatcher still uses per-group draws but reads
|
||||
textures bindless. Validate: visual identical to N.4.
|
||||
- **Week 2** — Indirect: build `DrawElementsIndirectCommand` buffer
|
||||
per frame, switch to `glMultiDrawElementsIndirect`. Three-pass
|
||||
translucent (or whatever brainstorm decides). Validate: visual
|
||||
identical, draw-call count drops to 2-4 per frame.
|
||||
- **Week 3** — Polish + ship: persistent-mapped buffers if brainstorm
|
||||
voted yes, profiler/counters, visual verification, flag flip, plan
|
||||
finalization.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria for the whole phase
|
||||
|
||||
- Visual output identical to N.4 (no character regressions, no
|
||||
scenery missing, no z-fighting introduced)
|
||||
- `[WB-DIAG]` shows `drawsIssued` ≤ ~5 per frame (down from N.4's
|
||||
few hundred)
|
||||
- Frame time measurably lower in dense scenes (specify what scenes
|
||||
to test in the spec — probably Holtburg courtyard + Foundry
|
||||
interior)
|
||||
- All tests still green (940/948 + any new conformance tests)
|
||||
- `ACDREAM_USE_WB_FOUNDATION=0` escape hatch still works
|
||||
- Plan doc finalized, roadmap updated, memory captured if N.5
|
||||
surfaces durable lessons (it almost certainly will — bindless
|
||||
+ indirect both have well-known driver gotchas)
|
||||
|
||||
---
|
||||
|
||||
## What you'll be doing in the first 30 minutes
|
||||
|
||||
1. Read this handoff in full.
|
||||
2. Read CLAUDE.md "WB integration cribs" section.
|
||||
3. Read `WbDrawDispatcher.cs` end-to-end.
|
||||
4. Skim WB's `StaticObjectModern.vert/frag` + `ObjectMeshManager.UploadGfxObjMeshData`
|
||||
to ground the reference.
|
||||
5. Verify build is green: `dotnet build`.
|
||||
6. Verify N.4 ship is intact: `dotnet test --filter "FullyQualifiedName~Wb|MatrixComposition"`
|
||||
should produce 60 passing tests, 0 failures.
|
||||
7. Invoke the `superpowers:brainstorming` skill with the user. Walk
|
||||
through the 8 brainstorm questions above. Capture decisions in a
|
||||
spec.
|
||||
8. Write the spec at the path above.
|
||||
9. Write the plan at the path above.
|
||||
10. Begin Week 1 implementation per the plan.
|
||||
|
||||
Don't skip the brainstorm. Multi-draw indirect + bindless have several
|
||||
real driver-compatibility / API-shape decisions that need user input,
|
||||
not "the agent makes a call and goes." This phase is structurally the
|
||||
same shape as N.4 — brainstorm → spec → plan → tasks-with-checkboxes →
|
||||
commits-update-checkboxes → final SHIP commit.
|
||||
|
||||
---
|
||||
|
||||
## Things to NOT do
|
||||
|
||||
- **Don't delete the legacy `InstancedMeshRenderer`.** It's the N.4
|
||||
escape hatch. N.6 retires it after N.5 is proven default-on.
|
||||
- **Don't fork WB.** N.4 deliberately avoided fork patches by using
|
||||
the side-table pattern (`AcSurfaceMetadataTable`). Stay on that
|
||||
path. If you need data WB doesn't expose, add a side-table or
|
||||
decode it yourself from dats.
|
||||
- **Don't try to make per-instance entities use WB's `TextureAtlasManager`.**
|
||||
That's N.6+ territory. acdream's `TextureCache` owns palette/surface
|
||||
overrides because WB's atlas is keyed by `(surfaceId, paletteId,
|
||||
stippling, isSolid)` and our overrides don't fit cleanly. Bindless
|
||||
handles let us escape that mismatch — handles for both atlas-tier
|
||||
AND per-instance-tier textures, no atlas adoption needed.
|
||||
- **Don't skip visual verification.** N.4 surfaced three bugs at
|
||||
visual verification that no test caught. Don't trust "build green +
|
||||
tests pass" — exercise the rendering path with the local ACE server.
|
||||
- **Don't extend the phase scope.** N.5 is bindless + indirect on
|
||||
the existing rendering pipeline. Texture array atlas, GPU-side
|
||||
culling, terrain wiring — all of those are subsequent phases. If
|
||||
the brainstorm tries to expand, push back.
|
||||
|
||||
---
|
||||
|
||||
## Reference: the N.4 dispatcher flow you're modifying
|
||||
|
||||
```
|
||||
Draw(camera, landblockEntries, frustum, ...) {
|
||||
// Phase 1: walk entities, build groups
|
||||
foreach (entity, meshRef, batch) {
|
||||
cull, classify into _groups[GroupKey]
|
||||
}
|
||||
|
||||
// Phase 2: lay matrices contiguously
|
||||
// Phase 3: glBufferData(_instanceVbo, allMatrices)
|
||||
// Phase 4: bind global VAO once
|
||||
// Phase 5: opaque pass (sorted)
|
||||
foreach (group in _opaqueDraws) {
|
||||
glBindTexture(group.handle)
|
||||
glBindBuffer(EBO, group.ibo)
|
||||
glDrawElementsInstancedBaseVertexBaseInstance(...)
|
||||
}
|
||||
// Phase 6: translucent pass
|
||||
}
|
||||
```
|
||||
|
||||
After N.5, Phases 5 and 6 collapse to:
|
||||
|
||||
```
|
||||
glBindBuffer(DRAW_INDIRECT_BUFFER, _opaqueIndirect)
|
||||
glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, 0, opaqueGroups.Count, sizeof(DEIC))
|
||||
glBindBuffer(DRAW_INDIRECT_BUFFER, _translucentIndirect)
|
||||
// 3 sub-calls for translucent or 1 if shader-folded
|
||||
glMultiDrawElementsIndirect(...)
|
||||
```
|
||||
|
||||
That's the destination. Get there cleanly.
|
||||
|
||||
Good luck. Holler at the user if any of the brainstorm questions feel
|
||||
genuinely ambiguous after reading the references — they care about
|
||||
this phase landing right and will engage on design questions.
|
||||
|
|
@ -0,0 +1,721 @@
|
|||
# Phase N.3 — Texture Decoding via WorldBuilder Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace acdream's hand-rolled pixel-format decoders in `SurfaceDecoder` with calls to WorldBuilder's `TextureHelpers.Fill*` methods for every format WB covers (INDEX16, P8, A8R8G8B8, R8G8B8, A8, A8Additive, R5G6B5, A4R4G4B4). Keep our decoders for formats WB lacks (X8R8G8B8, DXT1/3/5 with clipmap postprocess, SolidColor with translucency). Add conformance tests proving byte-identical output for each substituted format. Add the two previously-unsupported formats (R5G6B5, A4R4G4B4) as a bonus.
|
||||
|
||||
**Architecture:** In-place substitution inside `SurfaceDecoder`. Each private `Decode*` method that has a WB equivalent gets rewritten to allocate a `byte[]`, call `TextureHelpers.Fill*` into it, and return a `DecodedTexture`. The critical A8 divergence is resolved by adding an `isAdditive` parameter to `DecodeRenderSurface` — callers that know the `SurfaceType` pass it, terrain alpha callers (which always use the additive/replicate path) pass `isAdditive: true`. No feature flag — conformance tests prove equivalence before substitution, so the old code is deleted in the same pass.
|
||||
|
||||
**Tech Stack:** .NET 10 / C# 13, `Chorizite.OpenGLSDLBackend` (already referenced via `AcDream.Core.csproj`), `DatReaderWriter` for `RenderSurface` / `Palette` / `PixelFormat` types, `BCnEncoder.Net` for DXT (stays ours), xUnit for tests.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-08-phase-n-worldbuilder-migration-design.md`
|
||||
**Inventory:** `docs/architecture/worldbuilder-inventory.md`
|
||||
**Handoff:** `docs/research/2026-05-08-phase-n3-handoff.md`
|
||||
|
||||
**Prerequisite:** Phase N.0 shipped (submodule wired), Phase N.1 shipped (scenery migration). `AcDream.Core.csproj` already references `Chorizite.OpenGLSDLBackend`.
|
||||
|
||||
---
|
||||
|
||||
## Audit Summary
|
||||
|
||||
| # | Our function | WB equivalent | Action |
|
||||
|---|---|---|---|
|
||||
| 1 | `DecodeIndex16` | `TextureHelpers.FillIndex16` | **Substitute** |
|
||||
| 2 | `DecodeP8` | `TextureHelpers.FillP8` | **Substitute** |
|
||||
| 3 | `DecodeA8R8G8B8` | `TextureHelpers.FillA8R8G8B8` | **Substitute** |
|
||||
| 4 | `DecodeR8G8B8` | `TextureHelpers.FillR8G8B8` | **Substitute** |
|
||||
| 5 | `DecodeA8` | `TextureHelpers.FillA8` + `FillA8Additive` | **Substitute** (additive-aware) |
|
||||
| 6 | `DecodeX8R8G8B8` | None | **Keep ours** |
|
||||
| 7 | `DecodeBc` (DXT1/3/5) | None in TextureHelpers | **Keep ours** |
|
||||
| 8 | `DecodeSolidColor` | Different semantics | **Keep ours** |
|
||||
| 9 | (missing) | `TextureHelpers.FillR5G6B5` | **Add new** |
|
||||
| 10 | (missing) | `TextureHelpers.FillA4R4G4B4` | **Add new** |
|
||||
|
||||
### A8 divergence detail
|
||||
|
||||
- **Our current `DecodeA8`:** R=G=B=A=val (all four channels = alpha byte)
|
||||
- **WB `FillA8`:** R=G=B=255, A=val (white + alpha)
|
||||
- **WB `FillA8Additive`:** R=G=B=A=val (same as our current behavior)
|
||||
|
||||
WB dispatches based on `surface.Type.HasFlag(SurfaceType.Additive)`:
|
||||
- Additive surfaces → `FillA8Additive` (R=G=B=A=val)
|
||||
- Non-additive surfaces → `FillA8` (R=G=B=255, A=val)
|
||||
|
||||
Our current code always does the additive path. This is correct for terrain alpha masks (used as blend weights where `.r` channel = `.a` channel matters) but diverges from WB for non-additive A8 entity textures. Resolution: thread an `isAdditive` flag through the decode API.
|
||||
|
||||
---
|
||||
|
||||
## File Plan
|
||||
|
||||
| File | Disposition | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/AcDream.Core/Textures/SurfaceDecoder.cs` | MODIFY | Replace 5 private decode methods with WB `TextureHelpers.Fill*` calls. Add `isAdditive` parameter to `DecodeRenderSurface`. Add R5G6B5 + A4R4G4B4 format cases. Keep X8R8G8B8, DXT, SolidColor. |
|
||||
| `src/AcDream.App/Rendering/TextureCache.cs` | MODIFY | Pass `surface.Type.HasFlag(SurfaceType.Additive)` as `isAdditive` to `SurfaceDecoder.DecodeRenderSurface`. |
|
||||
| `src/AcDream.App/Rendering/TerrainAtlas.cs` | MODIFY | Pass `isAdditive: true` to `SurfaceDecoder.DecodeRenderSurface` in `TryDecodeAlphaMap` (terrain alpha masks always use the replicate-all-channels path). |
|
||||
| `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs` | NEW | Per-format conformance tests: synthetic byte arrays decoded by both our old logic and WB's `TextureHelpers.Fill*`, asserting byte-identical output. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Conformance tests for the 5 clean substitutions
|
||||
|
||||
Write tests first, run them to prove our current output matches WB's output for each format. These tests lock in the equivalence BEFORE any code changes — if any test fails, we know the formats actually diverge and must investigate.
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs`
|
||||
|
||||
- [ ] **Step 1.1: Create the conformance test file with INDEX16 test**
|
||||
|
||||
Create `tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs`:
|
||||
|
||||
```csharp
|
||||
using Chorizite.OpenGLSDLBackend.Lib;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Tests.Textures;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests proving WorldBuilder's TextureHelpers.Fill* methods
|
||||
/// produce byte-identical output to our SurfaceDecoder private methods
|
||||
/// for each pixel format. These tests run BEFORE the substitution — if
|
||||
/// one fails, the formats diverge and we must investigate, not "fix" the test.
|
||||
/// </summary>
|
||||
public sealed class TextureDecodeConformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void FillIndex16_MatchesOurDecodeIndex16()
|
||||
{
|
||||
// 2x2 INDEX16 texture: 4 pixels, each a 16-bit LE palette index.
|
||||
// Palette: index 0 = (R=10, G=20, B=30, A=255), index 1 = (R=40, G=50, B=60, A=200)
|
||||
// Pixel data: [0x0000, 0x0100, 0x0100, 0x0000] (indices 0, 1, 1, 0)
|
||||
byte[] src = [0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00];
|
||||
int w = 2, h = 2;
|
||||
|
||||
var palette = new Palette();
|
||||
palette.Colors.Add(new ColorARGB { Red = 10, Green = 20, Blue = 30, Alpha = 255 });
|
||||
palette.Colors.Add(new ColorARGB { Red = 40, Green = 50, Blue = 60, Alpha = 200 });
|
||||
|
||||
// Our decode
|
||||
byte[] ours = new byte[w * h * 4];
|
||||
for (int i = 0; i < w * h; i++)
|
||||
{
|
||||
int si = i * 2;
|
||||
ushort idx = (ushort)(src[si] | (src[si + 1] << 8));
|
||||
var c = palette.Colors[idx];
|
||||
int di = i * 4;
|
||||
ours[di + 0] = c.Red;
|
||||
ours[di + 1] = c.Green;
|
||||
ours[di + 2] = c.Blue;
|
||||
ours[di + 3] = c.Alpha;
|
||||
}
|
||||
|
||||
// WB decode
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h);
|
||||
|
||||
Assert.Equal(ours, wb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillIndex16_ClipMap_MatchesOurClipMapBehavior()
|
||||
{
|
||||
// Index 3 (< 8) should be transparent, index 10 should be normal
|
||||
byte[] src = [0x03, 0x00, 0x0A, 0x00];
|
||||
int w = 2, h = 1;
|
||||
|
||||
var palette = new Palette();
|
||||
for (int i = 0; i < 16; i++)
|
||||
palette.Colors.Add(new ColorARGB { Red = (byte)(i * 10), Green = (byte)(i * 15), Blue = (byte)(i * 5), Alpha = 255 });
|
||||
|
||||
// Our clipmap decode: index < 8 → all zeros
|
||||
byte[] ours = new byte[w * h * 4];
|
||||
for (int i = 0; i < w * h; i++)
|
||||
{
|
||||
int si = i * 2;
|
||||
ushort idx = (ushort)(src[si] | (src[si + 1] << 8));
|
||||
int di = i * 4;
|
||||
if (idx < 8)
|
||||
{
|
||||
ours[di] = ours[di + 1] = ours[di + 2] = ours[di + 3] = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var c = palette.Colors[idx];
|
||||
ours[di + 0] = c.Red;
|
||||
ours[di + 1] = c.Green;
|
||||
ours[di + 2] = c.Blue;
|
||||
ours[di + 3] = c.Alpha;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillIndex16(src, palette, wb.AsSpan(), w, h, isClipMap: true);
|
||||
|
||||
Assert.Equal(ours, wb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillP8_MatchesOurDecodeP8()
|
||||
{
|
||||
// 2x2 P8 texture: 4 pixels, each a single-byte palette index.
|
||||
byte[] src = [0, 1, 1, 0];
|
||||
int w = 2, h = 2;
|
||||
|
||||
var palette = new Palette();
|
||||
palette.Colors.Add(new ColorARGB { Red = 100, Green = 110, Blue = 120, Alpha = 255 });
|
||||
palette.Colors.Add(new ColorARGB { Red = 200, Green = 210, Blue = 220, Alpha = 180 });
|
||||
|
||||
byte[] ours = new byte[w * h * 4];
|
||||
for (int i = 0; i < w * h; i++)
|
||||
{
|
||||
var c = palette.Colors[src[i]];
|
||||
int di = i * 4;
|
||||
ours[di + 0] = c.Red;
|
||||
ours[di + 1] = c.Green;
|
||||
ours[di + 2] = c.Blue;
|
||||
ours[di + 3] = c.Alpha;
|
||||
}
|
||||
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillP8(src, palette, wb.AsSpan(), w, h);
|
||||
|
||||
Assert.Equal(ours, wb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillA8R8G8B8_MatchesOurDecodeA8R8G8B8()
|
||||
{
|
||||
// 2x1 A8R8G8B8: on-disk order is B, G, R, A per pixel
|
||||
byte[] src = [0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD];
|
||||
int w = 2, h = 1;
|
||||
|
||||
// Our decode: swap B,G,R,A → R,G,B,A
|
||||
byte[] ours = new byte[w * h * 4];
|
||||
for (int i = 0; i < w * h; i++)
|
||||
{
|
||||
int s = i * 4;
|
||||
ours[s + 0] = src[s + 2]; // R
|
||||
ours[s + 1] = src[s + 1]; // G
|
||||
ours[s + 2] = src[s + 0]; // B
|
||||
ours[s + 3] = src[s + 3]; // A
|
||||
}
|
||||
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillA8R8G8B8(src, wb.AsSpan(), w, h);
|
||||
|
||||
Assert.Equal(ours, wb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillR8G8B8_MatchesOurDecodeR8G8B8()
|
||||
{
|
||||
// 2x1 R8G8B8: on-disk order is B, G, R per pixel (3 bytes)
|
||||
byte[] src = [0x10, 0x20, 0x30, 0xAA, 0xBB, 0xCC];
|
||||
int w = 2, h = 1;
|
||||
|
||||
// Our decode: swap B,G,R → R,G,B,255
|
||||
byte[] ours = new byte[w * h * 4];
|
||||
for (int i = 0; i < w * h; i++)
|
||||
{
|
||||
int si = i * 3;
|
||||
int di = i * 4;
|
||||
ours[di + 0] = src[si + 2]; // R
|
||||
ours[di + 1] = src[si + 1]; // G
|
||||
ours[di + 2] = src[si + 0]; // B
|
||||
ours[di + 3] = 0xFF;
|
||||
}
|
||||
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillR8G8B8(src, wb.AsSpan(), w, h);
|
||||
|
||||
Assert.Equal(ours, wb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillA8Additive_MatchesOurDecodeA8()
|
||||
{
|
||||
// 4x1 A8: each byte replicated to all four channels (our current behavior)
|
||||
byte[] src = [0x00, 0x80, 0xFF, 0x42];
|
||||
int w = 4, h = 1;
|
||||
|
||||
byte[] ours = new byte[w * h * 4];
|
||||
for (int i = 0; i < w * h; i++)
|
||||
{
|
||||
byte a = src[i];
|
||||
int d = i * 4;
|
||||
ours[d + 0] = a;
|
||||
ours[d + 1] = a;
|
||||
ours[d + 2] = a;
|
||||
ours[d + 3] = a;
|
||||
}
|
||||
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillA8Additive(src, wb.AsSpan(), w, h);
|
||||
|
||||
Assert.Equal(ours, wb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillA8_NonAdditive_ProducesWhitePlusAlpha()
|
||||
{
|
||||
// WB's non-additive A8: R=G=B=255, A=val
|
||||
// This is DIFFERENT from our current DecodeA8 (which does R=G=B=A=val).
|
||||
// This test documents the WB behavior we're adopting for non-additive surfaces.
|
||||
byte[] src = [0x00, 0x80, 0xFF, 0x42];
|
||||
int w = 4, h = 1;
|
||||
|
||||
byte[] expected = new byte[w * h * 4];
|
||||
for (int i = 0; i < w * h; i++)
|
||||
{
|
||||
int d = i * 4;
|
||||
expected[d + 0] = 255;
|
||||
expected[d + 1] = 255;
|
||||
expected[d + 2] = 255;
|
||||
expected[d + 3] = src[i];
|
||||
}
|
||||
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillA8(src, wb.AsSpan(), w, h);
|
||||
|
||||
Assert.Equal(expected, wb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillR5G6B5_ProducesExpectedRgba()
|
||||
{
|
||||
// R5G6B5: 16-bit packed RGB. Not currently handled by our decoder.
|
||||
// White (0xFFFF) → R=248,G=252,B=248,A=255 (bit expansion truncation)
|
||||
// Black (0x0000) → R=0,G=0,B=0,A=255
|
||||
byte[] src = [0xFF, 0xFF, 0x00, 0x00];
|
||||
int w = 2, h = 1;
|
||||
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillR5G6B5(src, wb.AsSpan(), w, h);
|
||||
|
||||
// Pixel 0: white-ish
|
||||
Assert.Equal(248, wb[0]); // R: 31 << 3
|
||||
Assert.Equal(252, wb[1]); // G: 63 << 2
|
||||
Assert.Equal(248, wb[2]); // B: 31 << 3
|
||||
Assert.Equal(255, wb[3]); // A
|
||||
|
||||
// Pixel 1: black
|
||||
Assert.Equal(0, wb[4]);
|
||||
Assert.Equal(0, wb[5]);
|
||||
Assert.Equal(0, wb[6]);
|
||||
Assert.Equal(255, wb[7]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillA4R4G4B4_ProducesExpectedRgba()
|
||||
{
|
||||
// A4R4G4B4: 16-bit packed ARGB. Not currently handled by our decoder.
|
||||
// 0xF8C4 → A=15*17=255, R=8*17=136, G=12*17=204, B=4*17=68
|
||||
byte[] src = [0xC4, 0xF8];
|
||||
int w = 1, h = 1;
|
||||
|
||||
byte[] wb = new byte[w * h * 4];
|
||||
TextureHelpers.FillA4R4G4B4(src, wb.AsSpan(), w, h);
|
||||
|
||||
Assert.Equal(136, wb[0]); // R: ((0xF8C4 >> 8) & 0x0F) * 17 = 8*17
|
||||
Assert.Equal(204, wb[1]); // G: ((0xF8C4 >> 4) & 0x0F) * 17 = 12*17
|
||||
Assert.Equal(68, wb[2]); // B: (0xF8C4 & 0x0F) * 17 = 4*17
|
||||
Assert.Equal(255, wb[3]); // A: ((0xF8C4 >> 12) & 0x0F) * 17 = 15*17
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal`
|
||||
|
||||
Expected: All 9 tests PASS. These tests compare our current algorithm inline against WB's `TextureHelpers` — if any fail, it means the algorithms actually diverge and we must investigate before proceeding.
|
||||
|
||||
- [ ] **Step 1.3: Commit**
|
||||
|
||||
```
|
||||
git add tests/AcDream.Core.Tests/Textures/TextureDecodeConformanceTests.cs
|
||||
git commit -m "test(N.3): conformance tests proving WB TextureHelpers matches our decode
|
||||
|
||||
Nine tests covering INDEX16 (normal + clipmap), P8, A8R8G8B8, R8G8B8,
|
||||
A8Additive (matches our current DecodeA8), A8 non-additive (documents
|
||||
the divergence), R5G6B5, A4R4G4B4. All run before any substitution —
|
||||
they prove equivalence, not test the substitution.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add `isAdditive` parameter to SurfaceDecoder and wire A8 split
|
||||
|
||||
Thread the `isAdditive` flag through the decode API so the A8 format can dispatch to either WB path. Update all three callers.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Textures/SurfaceDecoder.cs`
|
||||
- Modify: `src/AcDream.App/Rendering/TextureCache.cs`
|
||||
- Modify: `src/AcDream.App/Rendering/TerrainAtlas.cs`
|
||||
|
||||
- [ ] **Step 2.1: Add `isAdditive` parameter to `DecodeRenderSurface`**
|
||||
|
||||
In `src/AcDream.Core/Textures/SurfaceDecoder.cs`, change the main public overload signature from:
|
||||
|
||||
```csharp
|
||||
public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false)
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false)
|
||||
```
|
||||
|
||||
And update the `PFID_A8`/`PFID_CUSTOM_LSCAPE_ALPHA` case in the switch from:
|
||||
|
||||
```csharp
|
||||
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive),
|
||||
```
|
||||
|
||||
And update the no-palette overload from:
|
||||
|
||||
```csharp
|
||||
public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
|
||||
=> DecodeRenderSurface(rs, palette: null);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
|
||||
=> DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false);
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Split `DecodeA8` into additive vs non-additive**
|
||||
|
||||
In `SurfaceDecoder.cs`, change the `DecodeA8` method signature and add the split:
|
||||
|
||||
```csharp
|
||||
private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
|
||||
{
|
||||
int expected = rs.Width * rs.Height;
|
||||
if (rs.SourceData.Length < expected)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[expected * 4];
|
||||
if (isAdditive)
|
||||
{
|
||||
// Additive: R=G=B=A=val (current behavior, matches WB FillA8Additive)
|
||||
for (int i = 0; i < expected; i++)
|
||||
{
|
||||
byte a = rs.SourceData[i];
|
||||
int d = i * 4;
|
||||
rgba[d + 0] = a;
|
||||
rgba[d + 1] = a;
|
||||
rgba[d + 2] = a;
|
||||
rgba[d + 3] = a;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-additive: R=G=B=255, A=val (matches WB FillA8)
|
||||
for (int i = 0; i < expected; i++)
|
||||
{
|
||||
int d = i * 4;
|
||||
rgba[d + 0] = 255;
|
||||
rgba[d + 1] = 255;
|
||||
rgba[d + 2] = 255;
|
||||
rgba[d + 3] = rs.SourceData[i];
|
||||
}
|
||||
}
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3: Update TextureCache to pass `isAdditive`**
|
||||
|
||||
In `src/AcDream.App/Rendering/TextureCache.cs`, in `DecodeFromDats`, change line 203 from:
|
||||
|
||||
```csharp
|
||||
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
|
||||
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Update TerrainAtlas to pass `isAdditive: true`**
|
||||
|
||||
In `src/AcDream.App/Rendering/TerrainAtlas.cs`, in `TryDecodeAlphaMap`, change line 322 from:
|
||||
|
||||
```csharp
|
||||
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```csharp
|
||||
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
|
||||
```
|
||||
|
||||
The terrain alpha masks MUST use the additive path (R=G=B=A=val) because our terrain blending shader reads from `.r` for the blend weight.
|
||||
|
||||
- [ ] **Step 2.5: Build and test**
|
||||
|
||||
Run: `dotnet build --verbosity quiet && dotnet test tests/AcDream.Core.Tests --filter "FullyQualifiedName~TextureDecodeConformanceTests" --verbosity normal`
|
||||
|
||||
Expected: Build green, all 9 conformance tests still pass.
|
||||
|
||||
- [ ] **Step 2.6: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.Core/Textures/SurfaceDecoder.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/Rendering/TerrainAtlas.cs
|
||||
git commit -m "refactor(N.3): thread isAdditive through A8 decode path
|
||||
|
||||
SurfaceDecoder.DecodeRenderSurface now accepts isAdditive parameter.
|
||||
A8/CUSTOM_LSCAPE_ALPHA format splits:
|
||||
- isAdditive=true: R=G=B=A=val (terrain alpha, additive entity textures)
|
||||
- isAdditive=false: R=G=B=255, A=val (non-additive entity textures)
|
||||
|
||||
TextureCache passes surface.Type.HasFlag(SurfaceType.Additive).
|
||||
TerrainAtlas passes isAdditive:true (alpha masks always replicate).
|
||||
This aligns with WB ObjectMeshManager's dispatch logic.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Substitute 5 decode methods with WB TextureHelpers calls
|
||||
|
||||
Replace the body of each private decode method with a call to the corresponding WB `TextureHelpers.Fill*` method. Add the two new format cases (R5G6B5, A4R4G4B4).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.Core/Textures/SurfaceDecoder.cs`
|
||||
|
||||
- [ ] **Step 3.1: Add WB using directive**
|
||||
|
||||
At the top of `SurfaceDecoder.cs`, add:
|
||||
|
||||
```csharp
|
||||
using Chorizite.OpenGLSDLBackend.Lib;
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: Replace `DecodeIndex16`**
|
||||
|
||||
Replace the body of `DecodeIndex16` with:
|
||||
|
||||
```csharp
|
||||
private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap)
|
||||
{
|
||||
int expectedBytes = rs.Width * rs.Height * 2;
|
||||
if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3: Replace `DecodeP8`**
|
||||
|
||||
Replace the body of `DecodeP8` with:
|
||||
|
||||
```csharp
|
||||
private static DecodedTexture DecodeP8(RenderSurface rs, Palette palette, bool isClipMap)
|
||||
{
|
||||
int expectedBytes = rs.Width * rs.Height;
|
||||
if (rs.SourceData.Length < expectedBytes || palette.Colors.Count == 0)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.4: Replace `DecodeA8R8G8B8`**
|
||||
|
||||
Replace the body of `DecodeA8R8G8B8` with:
|
||||
|
||||
```csharp
|
||||
private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs)
|
||||
{
|
||||
int expected = rs.Width * rs.Height * 4;
|
||||
if (rs.SourceData.Length < expected)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[expected];
|
||||
TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.5: Replace `DecodeR8G8B8`**
|
||||
|
||||
Replace the body of `DecodeR8G8B8` with:
|
||||
|
||||
```csharp
|
||||
private static DecodedTexture DecodeR8G8B8(RenderSurface rs)
|
||||
{
|
||||
int expectedBytes = rs.Width * rs.Height * 3;
|
||||
if (rs.SourceData.Length < expectedBytes)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.6: Replace `DecodeA8`**
|
||||
|
||||
Replace the body of `DecodeA8` with:
|
||||
|
||||
```csharp
|
||||
private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
|
||||
{
|
||||
int expected = rs.Width * rs.Height;
|
||||
if (rs.SourceData.Length < expected)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[expected * 4];
|
||||
if (isAdditive)
|
||||
TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
else
|
||||
TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.7: Add R5G6B5 and A4R4G4B4 cases to the format switch**
|
||||
|
||||
In the `DecodeRenderSurface` switch, add two new cases before the `_ => DecodedTexture.Magenta` default:
|
||||
|
||||
```csharp
|
||||
PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs),
|
||||
PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs),
|
||||
```
|
||||
|
||||
And add the two new private methods:
|
||||
|
||||
```csharp
|
||||
private static DecodedTexture DecodeR5G6B5(RenderSurface rs)
|
||||
{
|
||||
int expectedBytes = rs.Width * rs.Height * 2;
|
||||
if (rs.SourceData.Length < expectedBytes)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs)
|
||||
{
|
||||
int expectedBytes = rs.Width * rs.Height * 2;
|
||||
if (rs.SourceData.Length < expectedBytes)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.8: Build and run all tests**
|
||||
|
||||
Run: `dotnet build --verbosity quiet && dotnet test --verbosity quiet`
|
||||
|
||||
Expected: Build green, 873+ tests pass, 8 pre-existing failures unchanged.
|
||||
|
||||
- [ ] **Step 3.9: Commit**
|
||||
|
||||
```
|
||||
git add src/AcDream.Core/Textures/SurfaceDecoder.cs
|
||||
git commit -m "phase(N.3): substitute 5 decode methods with WB TextureHelpers
|
||||
|
||||
INDEX16, P8, A8R8G8B8, R8G8B8, A8 now delegate to
|
||||
TextureHelpers.FillIndex16/FillP8/FillA8R8G8B8/FillR8G8B8/
|
||||
FillA8/FillA8Additive. Validation + DecodedTexture wrapping stays ours.
|
||||
X8R8G8B8, DXT1/3/5, SolidColor remain our implementations (no WB equiv).
|
||||
|
||||
Bonus: R5G6B5 + A4R4G4B4 formats now handled (previously fell to magenta).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update roadmap + ISSUES, final cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plans/2026-04-11-roadmap.md` — mark N.3 shipped
|
||||
- Modify: `docs/ISSUES.md` — file any cosmetic deltas found
|
||||
|
||||
- [ ] **Step 4.1: Update roadmap**
|
||||
|
||||
In the roadmap, update the Phase N.3 entry to show shipped status with today's date and commit hash (obtain from `git log -1 --format='%h'`).
|
||||
|
||||
- [ ] **Step 4.2: File any ISSUES**
|
||||
|
||||
If the A8 non-additive behavioral change surfaces any visual delta at Holtburg during verification, file it in `docs/ISSUES.md`. Example:
|
||||
|
||||
```markdown
|
||||
### #NN: A8 non-additive textures now render white+alpha instead of gray+alpha
|
||||
|
||||
**Status:** OPEN
|
||||
**Phase:** N.3
|
||||
**Symptom:** [describe if applicable]
|
||||
**Root cause:** WB's FillA8 outputs R=G=B=255,A=val; our old DecodeA8 output R=G=B=A=val. For non-additive surfaces this is a behavioral change.
|
||||
**Impact:** [assess after visual verification]
|
||||
```
|
||||
|
||||
If no visual delta is observed, skip this step — no issue to file.
|
||||
|
||||
- [ ] **Step 4.3: Commit**
|
||||
|
||||
```
|
||||
git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md
|
||||
git commit -m "docs: mark Phase N.3 shipped, update ISSUES if applicable
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Visual verification (human-in-the-loop)
|
||||
|
||||
This task requires the user to launch the client and inspect textures at Holtburg.
|
||||
|
||||
- [ ] **Step 5.1: Build and launch**
|
||||
|
||||
```powershell
|
||||
dotnet build --verbosity quiet
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
|
||||
```
|
||||
|
||||
- [ ] **Step 5.2: Visual checks**
|
||||
|
||||
Walk around Holtburg and verify:
|
||||
1. **Terrain textures** — grass, dirt, sand transitions look correct (not magenta, not discolored)
|
||||
2. **Tree/bush textures** — scenery objects textured correctly (clipmap alpha works)
|
||||
3. **Building textures** — walls, roofs, doors look right
|
||||
4. **Sky/clouds** — if A8 textures are involved, verify they still render
|
||||
5. **Particles** — rain/aurora if weather is active
|
||||
|
||||
If all look correct, N.3 is done. If regressions found, file in ISSUES.md per the handoff doc's "whackamole stops the migration" rule.
|
||||
2737
docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
Normal file
2737
docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,414 @@
|
|||
# Phase N.4 — Rendering Pipeline Foundation: Design
|
||||
|
||||
**Date:** 2026-05-08
|
||||
**Status:** Design complete, awaiting plan generation.
|
||||
**Parent design:** [2026-05-08-phase-n-worldbuilder-migration-design.md](2026-05-08-phase-n-worldbuilder-migration-design.md)
|
||||
**Roadmap entry:** [docs/plans/2026-04-11-roadmap.md](../../plans/2026-04-11-roadmap.md) — Phase N.4
|
||||
**Inventory reference:** [docs/architecture/worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md)
|
||||
**Related:** [ISSUE #51](../../ISSUES.md) — terrain split formula divergence (handled in N.5).
|
||||
|
||||
## Goal
|
||||
|
||||
Adopt WB's `Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager` and
|
||||
`TextureAtlasManager` as acdream's rendering pipeline foundation. This
|
||||
is the integration that unblocks Phases N.5 (terrain), N.6 (static
|
||||
objects), N.7 (env cells), N.8 (sky/particles), and absorbs N.10
|
||||
(GL infrastructure consolidation). N.4 ships no visible change — the
|
||||
world should look identical to today; what changes is the infrastructure
|
||||
behind the scenes.
|
||||
|
||||
## Why
|
||||
|
||||
**The roadmap's original "drop-in helper" framing was wrong for N.4.**
|
||||
Discovery during brainstorm 2026-05-08: WB's `ObjectMeshManager` is not
|
||||
a stateless helper class like `SceneryHelpers` (N.1) or `TextureHelpers`
|
||||
(N.3). It is a 2070-line stateful asset pipeline that owns:
|
||||
|
||||
- GPU resources per object (VAO/VBO/IBO via `ObjectRenderData`)
|
||||
- Reference counting (`IncrementRefCount`/`DecrementRefCount`)
|
||||
- LRU cache + memory budget (default 1 GB)
|
||||
- Background-thread CPU mesh preparation, main-thread GPU upload
|
||||
- Shared texture atlases keyed by `(Width, Height, Format)`
|
||||
- Particle emitter staging
|
||||
- Modern bindless rendering path on capable hardware
|
||||
|
||||
**There is no clean "just the mesh extraction" entry point.** WB's
|
||||
`BuildPolygonIndices` (the algorithm we already faithfully ported into
|
||||
[GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs)) is a
|
||||
private method tightly coupled to atlas batching. To use WB's tested
|
||||
infrastructure at all means adopting the whole pipeline.
|
||||
|
||||
**N.5 + N.6 + N.7 build on this foundation.** WB's
|
||||
`TerrainRenderManager`, `StaticObjectRenderManager`, and
|
||||
`EnvCellRenderManager` all consume `ObjectMeshManager` (or its atlas)
|
||||
as substrate. Without N.4, each later phase would need to either fork
|
||||
those render managers or duplicate the infrastructure. Doing N.4 now
|
||||
means N.5/N.6/N.7 become integration phases on top of shared plumbing,
|
||||
not parallel infrastructure builds.
|
||||
|
||||
**Real benefits beyond infrastructure consolidation:**
|
||||
|
||||
1. **Memory budget with LRU eviction** (we don't have this; bigger
|
||||
stream radii currently risk OOM).
|
||||
2. **Texture atlasing → ~4-8× fewer draw calls** for static scenery
|
||||
(~1100 entities at Holtburg today).
|
||||
3. **Background-thread mesh preparation** — addresses the
|
||||
render-thread-stall problem from
|
||||
[feedback_phase_a1_hotfix_saga.md](../../../memory/feedback_phase_a1_hotfix_saga.md)
|
||||
that forced us to revert async streaming.
|
||||
4. **Bindless textures** on capable hardware (free perf when
|
||||
GL 4.3 + `GL_ARB_bindless_texture` are available).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two-tier rendering split
|
||||
|
||||
acdream's content cleanly partitions into two categories that map onto
|
||||
two rendering paths:
|
||||
|
||||
| Tier | Content | Why this category | Path |
|
||||
|---|---|---|---|
|
||||
| **Atlas (shared)** | Terrain props, scenery (procedural — trees / rocks / bushes / fences from ~50 templates), buildings, slabs, dungeon static geometry | Client-side procedural; no per-instance variation; many instances of few unique meshes | WB's `ObjectMeshManager` + `TextureAtlasManager`. Big sharing wins (1100 entities ↦ ~50 atlas slots). |
|
||||
| **Per-instance (customized)** | Server-spawned entities (`CreateObject`): characters, creatures, equipped items. Anything carrying `SubPalettes` / `TextureChanges` / `AnimPartChange` / `HiddenParts` / `GfxObjRemapping` | Always uniquely customized; few visible at a time (~10-50) | Existing [TextureCache.GetOrUploadWithPaletteOverride](../../../src/AcDream.App/Rendering/TextureCache.cs:122). Already hash-keys overrides for caching; already tested. |
|
||||
|
||||
**Routing rule**:
|
||||
|
||||
- Objects spawned by `LandblockStreamLoader` (procedural, no
|
||||
customization) → atlas tier.
|
||||
- Objects spawned by `CreateObject` (network, always customized) →
|
||||
per-instance tier.
|
||||
|
||||
The boundary mirrors a distinction that already exists in our
|
||||
networking model. We are not inventing a new conceptual line; we are
|
||||
matching one that's already there.
|
||||
|
||||
### Animation handling
|
||||
|
||||
**Core insight:** in AC, animation is per-part TRANSFORM changes, not
|
||||
mesh changes. A creature's Setup is a list of rigid GfxObj parts (head,
|
||||
body, hands, etc.). Each part is its own static mesh; vertices inside
|
||||
each part never change. Animation moves the parts as rigid bodies.
|
||||
|
||||
This means **mesh data is static even for animated entities** — the
|
||||
cache works fine. Only the per-part transforms change per frame, and
|
||||
those don't live in the mesh cache.
|
||||
|
||||
**Composition at draw time:**
|
||||
|
||||
```
|
||||
final_part_world_matrix
|
||||
= entity_world_transform
|
||||
× animation_override (from AnimationSequencer, this frame)
|
||||
× rest_pose_transform (cached in ObjectMeshData.SetupParts)
|
||||
```
|
||||
|
||||
- WB's `ObjectMeshData.SetupParts: List<(ulong GfxObjId, Matrix4x4 Transform)>`
|
||||
stores the rest-pose transforms (cached, shared).
|
||||
- Our existing [AnimationSequencer](../../../src/AcDream.Core/Animation/AnimationSequencer.cs)
|
||||
is **untouched**. It continues to produce per-part override matrices
|
||||
per frame, driven by motion table + current motion command + tick.
|
||||
- The renderer composes the three matrices per part per draw and pushes
|
||||
the result as a uniform/instance attribute.
|
||||
|
||||
**`AnimPartChange`** (server swaps a part's GfxObj — e.g., wielding a
|
||||
sword): per-entity override map `Dictionary<int partIndex, ulong gfxObjId>`.
|
||||
At draw time, look up override; fall back to cached Setup part. WB's
|
||||
mesh manager caches the override GfxObj's mesh data the same way as
|
||||
any other part — first time seen, then shared.
|
||||
|
||||
**`HiddenParts`** (bitmask hiding parts): per-entity `ulong` bitmask.
|
||||
Draw loop: `if (hiddenMask & (1 << partIndex)) continue;`.
|
||||
|
||||
**Per-frame CPU cost:** ~50 visible animated entities × ~20 parts =
|
||||
~1000 matrix multiplies per frame. Sub-millisecond on any CPU.
|
||||
|
||||
**GPU-side per-draw transform push:** start with uniform-per-draw
|
||||
(simple, ~1000 draws/frame for animated entities — fine). Promote to
|
||||
per-instance vertex attribute (instanced draw, ~50 draws/frame) only
|
||||
if measured perf demands it.
|
||||
|
||||
### Streaming loader integration
|
||||
|
||||
Adapter shim, ~200 LOC, sits between `LandblockStreamLoader` /
|
||||
`WorldSession` and `ObjectMeshManager`:
|
||||
|
||||
| Source event | Adapter call | What `ObjectMeshManager` does |
|
||||
|---|---|---|
|
||||
| Landblock loaded by streaming | `IncrementRefCount(id)` per unique GfxObj/Setup id in `Setups[]` + `Statics[]` | Begins CPU prep on background worker if not cached; queues GPU upload on main thread |
|
||||
| Landblock unloaded by streaming (radius hysteresis) | `DecrementRefCount(id)` per object | Drops to LRU when count reaches 0; LRU + 1 GB memory budget handles eviction |
|
||||
| Network `CreateObject` | Per-instance path: build `PaletteOverride` from `SubPalettes`, decode through `TextureCache.GetOrUploadWithPaletteOverride`, register entity-local mesh data | Bypasses WB atlas; stays in our existing per-instance path |
|
||||
| Network `RemoveObject` | Release per-instance state for entity | (no WB call) |
|
||||
|
||||
**Pending-spawn list preservation:** the streaming loader's existing
|
||||
[pending-spawn list](../../../memory/feedback_phase_a1_hotfix_saga.md)
|
||||
mechanism stays in place. `CreateObject` arriving before its landblock
|
||||
streams in still parks until the landblock arrives, then drains. The
|
||||
adapter is invoked when the spawn drains, not when it parks.
|
||||
|
||||
**Thread safety:** WB's `ObjectMeshManager` uses `ConcurrentDictionary`
|
||||
for its internal state and is designed to take `IncrementRefCount` calls
|
||||
from any thread. Our streaming worker can call it directly without
|
||||
marshaling onto the render thread. (This is part of why WB's design
|
||||
addresses the render-thread-stall problem.)
|
||||
|
||||
### Surface metadata strategy
|
||||
|
||||
**Side-table, not fork patch.**
|
||||
|
||||
WB's `MeshBatchData` carries `IsTransparent` + `IsAdditive`. We need to
|
||||
preserve these acdream-specific surface properties already present in
|
||||
our [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs):
|
||||
|
||||
- `Translucency` (`TranslucencyKind` enum: Opaque / AlphaBlend / Additive)
|
||||
- `Luminosity` (float, self-illumination coefficient — sky pass critical)
|
||||
- `Diffuse` (float)
|
||||
- `SurfOpacity` (float, derived from `Surface.Translucency`)
|
||||
- `NeedsUvRepeat` (bool, derived from authored UV range — sky-pass wrap-mode selection)
|
||||
- `DisableFog` (bool, derived from emissive surface flags — sky-pass fog skip)
|
||||
|
||||
Our renderer integration maintains a side-table:
|
||||
`Dictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata>`. The
|
||||
key matches the shape of today's `GfxObjSubMesh` — a (GfxObj, surface
|
||||
index) pair uniquely identifies a per-surface render batch. Stable
|
||||
across `IncrementRefCount` cycles. The metadata is computed once at
|
||||
mesh-extraction time (matching today's `GfxObjMesh.Build`) and looked
|
||||
up at draw time.
|
||||
|
||||
**Why side-table not fork patch:**
|
||||
|
||||
- Keeps WB's types pristine; upstream merges stay clean.
|
||||
- Lookup cost is negligible (one hash lookup per batch per frame).
|
||||
- Easy to roll back if WB's design evolves to incorporate similar fields.
|
||||
- Preserves the careful sky-pass work done in C.1 with no risk to sky
|
||||
rendering during this migration.
|
||||
|
||||
### Fork hygiene
|
||||
|
||||
**Target: zero fork patches for N.4.** WB's `acdream` branch stays at
|
||||
upstream `master` plus the editor-only file deletions inherited from
|
||||
N.0/N.1. If a fork patch becomes genuinely necessary mid-implementation
|
||||
(e.g., a public hook is missing for our customization layer), it lands
|
||||
as a single named patch with a comment explaining the rationale. Each
|
||||
patch is candidate to upstream back to Chorizite/WorldBuilder.
|
||||
|
||||
## Components
|
||||
|
||||
### New code (acdream-side)
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | Bridges acdream's lifecycle events to `ObjectMeshManager`. Holds the `ObjectMeshManager` instance, exposes `IncrementRefCount` / `DecrementRefCount` / `GetRenderData` to the rest of the renderer. |
|
||||
| `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` | Streaming-loader hook. Walks `LandblockEntry.Setups[]` + `Statics[]`, calls `WbMeshAdapter` with unique ids. Companion `LandblockUnloadAdapter` for unload events. |
|
||||
| `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs` | Network-spawn hook. Routes `CreateObject` to per-instance path, `RemoveObject` to release. |
|
||||
| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs` | Side-table type holding `Translucency` / `Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` / `DisableFog`. |
|
||||
| `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs` | The `Dictionary<batchKey, AcSurfaceMetadata>` side-table, populated at mesh-extraction time, queried at draw time. |
|
||||
| `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs` | Per-entity render state for animated entities: `partGfxObjOverrides` map (AnimPartChange), `hiddenMask` (HiddenParts), reference to `AnimationSequencer` for per-frame override matrices. |
|
||||
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | Per-frame draw loop. For each visible entity, looks up `ObjectRenderData`, composes per-part matrices (entity × animation × rest-pose), reads side-table metadata, issues GL draw. |
|
||||
|
||||
### Modified code (acdream-side)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/AcDream.App/Rendering/StaticMeshRenderer.cs` | Replace internal mesh-data + GL-resource handling with calls into `WbMeshAdapter`. Public surface preserved for the rest of the renderer's call sites. **N.6 will fully replace this file**; N.4 leaves it in place as a thin adapter. |
|
||||
| `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` | Same pattern — internal swap, public surface preserved. **N.6 fully replaces this file.** |
|
||||
| `src/AcDream.App/Rendering/TextureCache.cs` | Per-instance path stays. Atlas-tier callers (anything using `GetOrUpload(surfaceId)` for static content) route through `WbMeshAdapter` instead. The override paths (`GetOrUploadWithOrigTextureOverride`, `GetOrUploadWithPaletteOverride`) keep their current behavior. |
|
||||
| `src/AcDream.App/Rendering/GpuWorldState.cs` | Spawn/despawn callbacks route through `WbMeshAdapter`. Pending-spawn list mechanism preserved verbatim. |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | Construct `WbMeshAdapter` on init; dispose on shutdown. |
|
||||
| `src/AcDream.Core/Meshing/SetupMesh.cs` | Kept for tests + as the conformance-test reference implementation. Production callers route through `WbMeshAdapter`. |
|
||||
| `src/AcDream.Core/Meshing/GfxObjMesh.cs` | Kept for tests + conformance reference. Production callers route through `WbMeshAdapter`. |
|
||||
|
||||
## Data flow
|
||||
|
||||
### Spawn — landblock-streamed (atlas tier)
|
||||
|
||||
```
|
||||
LandblockStreamLoader.Load(landblockId)
|
||||
→ LandblockEntry { Setups, Statics, ... }
|
||||
→ LandblockSpawnAdapter.OnLoaded(entry)
|
||||
for each unique gfxObjId in (entry.Setups ∪ entry.Statics):
|
||||
WbMeshAdapter.IncrementRefCount(gfxObjId)
|
||||
→ ObjectMeshManager.IncrementRefCount(gfxObjId)
|
||||
→ if not cached: queue background prep
|
||||
→ on prep complete: queue main-thread upload
|
||||
→ on upload: GL VAO/VBO/IBO ready
|
||||
```
|
||||
|
||||
### Spawn — network-customized (per-instance tier)
|
||||
|
||||
```
|
||||
WorldSession.OnCreateObject(msg)
|
||||
→ EntitySpawnAdapter.OnCreate(entity)
|
||||
→ build PaletteOverride from msg.SubPalettes
|
||||
→ for each surface needing per-instance decode:
|
||||
TextureCache.GetOrUploadWithPaletteOverride(...)
|
||||
→ register AnimatedEntityState (override map, hidden mask,
|
||||
animation sequencer reference)
|
||||
```
|
||||
|
||||
### Per-frame draw (atlas tier)
|
||||
|
||||
```
|
||||
WbDrawDispatcher.Draw()
|
||||
for each visible atlas-tier entity:
|
||||
var renderData = WbMeshAdapter.GetRenderData(entity.GfxObjId)
|
||||
foreach (batch in renderData.Batches):
|
||||
bind atlas, bind shader, push uniforms
|
||||
foreach (part in renderData.SetupParts):
|
||||
push final_part_world_matrix uniform
|
||||
glDrawElements(part.indices)
|
||||
```
|
||||
|
||||
### Per-frame draw (per-instance tier, animated)
|
||||
|
||||
```
|
||||
WbDrawDispatcher.DrawAnimated()
|
||||
for each visible animated entity:
|
||||
var state = entity.AnimatedEntityState
|
||||
var sequencer = entity.AnimationSequencer
|
||||
sequencer.AdvanceTo(currentTime) // existing
|
||||
var animOverrides = sequencer.GetCurrentPartTransforms() // existing
|
||||
|
||||
foreach (partIdx in 0..parts.Count):
|
||||
if (state.hiddenMask & (1 << partIdx)) continue;
|
||||
var gfxObjId = state.partGfxObjOverrides.GetValueOrDefault(partIdx) ?? defaultParts[partIdx]
|
||||
var renderData = WbMeshAdapter.GetRenderData(gfxObjId)
|
||||
var meta = AcSurfaceMetadataTable.Lookup(renderData.BatchKey)
|
||||
var worldMatrix = entityWorld × animOverrides[partIdx] × renderData.RestPose
|
||||
bind per-instance texture (TextureCache lookup)
|
||||
push uniforms (worldMatrix, meta.Luminosity, meta.Diffuse, ...)
|
||||
glDrawElements(...)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Algorithmic conformance (before substitution)
|
||||
|
||||
Per the N.1 / N.3 pattern, conformance tests run BEFORE the substitution
|
||||
to prove equivalence:
|
||||
|
||||
| Test | Compares |
|
||||
|---|---|
|
||||
| `MeshExtraction_OurBuildVsWbBuildPolygonIndices` | Battery of fixture GfxObjs (varying polygon counts, stippling flags, NegUVIndices, double-sided polys). For each: our `GfxObjMesh.Build` output vs WB's `ObjectMeshManager` output (extracted via test harness). Assert: identical vertex arrays, identical index arrays, identical per-bucket surface mapping. |
|
||||
| `SetupFlattening_OurFlattenVsWbSetupParts` | Battery of representative Setups (flat / hierarchical / Resting-frame / Default-frame / no-frame). For each: our `SetupMesh.Flatten` output vs WB's Setup-parts walk. Assert: identical (GfxObjId, Matrix4x4) sequences. |
|
||||
| `PerInstanceDecode_OldVsNewPath` | Synthetic palette + texture overrides (mirroring real `CreateObject` data). Decoded through new integrated path vs current `TextureCache.GetOrUploadWithPaletteOverride`. Assert: identical RGBA8. |
|
||||
|
||||
If any test fails it's a real divergence — investigate, do not "fix"
|
||||
the test (per N.3 watchout).
|
||||
|
||||
### Component micro-tests
|
||||
|
||||
| Test | Covers |
|
||||
|---|---|
|
||||
| `LandblockSpawnAdapter_RegistersAndUnregisters` | Mock `ObjectMeshManager`; verify ref-count increments/decrements pair correctly across landblock load/unload events. |
|
||||
| `LandblockSpawnAdapter_DedupesSharedIds` | Same GfxObj id appearing in multiple landblocks: verify single ref-count per landblock, not per occurrence. |
|
||||
| `EntitySpawnAdapter_RoutesToPerInstance` | `CreateObject` with `SubPalettes` set: verify per-instance path taken, atlas tier not invoked. |
|
||||
| `AnimPartChange_OverridesAtDraw` | Per-instance override map: verify draw loop resolves correct part GfxObj id when override present, falls back to Setup default when absent. |
|
||||
| `HiddenParts_SuppressesDraw` | Bitmask: verify draw loop skips hidden parts. |
|
||||
| `MatrixComposition_EntityAnimRest` | Known entity transform + animation matrix + rest pose: verify final world matrix matches expected composition order (column-major: rest applied first, then animation, then entity world). |
|
||||
| `SurfaceMetadata_SideTableLookup` | Populate side-table during mesh extraction; query at draw time; verify Luminosity / Diffuse / DisableFog round-trip correctly. |
|
||||
|
||||
### Visual verification (per phase, before flipping `Live ✓`)
|
||||
|
||||
Walk the following with the user, comparing against pre-N.4 screenshots
|
||||
or video:
|
||||
|
||||
1. **Holtburg outdoor** — terrain props, scenery, buildings, NPCs,
|
||||
characters. Verify: no missing entities, no magenta squares, no
|
||||
alpha bleeding, no shading regressions, no animation hitches.
|
||||
2. **Drudge Hideout** (or comparable starter dungeon) — EnvCell
|
||||
geometry, interior lighting, animated creatures.
|
||||
3. **Foundry** — heavy NPC traffic, customized appearances (the
|
||||
server's first-time test bed for per-instance customization
|
||||
correctness).
|
||||
4. **A character with extreme palette overrides** — char-creation
|
||||
variant if available, otherwise a known-customized server-side
|
||||
test character.
|
||||
5. **Long roam** — walk for ~5 minutes across multiple landblocks,
|
||||
monitor GPU memory in title bar (memory budget enforcement working
|
||||
means it stabilizes; memory growing unboundedly means LRU eviction
|
||||
isn't firing).
|
||||
|
||||
## Phasing
|
||||
|
||||
Single shippable phase — no internal sub-phases. Within the phase, work
|
||||
ordered to minimize the duration of "broken in middle" state:
|
||||
|
||||
| Week | Focus | "Done when" |
|
||||
|---|---|---|
|
||||
| 1 | WB integration plumbing + atlas bring-up for static scenery only (smallest tier, highest sharing factor) + algorithmic conformance tests pass | Conformance tests green; static scenery renders through `ObjectMeshManager` while everything else uses old path |
|
||||
| 2 | Streaming-loader adapter; LRU + memory budget verified under streaming pressure (long roam + radius 7×7) | Long roam holds steady GPU memory; landblock unload reclaims memory |
|
||||
| 3 | Per-instance customization path; animated creatures with palette overrides; AnimPartChange + HiddenParts | Drudge / chicken / banderling render with correct customizations; animation matches today |
|
||||
| 4 | Surface metadata side-table integration; sky-pass preservation; visual verification at named locations; polish | Visual verification at all 5 locations passes; sky pass renders identically; ready for `Live ✓` |
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Per-instance customization scope creep.** If we discover a
|
||||
customization path we don't already handle in `TextureCache` (e.g.,
|
||||
a rare `GfxObjRemapping` case), the per-instance path may need
|
||||
extension. Mitigation: enumerate all customization paths during
|
||||
week 3, add tests for each before integrating.
|
||||
|
||||
2. **WB threading model interaction with our streaming worker.**
|
||||
`ObjectMeshManager` uses `ConcurrentDictionary` and is designed for
|
||||
concurrent `IncrementRefCount` calls, but its `_pendingRequests` queue
|
||||
is guarded by a `lock`. Heavy concurrent landblock loads could serialize
|
||||
on this lock. Mitigation: profile during week 2; if contention is
|
||||
visible, batch landblock loads to amortize the lock.
|
||||
|
||||
3. **Sky pass regression.** The sky pass's `NeedsUvRepeat` /
|
||||
`DisableFog` / `Luminosity` flow is fragile and load-bearing. The
|
||||
side-table preserves the data, but the integration point with
|
||||
`SkyRenderer` needs careful review. Mitigation: sky-pass-specific
|
||||
visual verification before flipping `Live ✓`.
|
||||
|
||||
4. **Bindless rendering path mismatch.** WB enables bindless when
|
||||
`GL 4.3 + GL_ARB_bindless_texture` are present. If we ship through
|
||||
the bindless path and a player has older hardware, fallback path
|
||||
must work. Mitigation: dev/test with `_useModernRendering = false`
|
||||
forced during week 1 to ensure the non-bindless path is also exercised.
|
||||
|
||||
5. **Performance regression** during integration of week 1's "atlas for
|
||||
static scenery, old path for everything else" mixed state. Mitigation:
|
||||
keep the feature gate `ACDREAM_USE_WB_FOUNDATION=1` during weeks 1-3;
|
||||
default-off until week 4 visual verification.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replacing `StaticMeshRenderer` / `InstancedMeshRenderer` — those become
|
||||
thin adapters in N.4 and are fully replaced in **N.6**.
|
||||
- Replacing `TerrainAtlas` / `TerrainBlending` — that's **N.5**.
|
||||
- Replacing EnvCell rendering — that's **N.7**.
|
||||
- Replacing sky / particle rendering — that's **N.8**.
|
||||
- Replacing visibility / culling — that's **N.9**.
|
||||
- Per-instance customization beyond what's in today's `TextureCache`
|
||||
(e.g., novel customization opcodes from future Phase F work) — out of
|
||||
scope; future opcodes route through the same per-instance path.
|
||||
|
||||
## Documentation impact
|
||||
|
||||
- [x] [Roadmap](../../plans/2026-04-11-roadmap.md) — N.4 entry rebranded
|
||||
and N.5/N.6/N.7/N.8/N.9/N.10 estimates revised (committed `6d42744`
|
||||
and merged to main).
|
||||
- [ ] This spec — written 2026-05-08, committing alongside.
|
||||
- [ ] [worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md)
|
||||
— minor update at end of N.4 to mark `ObjectMeshManager` /
|
||||
`TextureAtlasManager` as "now wired up" rather than just "should
|
||||
use." Not blocking N.4 start.
|
||||
- [ ] [acdream-architecture.md](../../architecture/acdream-architecture.md)
|
||||
— needs an acknowledging note after N.4 lands that the rendering
|
||||
pipeline is WB-backed. Can follow in a later commit.
|
||||
|
||||
## Reference materials
|
||||
|
||||
- WB `ObjectMeshManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`
|
||||
- WB `TextureAtlasManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureAtlasManager.cs`
|
||||
- WB `BaseObjectRenderManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/BaseObjectRenderManager.cs`
|
||||
- ACME secondary oracle for character appearance (CreaturePalette
|
||||
/ GfxObjRemapping / HiddenParts behavior):
|
||||
`references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/StaticObjectManager.cs`
|
||||
- Existing acdream code:
|
||||
- [SetupMesh.cs](../../../src/AcDream.Core/Meshing/SetupMesh.cs)
|
||||
- [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs)
|
||||
- [TextureCache.cs](../../../src/AcDream.App/Rendering/TextureCache.cs)
|
||||
- [PaletteOverride.cs](../../../src/AcDream.Core/World/PaletteOverride.cs)
|
||||
- [AnimationSequencer.cs](../../../src/AcDream.Core/Animation/AnimationSequencer.cs)
|
||||
|
|
@ -9,6 +9,9 @@
|
|||
<RootNamespace>AcDream.App</RootNamespace>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AcDream.Core.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||
|
|
@ -26,6 +29,12 @@
|
|||
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
|
||||
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
|
||||
<!-- Phase N.4 Task 9: WbMeshAdapter constructs the WB GL pipeline directly.
|
||||
AcDream.Core already references these projects, but project references are
|
||||
not transitive in .NET — AcDream.App must list them explicitly to compile
|
||||
against Chorizite.OpenGLSDLBackend and WorldBuilder.Shared types. -->
|
||||
<ProjectReference Include="..\..\references\WorldBuilder\WorldBuilder.Shared\WorldBuilder.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\references\WorldBuilder\Chorizite.OpenGLSDLBackend\Chorizite.OpenGLSDLBackend.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Rendering\Shaders\*.*">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ public sealed class GameWindow : IDisposable
|
|||
private InstancedMeshRenderer? _staticMesh;
|
||||
private Shader? _meshShader;
|
||||
private TextureCache? _textureCache;
|
||||
/// <summary>Phase N.4: WB-backed rendering pipeline adapter. Non-null only
|
||||
/// when <c>ACDREAM_USE_WB_FOUNDATION=1</c> is set; null otherwise.</summary>
|
||||
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
|
||||
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
||||
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
||||
private SamplerCache? _samplerCache;
|
||||
private DebugLineRenderer? _debugLines;
|
||||
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
|
||||
|
|
@ -62,7 +67,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Phase A.1: streaming fields replacing the one-shot _entities list.
|
||||
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
||||
private readonly AcDream.App.Streaming.GpuWorldState _worldState = new();
|
||||
private AcDream.App.Streaming.GpuWorldState _worldState = new();
|
||||
private AcDream.App.Streaming.StreamingController? _streamingController;
|
||||
private int _streamingRadius = 2; // default 5×5
|
||||
private uint? _lastLivePlayerLandblockId;
|
||||
|
|
@ -1421,7 +1426,81 @@ public sealed class GameWindow : IDisposable
|
|||
// WorldBuilder reference at
|
||||
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
|
||||
_samplerCache = new SamplerCache(_gl);
|
||||
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache);
|
||||
|
||||
// Phase N.4 — WB rendering pipeline foundation. Constructed only when
|
||||
// ACDREAM_USE_WB_FOUNDATION=1 is set; otherwise the legacy renderer
|
||||
// path stays in charge. The full ObjectMeshManager bring-up lives in
|
||||
// WbMeshAdapter (Task 9): OpenGLGraphicsDevice + DefaultDatReaderWriter
|
||||
// + ObjectMeshManager. WbMeshAdapter opens its own file handles for
|
||||
// the dat files (independent of our DatCollection).
|
||||
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
|
||||
{
|
||||
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
|
||||
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _dats, wbLogger);
|
||||
Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager.");
|
||||
}
|
||||
|
||||
// Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag
|
||||
// and rebuild _worldState so it threads the adapter in. _worldState starts
|
||||
// as an unadorned GpuWorldState (field initializer); here we replace it with
|
||||
// one that carries the adapter so AddLandblock/RemoveLandblock notify WB.
|
||||
// Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned
|
||||
// per-instance content under the same flag.
|
||||
{
|
||||
AcDream.App.Rendering.Wb.LandblockSpawnAdapter? wbSpawnAdapter = null;
|
||||
AcDream.App.Rendering.Wb.EntitySpawnAdapter? wbEntitySpawnAdapter = null;
|
||||
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
|
||||
{
|
||||
wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter);
|
||||
// Sequencer factory: look up Setup + MotionTable from dats and build
|
||||
// an AnimationSequencer. Falls back to a no-op sequencer when the
|
||||
// entity has no motion table (static props, etc.). Uses _animLoader
|
||||
// which is initialised at line 1004; it is non-null here because
|
||||
// OnLoad wires _dats + _animLoader before this block runs.
|
||||
var capturedDats = _dats;
|
||||
var capturedAnimLoader = _animLoader;
|
||||
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
|
||||
{
|
||||
if (capturedDats is not null && capturedAnimLoader is not null)
|
||||
{
|
||||
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||||
if (setup is not null)
|
||||
{
|
||||
uint mtableId = (uint)setup.DefaultMotionTable;
|
||||
if (mtableId != 0)
|
||||
{
|
||||
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||||
if (mtable is not null)
|
||||
return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader);
|
||||
}
|
||||
// Setup exists but no motion table — no-op sequencer.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
setup,
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
capturedAnimLoader);
|
||||
}
|
||||
}
|
||||
// Complete fallback: empty setup + empty motion table + null loader.
|
||||
return new AcDream.Core.Physics.AnimationSequencer(
|
||||
new DatReaderWriter.DBObjs.Setup(),
|
||||
new DatReaderWriter.DBObjs.MotionTable(),
|
||||
new NullAnimLoader());
|
||||
}
|
||||
wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
|
||||
_textureCache, SequencerFactory, _wbMeshAdapter);
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
}
|
||||
_worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter);
|
||||
}
|
||||
|
||||
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
|
||||
|
||||
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled
|
||||
&& _wbMeshAdapter is not null && _wbEntitySpawnAdapter is not null)
|
||||
{
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
_gl, _meshShader, _textureCache, _wbMeshAdapter, _wbEntitySpawnAdapter);
|
||||
}
|
||||
|
||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||
// with depth writes off + far plane 1e6 so celestial meshes
|
||||
|
|
@ -2338,6 +2417,19 @@ public sealed class GameWindow : IDisposable
|
|||
SubPalettes: ranges);
|
||||
}
|
||||
|
||||
AcDream.Core.World.PartOverride[] entityPartOverrides;
|
||||
if (animPartChanges.Count == 0)
|
||||
{
|
||||
entityPartOverrides = Array.Empty<AcDream.Core.World.PartOverride>();
|
||||
}
|
||||
else
|
||||
{
|
||||
entityPartOverrides = new AcDream.Core.World.PartOverride[animPartChanges.Count];
|
||||
for (int i = 0; i < animPartChanges.Count; i++)
|
||||
entityPartOverrides[i] = new AcDream.Core.World.PartOverride(
|
||||
animPartChanges[i].PartIndex, animPartChanges[i].NewModelId);
|
||||
}
|
||||
|
||||
var entity = new AcDream.Core.World.WorldEntity
|
||||
{
|
||||
Id = _liveEntityIdCounter++,
|
||||
|
|
@ -2347,6 +2439,7 @@ public sealed class GameWindow : IDisposable
|
|||
Rotation = rot,
|
||||
MeshRefs = meshRefs,
|
||||
PaletteOverride = paletteOverride,
|
||||
PartOverrides = entityPartOverrides,
|
||||
};
|
||||
|
||||
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||||
|
|
@ -6048,6 +6141,12 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
|
||||
// Phase N.4: drain WB pipeline queues (staged mesh data +
|
||||
// GL thread queue). Must happen before any draw work so that
|
||||
// resources uploaded this frame are available immediately.
|
||||
// No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null).
|
||||
_wbMeshAdapter?.Tick();
|
||||
|
||||
// Phase D.2a — begin ImGui frame. Paired with the Render() call
|
||||
// after the scene draws (below). ImGuiController.Update()
|
||||
// consumes buffered Silk.NET input events and calls ImGui.NewFrame.
|
||||
|
|
@ -6237,10 +6336,20 @@ public sealed class GameWindow : IDisposable
|
|||
animatedIds.Add(k);
|
||||
}
|
||||
|
||||
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
if (_wbDrawDispatcher is not null)
|
||||
{
|
||||
_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: visibility?.VisibleCellIds,
|
||||
animatedEntityIds: animatedIds);
|
||||
}
|
||||
|
||||
// Phase G.1 / E.3: draw all live particles after opaque
|
||||
// scene geometry so alpha blending composites correctly.
|
||||
|
|
@ -8621,10 +8730,13 @@ public sealed class GameWindow : IDisposable
|
|||
_combatChatTranslator?.Dispose();
|
||||
_liveSession?.Dispose();
|
||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||
_wbDrawDispatcher?.Dispose();
|
||||
_staticMesh?.Dispose();
|
||||
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
||||
_samplerCache?.Dispose();
|
||||
_textureCache?.Dispose();
|
||||
_wbMeshAdapter?.Dispose(); // Phase N.4 WB foundation — null when flag off
|
||||
|
||||
_meshShader?.Dispose();
|
||||
_terrain?.Dispose();
|
||||
_shader?.Dispose();
|
||||
|
|
@ -8709,4 +8821,16 @@ public sealed class GameWindow : IDisposable
|
|||
_ => $"Room 0x{roomId:X8}",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
|
||||
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
|
||||
/// factory when neither <c>_dats</c> nor the entity's setup is available.
|
||||
/// Returns null for all animation lookups so the sequencer silently has
|
||||
/// no data (same behaviour as a new empty Setup).
|
||||
/// </summary>
|
||||
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
|
||||
{
|
||||
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
// needs to update the shader and uniform setup at the call sites.
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
|
|
@ -33,6 +34,17 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
private readonly Shader _shader;
|
||||
private readonly TextureCache _textures;
|
||||
|
||||
/// <summary>
|
||||
/// Optional WB adapter. Held but currently unused — Phase N.4 Adjustment 2
|
||||
/// (2026-05-08) reverted Task 9's renderer-level routing. Tier-routing decisions
|
||||
/// (atlas vs per-instance) belong at the spawn-callback layer (Task 11
|
||||
/// LandblockSpawnAdapter for atlas-tier; Task 17 EntitySpawnAdapter for
|
||||
/// per-instance), not in the renderer which is intentionally tier-blind. The
|
||||
/// constructor parameter is preserved so GameWindow's wire-up doesn't shift
|
||||
/// when later tasks need adapter access.
|
||||
/// </summary>
|
||||
private readonly WbMeshAdapter? _wbMeshAdapter;
|
||||
|
||||
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
|
||||
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
|
||||
|
||||
|
|
@ -67,11 +79,13 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
|
||||
private readonly record struct GroupKey(uint GfxObjId, ulong TextureSignature);
|
||||
|
||||
public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures)
|
||||
public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures,
|
||||
WbMeshAdapter? wbMeshAdapter = null)
|
||||
{
|
||||
_gl = gl;
|
||||
_shader = shader;
|
||||
_textures = textures;
|
||||
_wbMeshAdapter = wbMeshAdapter;
|
||||
|
||||
_instanceVbo = _gl.GenBuffer();
|
||||
}
|
||||
|
|
@ -83,6 +97,11 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
if (_gpuByGfxObj.ContainsKey(gfxObjId))
|
||||
return;
|
||||
|
||||
// Phase N.4 Adjustment 2 (2026-05-08): renderer is tier-blind. Tier-routing
|
||||
// (atlas vs per-instance) lives at the spawn-callback layer (Tasks 11 + 17),
|
||||
// not here. Smoke-test of the original Task 9 routing showed it caught
|
||||
// characters / NPCs (server-spawned, per-instance tier) along with static
|
||||
// scenery, because EnsureUploaded is called from both spawn paths.
|
||||
var list = new List<SubMeshGpu>(subMeshes.Count);
|
||||
foreach (var sm in subMeshes)
|
||||
list.Add(UploadSubMesh(sm));
|
||||
|
|
@ -419,7 +438,7 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
if (!_gpuByGfxObj.ContainsKey(meshRef.GfxObjId))
|
||||
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var cachedMeshes))
|
||||
continue;
|
||||
|
||||
var model = meshRef.PartTransform * entityRoot;
|
||||
|
|
|
|||
|
|
@ -316,10 +316,10 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
return false;
|
||||
|
||||
// Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha
|
||||
// format) or the more generic PFID_A8; SurfaceDecoder routes both
|
||||
// through the same "replicate single byte to RGBA" path. Palette is
|
||||
// not used.
|
||||
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||||
// format) or the more generic PFID_A8; terrain blending alpha masks
|
||||
// MUST use isAdditive=true so R=G=B=A=val — the terrain fragment shader
|
||||
// reads .r for the blend weight. Palette is not used.
|
||||
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
|
||||
if (ReferenceEquals(d, DecodedTexture.Magenta))
|
||||
return false;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ using SurfaceType = DatReaderWriter.Enums.SurfaceType;
|
|||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public sealed unsafe class TextureCache : IDisposable
|
||||
public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly DatCollection _dats;
|
||||
|
|
@ -123,10 +123,23 @@ public sealed unsafe class TextureCache : IDisposable
|
|||
uint surfaceId,
|
||||
uint? overrideOrigTextureId,
|
||||
PaletteOverride paletteOverride)
|
||||
=> GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride,
|
||||
HashPaletteOverride(paletteOverride));
|
||||
|
||||
/// <summary>
|
||||
/// Overload that accepts a precomputed palette hash. Lets callers (e.g.
|
||||
/// the WB draw dispatcher) compute the hash ONCE per entity and reuse
|
||||
/// it across every (part, batch) lookup, avoiding the per-batch
|
||||
/// FNV-1a fold over <see cref="PaletteOverride.SubPalettes"/>.
|
||||
/// </summary>
|
||||
public uint GetOrUploadWithPaletteOverride(
|
||||
uint surfaceId,
|
||||
uint? overrideOrigTextureId,
|
||||
PaletteOverride paletteOverride,
|
||||
ulong precomputedPaletteHash)
|
||||
{
|
||||
ulong hash = HashPaletteOverride(paletteOverride);
|
||||
uint origTexKey = overrideOrigTextureId ?? 0;
|
||||
var key = (surfaceId, origTexKey, hash);
|
||||
var key = (surfaceId, origTexKey, precomputedPaletteHash);
|
||||
if (_handlesByPalette.TryGetValue(key, out var h))
|
||||
return h;
|
||||
|
||||
|
|
@ -138,9 +151,10 @@ public sealed unsafe class TextureCache : IDisposable
|
|||
|
||||
/// <summary>
|
||||
/// Cheap 64-bit hash over a palette override's identity so two
|
||||
/// entities with the same palette setup share a decode.
|
||||
/// entities with the same palette setup share a decode. Internal so
|
||||
/// the WB dispatcher can compute it once per entity.
|
||||
/// </summary>
|
||||
private static ulong HashPaletteOverride(PaletteOverride p)
|
||||
internal static ulong HashPaletteOverride(PaletteOverride p)
|
||||
{
|
||||
// Not cryptographic — just needs to distinguish override setups
|
||||
// for caching. Start with base palette id, fold in each entry.
|
||||
|
|
@ -199,8 +213,9 @@ public sealed unsafe class TextureCache : IDisposable
|
|||
|
||||
// Clipmap surfaces use palette indices 0..7 as transparent sentinels.
|
||||
bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap);
|
||||
bool isAdditive = surface.Type.HasFlag(SurfaceType.Additive);
|
||||
|
||||
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap);
|
||||
return SurfaceDecoder.DecodeRenderSurface(rs, effectivePalette, isClipMap, isAdditive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
21
src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs
Normal file
21
src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using AcDream.Core.Meshing;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// AC-specific surface render metadata that WB's <c>MeshBatchData</c>
|
||||
/// doesn't carry. Computed at mesh-extraction time and looked up by the
|
||||
/// draw dispatcher to drive translucency / sky-pass / fog behavior.
|
||||
///
|
||||
/// <para>
|
||||
/// All fields mirror those on today's <see cref="GfxObjSubMesh"/> so
|
||||
/// behavior is preserved bit-for-bit through the migration.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record AcSurfaceMetadata(
|
||||
TranslucencyKind Translucency,
|
||||
float Luminosity,
|
||||
float Diffuse,
|
||||
float SurfOpacity,
|
||||
bool NeedsUvRepeat,
|
||||
bool DisableFog);
|
||||
27
src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs
Normal file
27
src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe side-table mapping <c>(gfxObjId, surfaceIdx)</c> to
|
||||
/// <see cref="AcSurfaceMetadata"/>. Populated when a GfxObj's mesh data
|
||||
/// is extracted; queried at draw time.
|
||||
///
|
||||
/// <para>
|
||||
/// Keyed by <c>(gfxObjId, surfaceIdx)</c> not by WB's runtime batch
|
||||
/// identity because batch objects can be evicted and re-loaded by WB's
|
||||
/// LRU; the (gfxObj, surface) pair is stable across cycles.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AcSurfaceMetadataTable
|
||||
{
|
||||
private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();
|
||||
|
||||
public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
|
||||
=> _table[(gfxObjId, surfaceIdx)] = meta;
|
||||
|
||||
public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
|
||||
=> _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
|
||||
|
||||
public void Clear() => _table.Clear();
|
||||
}
|
||||
67
src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs
Normal file
67
src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Per-entity render state for animated entities (characters, creatures,
|
||||
/// equipped items). Holds AC-specific per-instance customizations the WB
|
||||
/// atlas cache doesn't carry: <c>AnimPartChange</c> override map +
|
||||
/// <c>HiddenParts</c> bitmask. Also holds a reference to acdream's existing
|
||||
/// <see cref="AnimationSequencer"/> — Phase N.4 explicitly does not touch
|
||||
/// the sequencer; we just route through it at draw time.
|
||||
///
|
||||
/// <para>
|
||||
/// Lifecycle: created by <c>EntitySpawnAdapter.OnCreate</c> (Task 17) when
|
||||
/// a server <c>CreateObject</c> is processed; destroyed by
|
||||
/// <c>EntitySpawnAdapter.OnRemove</c> on <c>RemoveObject</c>. The mesh
|
||||
/// data backing each part is cached in WB's <c>ObjectMeshManager</c>;
|
||||
/// per-instance customizations don't go through the atlas — they overlay
|
||||
/// at draw time.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AnimatedEntityState
|
||||
{
|
||||
private readonly Dictionary<int, ulong> _partGfxObjOverrides = new();
|
||||
private ulong _hiddenMask = 0;
|
||||
|
||||
/// <summary>Reference to acdream's existing animation sequencer.
|
||||
/// Phase N.4 doesn't touch the sequencer; the draw dispatcher consumes
|
||||
/// per-part transforms it produces per frame.</summary>
|
||||
public AnimationSequencer Sequencer { get; }
|
||||
|
||||
public AnimatedEntityState(AnimationSequencer sequencer)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(sequencer);
|
||||
Sequencer = sequencer;
|
||||
}
|
||||
|
||||
/// <summary>Set the <c>HiddenParts</c> bitmask for this entity. Bit
|
||||
/// <c>i</c> set hides part <c>i</c> at draw time.</summary>
|
||||
public void HideParts(ulong hiddenMask) => _hiddenMask = hiddenMask;
|
||||
|
||||
/// <summary>True if part <c>partIdx</c> should be skipped at draw
|
||||
/// time. Returns false for part indices outside [0, 63].</summary>
|
||||
public bool IsPartHidden(int partIdx)
|
||||
{
|
||||
if (partIdx < 0 || partIdx >= 64) return false;
|
||||
return (_hiddenMask & (1ul << partIdx)) != 0;
|
||||
}
|
||||
|
||||
/// <summary>Override the GfxObj id for a Setup part. Used for
|
||||
/// AnimPartChange — e.g. wielding a weapon swaps the hand-part's
|
||||
/// GfxObj.</summary>
|
||||
public void SetPartOverride(int partIdx, ulong gfxObjId)
|
||||
=> _partGfxObjOverrides[partIdx] = gfxObjId;
|
||||
|
||||
/// <summary>Look up the GfxObj override for a part. Returns false if
|
||||
/// no override is set (caller should fall back to Setup default).</summary>
|
||||
public bool TryGetPartOverride(int partIdx, out ulong gfxObjId)
|
||||
=> _partGfxObjOverrides.TryGetValue(partIdx, out gfxObjId);
|
||||
|
||||
/// <summary>Resolve the GfxObj id for <paramref name="partIdx"/>:
|
||||
/// override if set, else <paramref name="setupDefault"/>. Used by the
|
||||
/// draw dispatcher to pick the right cached mesh data per part.</summary>
|
||||
public ulong ResolvePartGfxObj(int partIdx, ulong setupDefault)
|
||||
=> TryGetPartOverride(partIdx, out var ov) ? ov : setupDefault;
|
||||
}
|
||||
188
src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs
Normal file
188
src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Routes server-spawned (<c>CreateObject</c>) entities through the
|
||||
/// per-instance rendering path. Server entities always carry per-instance
|
||||
/// customizations (palette overrides, texture changes, part swaps) that
|
||||
/// don't fit WB's atlas key, so they bypass the atlas and use the existing
|
||||
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
|
||||
/// path which already hash-keys overrides for caching.
|
||||
///
|
||||
/// <para>
|
||||
/// Companion to <see cref="LandblockSpawnAdapter"/>: that adapter handles
|
||||
/// atlas-tier (procedural) entities; this one handles per-instance-tier
|
||||
/// (server-spawned). The boundary is <c>ServerGuid != 0</c> on
|
||||
/// <see cref="WorldEntity"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Per-entity texture decode</b>: when <c>entity.PaletteOverride</c> is
|
||||
/// non-null, the adapter calls
|
||||
/// <see cref="ITextureCachePerInstance.GetOrUploadWithPaletteOverride"/>
|
||||
/// once per surface id that is known at spawn time (those on
|
||||
/// <see cref="MeshRef.SurfaceOverrides"/>). Surfaces whose ids are only
|
||||
/// discoverable by opening the GfxObj dat are decoded lazily by the draw
|
||||
/// dispatcher (Task 22) on first use — that matches the existing
|
||||
/// <c>StaticMeshRenderer</c> behavior.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Sequencer factory</b>: the adapter is constructed with a
|
||||
/// <c>Func<WorldEntity, AnimationSequencer></c> factory so tests can
|
||||
/// inject a stub without needing a live DatCollection or MotionTable.
|
||||
/// Production callers supply a factory that fetches MotionTable from dats.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Adjustment 6</b> (resolved Adjustment 4): <see cref="WorldEntity"/> now
|
||||
/// carries <see cref="WorldEntity.PartOverrides"/> and
|
||||
/// <see cref="WorldEntity.HiddenPartsMask"/>. <see cref="OnCreate"/> applies
|
||||
/// both to the created <see cref="AnimatedEntityState"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EntitySpawnAdapter
|
||||
{
|
||||
private readonly ITextureCachePerInstance _textureCache;
|
||||
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
|
||||
private readonly IWbMeshAdapter? _meshAdapter;
|
||||
|
||||
// Per-server-guid state. Written on OnCreate, released on OnRemove.
|
||||
// Single-threaded: called only from the render thread (same as GpuWorldState).
|
||||
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
|
||||
|
||||
// Per-server-guid set of GfxObj ids registered with the mesh adapter,
|
||||
// so OnRemove can decrement each. Per-instance entities don't go through
|
||||
// LandblockSpawnAdapter, so without this their meshes would never load
|
||||
// (WB doesn't know they exist).
|
||||
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new();
|
||||
|
||||
/// <param name="textureCache">
|
||||
/// Per-instance texture decode path. In production this is the
|
||||
/// <see cref="TextureCache"/> instance (which implements
|
||||
/// <see cref="ITextureCachePerInstance"/>); in tests it is a capturing mock.
|
||||
/// </param>
|
||||
/// <param name="sequencerFactory">
|
||||
/// Factory that builds an <see cref="AnimationSequencer"/> for a given
|
||||
/// entity. Receives the full <see cref="WorldEntity"/> so it can look up
|
||||
/// the Setup + MotionTable from the entity's <c>SourceGfxObjOrSetupId</c>
|
||||
/// and server-supplied motion table override. Tests pass a lambda that
|
||||
/// returns a stub sequencer.
|
||||
/// </param>
|
||||
/// <param name="meshAdapter">
|
||||
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
|
||||
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
|
||||
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
|
||||
/// matching ref counts. When null, the adapter only tracks per-instance
|
||||
/// state without driving WB lifecycle (test mode + flag-off mode).
|
||||
/// </param>
|
||||
public EntitySpawnAdapter(
|
||||
ITextureCachePerInstance textureCache,
|
||||
Func<WorldEntity, AnimationSequencer> sequencerFactory,
|
||||
IWbMeshAdapter? meshAdapter = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(textureCache);
|
||||
ArgumentNullException.ThrowIfNull(sequencerFactory);
|
||||
_textureCache = textureCache;
|
||||
_sequencerFactory = sequencerFactory;
|
||||
_meshAdapter = meshAdapter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process a server-spawned entity. Returns the created
|
||||
/// <see cref="AnimatedEntityState"/> for the entity, or <c>null</c> if
|
||||
/// <paramref name="entity"/> is atlas-tier (<c>ServerGuid == 0</c>).
|
||||
/// </summary>
|
||||
public AnimatedEntityState? OnCreate(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
|
||||
// Atlas-tier entities (procedural / dat-hydrated, ServerGuid == 0)
|
||||
// are handled by LandblockSpawnAdapter, not here.
|
||||
if (entity.ServerGuid == 0) return null;
|
||||
|
||||
// Pre-warm the per-instance texture cache for surfaces whose ids are
|
||||
// already known at spawn time (those appearing as keys in
|
||||
// MeshRef.SurfaceOverrides). GfxObj sub-mesh surface ids that aren't
|
||||
// covered by SurfaceOverrides are decoded lazily by the draw
|
||||
// dispatcher on first use — consistent with StaticMeshRenderer.
|
||||
if (entity.PaletteOverride is { } paletteOverride)
|
||||
{
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
if (meshRef.SurfaceOverrides is null) continue;
|
||||
|
||||
// SurfaceOverrides maps surfaceId → origTextureOverride (may be 0
|
||||
// meaning "no texture swap, just the palette override applies").
|
||||
foreach (var (surfaceId, origTexOverride) in meshRef.SurfaceOverrides)
|
||||
{
|
||||
_textureCache.GetOrUploadWithPaletteOverride(
|
||||
surfaceId,
|
||||
origTexOverride == 0 ? null : origTexOverride,
|
||||
paletteOverride);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the per-entity AnimatedEntityState. The sequencer factory
|
||||
// may return a stub (in tests) or a fully-constructed sequencer from
|
||||
// the MotionTable (in production). Factory must not return null —
|
||||
// if the entity has no motion table the factory should construct a
|
||||
// no-op sequencer (Setup + empty MotionTable + NullAnimationLoader).
|
||||
var sequencer = _sequencerFactory(entity);
|
||||
var state = new AnimatedEntityState(sequencer);
|
||||
|
||||
// Adjustment 6: WorldEntity now carries PartOverrides + HiddenPartsMask.
|
||||
state.HideParts(entity.HiddenPartsMask);
|
||||
foreach (var po in entity.PartOverrides)
|
||||
state.SetPartOverride(po.PartIndex, po.GfxObjId);
|
||||
|
||||
_stateByGuid[entity.ServerGuid] = state;
|
||||
|
||||
// Register each unique GfxObj id with WB so the meshes background-load.
|
||||
// Includes both the entity's natural MeshRefs AND any server-sent
|
||||
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
|
||||
// Setup default and need their own mesh data uploaded.
|
||||
if (_meshAdapter is not null)
|
||||
{
|
||||
var unique = new HashSet<ulong>();
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
unique.Add((ulong)meshRef.GfxObjId);
|
||||
foreach (var po in entity.PartOverrides)
|
||||
unique.Add((ulong)po.GfxObjId);
|
||||
|
||||
_meshIdsByGuid[entity.ServerGuid] = unique;
|
||||
foreach (var id in unique) _meshAdapter.IncrementRefCount(id);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Release the per-entity state for <paramref name="serverGuid"/>. Called
|
||||
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
|
||||
/// removed) are silently ignored.
|
||||
/// </summary>
|
||||
public void OnRemove(uint serverGuid)
|
||||
{
|
||||
_stateByGuid.Remove(serverGuid);
|
||||
|
||||
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids))
|
||||
{
|
||||
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
|
||||
_meshIdsByGuid.Remove(serverGuid);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up the <see cref="AnimatedEntityState"/> for a server guid.
|
||||
/// Returns <c>null</c> if the entity was never spawned or has already
|
||||
/// been removed.
|
||||
/// </summary>
|
||||
public AnimatedEntityState? GetState(uint serverGuid)
|
||||
=> _stateByGuid.TryGetValue(serverGuid, out var s) ? s : null;
|
||||
}
|
||||
22
src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs
Normal file
22
src/AcDream.App/Rendering/Wb/ITextureCachePerInstance.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Seam interface over the per-instance palette-override decode path in
|
||||
/// <see cref="TextureCache"/>. Extracted so <see cref="EntitySpawnAdapter"/>
|
||||
/// can be tested without a live GL context.
|
||||
/// </summary>
|
||||
public interface ITextureCachePerInstance
|
||||
{
|
||||
/// <summary>
|
||||
/// Decode (or return cached) the palette-overridden texture for
|
||||
/// <paramref name="surfaceId"/>. Delegates to
|
||||
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> in
|
||||
/// production.
|
||||
/// </summary>
|
||||
uint GetOrUploadWithPaletteOverride(
|
||||
uint surfaceId,
|
||||
uint? overrideOrigTextureId,
|
||||
PaletteOverride paletteOverride);
|
||||
}
|
||||
12
src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs
Normal file
12
src/AcDream.App/Rendering/Wb/IWbMeshAdapter.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Mockable interface over <see cref="WbMeshAdapter"/> so adapters that
|
||||
/// drive ref-count lifecycle (e.g. LandblockSpawnAdapter, EntitySpawnAdapter)
|
||||
/// can be unit-tested without a real WB pipeline behind them.
|
||||
/// </summary>
|
||||
public interface IWbMeshAdapter
|
||||
{
|
||||
void IncrementRefCount(ulong id);
|
||||
void DecrementRefCount(ulong id);
|
||||
}
|
||||
94
src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs
Normal file
94
src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges landblock streaming events to <see cref="IWbMeshAdapter"/>'s
|
||||
/// reference-count lifecycle. <b>Tier-aware by design</b>: only atlas-tier
|
||||
/// entities (procedural / dat-hydrated, identified by
|
||||
/// <c>ServerGuid == 0</c>) drive ref counts. Server-spawned entities
|
||||
/// (per-instance tier) are skipped — those go through
|
||||
/// <c>EntitySpawnAdapter</c> + <c>TextureCache.GetOrUploadWithPaletteOverride</c>
|
||||
/// (see Phase N.4 spec, Architecture → Two-tier rendering split).
|
||||
///
|
||||
/// <para>
|
||||
/// On load: walks the landblock's atlas-tier entities, collects unique
|
||||
/// GfxObj ids from their <c>MeshRefs</c>, calls
|
||||
/// <c>IncrementRefCount</c> per id. Snapshots the id-set per landblock so
|
||||
/// unload can match the load 1:1.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// On unload: looks up the snapshot, calls <c>DecrementRefCount</c> per id,
|
||||
/// drops the snapshot. Unknown / never-loaded landblocks no-op.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Idempotency: a duplicate load for the same landblock is a no-op on
|
||||
/// ref-counting (the snapshot is already present). Defensive guard against
|
||||
/// streaming-controller bugs.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Thread safety: the underlying <see cref="IWbMeshAdapter"/> implementation
|
||||
/// uses <c>ConcurrentDictionary</c>, so the streaming worker thread may call
|
||||
/// this safely. The internal snapshot dictionary is NOT thread-safe and must
|
||||
/// be called from a single streaming thread (the same thread that fires
|
||||
/// AddLandblock / RemoveLandblock events).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LandblockSpawnAdapter
|
||||
{
|
||||
private readonly IWbMeshAdapter _adapter;
|
||||
|
||||
// Maps landblock id → unique GfxObj ids registered for that landblock.
|
||||
// Written on load, read+cleared on unload. Single-threaded (streaming worker).
|
||||
private readonly Dictionary<uint, HashSet<ulong>> _idsByLandblock = new();
|
||||
|
||||
public LandblockSpawnAdapter(IWbMeshAdapter adapter)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(adapter);
|
||||
_adapter = adapter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a landblock finishes streaming in.
|
||||
/// Registers a ref-count increment with WB for each unique atlas-tier
|
||||
/// GfxObj id in the landblock. Duplicate loads for the same landblock id
|
||||
/// are silently ignored.
|
||||
/// </summary>
|
||||
public void OnLandblockLoaded(LoadedLandblock landblock)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(landblock);
|
||||
|
||||
// Idempotency: already-loaded landblock is a no-op.
|
||||
if (_idsByLandblock.ContainsKey(landblock.LandblockId)) return;
|
||||
|
||||
var unique = new HashSet<ulong>();
|
||||
foreach (var entity in landblock.Entities)
|
||||
{
|
||||
// Atlas-tier filter: server-spawned entities (ServerGuid != 0)
|
||||
// belong to the per-instance path and are NOT registered with WB.
|
||||
if (entity.ServerGuid != 0) continue;
|
||||
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
unique.Add((ulong)meshRef.GfxObjId);
|
||||
}
|
||||
|
||||
_idsByLandblock[landblock.LandblockId] = unique;
|
||||
foreach (var id in unique) _adapter.IncrementRefCount(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a landblock is unloaded from the streaming window.
|
||||
/// Releases the ref-count for every GfxObj id that was registered on load.
|
||||
/// Unknown landblock ids (never loaded, or already unloaded) are no-ops.
|
||||
/// </summary>
|
||||
public void OnLandblockUnloaded(uint landblockId)
|
||||
{
|
||||
if (!_idsByLandblock.TryGetValue(landblockId, out var unique)) return;
|
||||
foreach (var id in unique) _adapter.DecrementRefCount(id);
|
||||
_idsByLandblock.Remove(landblockId);
|
||||
}
|
||||
}
|
||||
521
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
Normal file
521
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using Chorizite.OpenGLSDLBackend.Lib;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global
|
||||
/// VAO/VBO/IBO under modern rendering) with acdream's <see cref="TextureCache"/>
|
||||
/// for texture resolution and <see cref="AcSurfaceMetadataTable"/> for
|
||||
/// translucency classification.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Atlas-tier</b> entities (<c>ServerGuid == 0</c>): mesh data comes from WB's
|
||||
/// <see cref="ObjectMeshManager"/> via <see cref="WbMeshAdapter.TryGetRenderData"/>.
|
||||
/// Textures resolve through <see cref="TextureCache.GetOrUpload"/> using the batch's
|
||||
/// <c>SurfaceId</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Per-instance-tier</b> entities (<c>ServerGuid != 0</c>): mesh data also from
|
||||
/// WB, but textures resolve through <see cref="TextureCache"/> with palette and
|
||||
/// surface overrides applied. <see cref="AnimatedEntityState"/> is currently
|
||||
/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges +
|
||||
/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into <c>MeshRefs</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>GL strategy:</b> GROUPED instanced drawing. All visible (entity, batch)
|
||||
/// pairs are bucketed by <see cref="GroupKey"/>; within a group a single
|
||||
/// <c>glDrawElementsInstancedBaseVertexBaseInstance</c> renders all instances.
|
||||
/// All matrices for the frame land in one shared instance VBO via a single
|
||||
/// <c>BufferData</c> upload. This drops draw calls from O(entities×batches)
|
||||
/// to O(unique GfxObj×batch×texture) — typically two orders of magnitude fewer.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Shader:</b> reuses <c>mesh_instanced</c> (vert locations 0-2 = Position/
|
||||
/// Normal/UV from WB's <c>VertexPositionNormalTexture</c>; locations 3-6 = instance
|
||||
/// matrix from our VBO). WB's 32-byte vertex stride is compatible.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Modern rendering assumption:</b> WB's <c>_useModernRendering</c> path (GL
|
||||
/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses
|
||||
/// <c>FirstIndex</c> + <c>BaseVertex</c> per batch. The dispatcher honors those
|
||||
/// offsets via <c>DrawElementsInstancedBaseVertex(BaseInstance)</c>. The legacy
|
||||
/// per-mesh-VAO path also works since FirstIndex/BaseVertex are zero there.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly Shader _shader;
|
||||
private readonly TextureCache _textures;
|
||||
private readonly WbMeshAdapter _meshAdapter;
|
||||
private readonly EntitySpawnAdapter _entitySpawnAdapter;
|
||||
|
||||
private readonly uint _instanceVbo;
|
||||
private readonly HashSet<uint> _patchedVaos = new();
|
||||
|
||||
// Per-frame scratch — reused across frames to avoid per-frame allocation.
|
||||
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
|
||||
private readonly List<InstanceGroup> _opaqueDraws = new();
|
||||
private readonly List<InstanceGroup> _translucentDraws = new();
|
||||
private float[] _instanceBuffer = new float[256 * 16]; // grow on demand, never shrink
|
||||
|
||||
// Per-entity-cull AABB radius. Conservative — covers most entities; large
|
||||
// outliers (long banners, tall columns) are still landblock-culled.
|
||||
private const float PerEntityCullRadius = 5.0f;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
|
||||
private int _entitiesSeen;
|
||||
private int _entitiesDrawn;
|
||||
private int _meshesMissing;
|
||||
private int _drawsIssued;
|
||||
private int _instancesIssued;
|
||||
private long _lastLogTick;
|
||||
|
||||
public WbDrawDispatcher(
|
||||
GL gl,
|
||||
Shader shader,
|
||||
TextureCache textures,
|
||||
WbMeshAdapter meshAdapter,
|
||||
EntitySpawnAdapter entitySpawnAdapter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gl);
|
||||
ArgumentNullException.ThrowIfNull(shader);
|
||||
ArgumentNullException.ThrowIfNull(textures);
|
||||
ArgumentNullException.ThrowIfNull(meshAdapter);
|
||||
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
|
||||
|
||||
_gl = gl;
|
||||
_shader = shader;
|
||||
_textures = textures;
|
||||
_meshAdapter = meshAdapter;
|
||||
_entitySpawnAdapter = entitySpawnAdapter;
|
||||
|
||||
_instanceVbo = _gl.GenBuffer();
|
||||
}
|
||||
|
||||
public static Matrix4x4 ComposePartWorldMatrix(
|
||||
Matrix4x4 entityWorld,
|
||||
Matrix4x4 animOverride,
|
||||
Matrix4x4 restPose)
|
||||
=> restPose * animOverride * entityWorld;
|
||||
|
||||
public void Draw(
|
||||
ICamera camera,
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
|
||||
FrustumPlanes? frustum = null,
|
||||
uint? neverCullLandblockId = null,
|
||||
HashSet<uint>? visibleCellIds = null,
|
||||
HashSet<uint>? animatedEntityIds = null)
|
||||
{
|
||||
_shader.Use();
|
||||
var vp = camera.View * camera.Projection;
|
||||
_shader.SetMatrix4("uViewProjection", vp);
|
||||
|
||||
bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal);
|
||||
|
||||
// Camera world-space position for front-to-back sort (perf #2). The view
|
||||
// matrix is the inverse of the camera's world transform, so the world
|
||||
// translation lives in the inverse's translation row.
|
||||
Vector3 camPos = Vector3.Zero;
|
||||
if (Matrix4x4.Invert(camera.View, out var invView))
|
||||
camPos = invView.Translation;
|
||||
|
||||
// ── Phase 1: clear groups, walk entities, build groups ──────────────
|
||||
foreach (var grp in _groups.Values) grp.Matrices.Clear();
|
||||
|
||||
var metaTable = _meshAdapter.MetadataTable;
|
||||
uint anyVao = 0;
|
||||
|
||||
foreach (var entry in landblockEntries)
|
||||
{
|
||||
bool landblockVisible = frustum is null
|
||||
|| entry.LandblockId == neverCullLandblockId
|
||||
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
|
||||
|
||||
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
|
||||
continue;
|
||||
|
||||
foreach (var entity in entry.Entities)
|
||||
{
|
||||
if (entity.MeshRefs.Count == 0) continue;
|
||||
|
||||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||||
if (!landblockVisible && !isAnimated) continue;
|
||||
|
||||
if (entity.ParentCellId.HasValue && visibleCellIds is not null
|
||||
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
|
||||
continue;
|
||||
|
||||
// Per-entity AABB frustum cull (perf #3). Skips work for distant
|
||||
// entities even when their landblock is visible. Animated
|
||||
// entities bypass — they're tracked at landblock level + need
|
||||
// per-frame work for animation regardless. Conservative 5m
|
||||
// radius covers typical entity bounds.
|
||||
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
|
||||
{
|
||||
var p = entity.Position;
|
||||
var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius);
|
||||
var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius);
|
||||
if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (diag) _entitiesSeen++;
|
||||
|
||||
var entityWorld =
|
||||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||||
Matrix4x4.CreateTranslation(entity.Position);
|
||||
|
||||
// Compute palette-override hash ONCE per entity (perf #4).
|
||||
// Reused across every (part, batch) lookup so the FNV-1a fold
|
||||
// over SubPalettes runs once instead of N times. Zero when the
|
||||
// entity has no palette override (trees, scenery).
|
||||
ulong palHash = 0;
|
||||
if (entity.PaletteOverride is not null)
|
||||
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
|
||||
|
||||
bool drewAny = false;
|
||||
for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
|
||||
{
|
||||
// Note: GameWindow's spawn path already applies
|
||||
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
|
||||
// close-detail mesh swap for humanoids) to MeshRefs. We
|
||||
// trust MeshRefs as the source of truth here. AnimatedEntityState's
|
||||
// overrides become relevant only for hot-swap (0xF625
|
||||
// ObjDescEvent) which today rebuilds MeshRefs anyway.
|
||||
var meshRef = entity.MeshRefs[partIdx];
|
||||
ulong gfxObjId = meshRef.GfxObjId;
|
||||
|
||||
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
|
||||
if (renderData is null)
|
||||
{
|
||||
if (diag) _meshesMissing++;
|
||||
continue;
|
||||
}
|
||||
drewAny = true;
|
||||
if (anyVao == 0) anyVao = renderData.VAO;
|
||||
|
||||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||||
{
|
||||
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
|
||||
{
|
||||
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
||||
if (partData is null) continue;
|
||||
|
||||
var model = ComposePartWorldMatrix(
|
||||
entityWorld, meshRef.PartTransform, partTransform);
|
||||
|
||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var model = meshRef.PartTransform * entityWorld;
|
||||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
|
||||
}
|
||||
}
|
||||
|
||||
if (diag && drewAny) _entitiesDrawn++;
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing visible — skip the GL pass entirely.
|
||||
if (anyVao == 0)
|
||||
{
|
||||
if (diag) MaybeFlushDiag();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Phase 2: lay matrices out contiguously, assign per-group offsets,
|
||||
// split into opaque/translucent + compute sort keys ─────────
|
||||
int totalInstances = 0;
|
||||
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
|
||||
if (totalInstances == 0)
|
||||
{
|
||||
if (diag) MaybeFlushDiag();
|
||||
return;
|
||||
}
|
||||
|
||||
int needed = totalInstances * 16;
|
||||
if (_instanceBuffer.Length < needed)
|
||||
_instanceBuffer = new float[needed + 256 * 16]; // headroom
|
||||
|
||||
_opaqueDraws.Clear();
|
||||
_translucentDraws.Clear();
|
||||
|
||||
int cursor = 0;
|
||||
foreach (var grp in _groups.Values)
|
||||
{
|
||||
if (grp.Matrices.Count == 0) continue;
|
||||
|
||||
grp.FirstInstance = cursor;
|
||||
grp.InstanceCount = grp.Matrices.Count;
|
||||
|
||||
// Use the first instance's translation as the group's representative
|
||||
// position for front-to-back sort (perf #2). Cheap heuristic; works
|
||||
// well when instances of one group are spatially coherent
|
||||
// (typical for trees in one landblock area, NPCs at one spawn).
|
||||
var firstM = grp.Matrices[0];
|
||||
var grpPos = new Vector3(firstM.M41, firstM.M42, firstM.M43);
|
||||
grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos);
|
||||
|
||||
for (int i = 0; i < grp.Matrices.Count; i++)
|
||||
{
|
||||
WriteMatrix(_instanceBuffer, cursor * 16, grp.Matrices[i]);
|
||||
cursor++;
|
||||
}
|
||||
|
||||
if (grp.Translucency == TranslucencyKind.Opaque || grp.Translucency == TranslucencyKind.ClipMap)
|
||||
_opaqueDraws.Add(grp);
|
||||
else
|
||||
_translucentDraws.Add(grp);
|
||||
}
|
||||
|
||||
// Front-to-back sort for opaque pass: nearer groups draw first so the
|
||||
// depth test rejects fragments hidden behind them, reducing fragment
|
||||
// shader cost from overdraw on dense scenes (Holtburg courtyard,
|
||||
// Foundry interior).
|
||||
_opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance));
|
||||
|
||||
// ── Phase 3: one upload of all matrices ─────────────────────────────
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
fixed (float* p = _instanceBuffer)
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(totalInstances * 16 * sizeof(float)), p, BufferUsageARB.DynamicDraw);
|
||||
|
||||
// ── Phase 4: bind VAO once (modern rendering shares one global VAO) ──
|
||||
EnsureInstanceAttribs(anyVao);
|
||||
_gl.BindVertexArray(anyVao);
|
||||
|
||||
// ── Phase 5: opaque + ClipMap pass (front-to-back sorted) ───────────
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
|
||||
foreach (var grp in _opaqueDraws)
|
||||
{
|
||||
_shader.SetInt("uTranslucencyKind", (int)grp.Translucency);
|
||||
DrawGroup(grp);
|
||||
}
|
||||
|
||||
// ── Phase 6: translucent pass ───────────────────────────────────────
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.DepthMask(false);
|
||||
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
}
|
||||
|
||||
foreach (var grp in _translucentDraws)
|
||||
{
|
||||
switch (grp.Translucency)
|
||||
{
|
||||
case TranslucencyKind.Additive:
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
|
||||
break;
|
||||
case TranslucencyKind.InvAlpha:
|
||||
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
|
||||
break;
|
||||
default:
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
break;
|
||||
}
|
||||
|
||||
_shader.SetInt("uTranslucencyKind", (int)grp.Translucency);
|
||||
DrawGroup(grp);
|
||||
}
|
||||
|
||||
_gl.DepthMask(true);
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
if (diag)
|
||||
{
|
||||
_drawsIssued += _opaqueDraws.Count + _translucentDraws.Count;
|
||||
_instancesIssued += totalInstances;
|
||||
MaybeFlushDiag();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawGroup(InstanceGroup grp)
|
||||
{
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, grp.TextureHandle);
|
||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, grp.Ibo);
|
||||
|
||||
// BaseInstance offsets the per-instance attribute fetches into our
|
||||
// shared instance VBO so each group reads its own slice. Requires
|
||||
// GL_ARB_base_instance (GL 4.2+); WB requires 4.3 so this is available.
|
||||
_gl.DrawElementsInstancedBaseVertexBaseInstance(
|
||||
PrimitiveType.Triangles,
|
||||
(uint)grp.IndexCount,
|
||||
DrawElementsType.UnsignedShort,
|
||||
(void*)(grp.FirstIndex * sizeof(ushort)),
|
||||
(uint)grp.InstanceCount,
|
||||
grp.BaseVertex,
|
||||
(uint)grp.FirstInstance);
|
||||
}
|
||||
|
||||
private void MaybeFlushDiag()
|
||||
{
|
||||
long now = Environment.TickCount64;
|
||||
if (now - _lastLogTick > 5000)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count}");
|
||||
_entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0;
|
||||
_lastLogTick = now;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClassifyBatches(
|
||||
ObjectRenderData renderData,
|
||||
ulong gfxObjId,
|
||||
Matrix4x4 model,
|
||||
WorldEntity entity,
|
||||
MeshRef meshRef,
|
||||
ulong palHash,
|
||||
AcSurfaceMetadataTable metaTable)
|
||||
{
|
||||
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
||||
{
|
||||
var batch = renderData.Batches[batchIdx];
|
||||
|
||||
TranslucencyKind translucency;
|
||||
if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta))
|
||||
{
|
||||
translucency = meta.Translucency;
|
||||
}
|
||||
else
|
||||
{
|
||||
translucency = batch.IsAdditive ? TranslucencyKind.Additive
|
||||
: batch.IsTransparent ? TranslucencyKind.AlphaBlend
|
||||
: TranslucencyKind.Opaque;
|
||||
}
|
||||
|
||||
uint texHandle = ResolveTexture(entity, meshRef, batch, palHash);
|
||||
if (texHandle == 0) continue;
|
||||
|
||||
var key = new GroupKey(
|
||||
batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
|
||||
batch.IndexCount, texHandle, translucency);
|
||||
|
||||
if (!_groups.TryGetValue(key, out var grp))
|
||||
{
|
||||
grp = new InstanceGroup
|
||||
{
|
||||
Ibo = batch.IBO,
|
||||
FirstIndex = batch.FirstIndex,
|
||||
BaseVertex = (int)batch.BaseVertex,
|
||||
IndexCount = batch.IndexCount,
|
||||
TextureHandle = texHandle,
|
||||
Translucency = translucency,
|
||||
};
|
||||
_groups[key] = grp;
|
||||
}
|
||||
grp.Matrices.Add(model);
|
||||
}
|
||||
}
|
||||
|
||||
private uint ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch, ulong palHash)
|
||||
{
|
||||
// WB stores the surface id on batch.Key.SurfaceId (TextureKey struct);
|
||||
// batch.SurfaceId is unset (zero) for batches built by ObjectMeshManager.
|
||||
uint surfaceId = batch.Key.SurfaceId;
|
||||
if (surfaceId == 0 || surfaceId == 0xFFFFFFFF) return 0;
|
||||
|
||||
uint overrideOrigTex = 0;
|
||||
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
|
||||
&& meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex);
|
||||
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
|
||||
|
||||
if (entity.PaletteOverride is not null)
|
||||
{
|
||||
// perf #4: pass the entity-precomputed palette hash so TextureCache
|
||||
// can skip its internal HashPaletteOverride for repeat lookups
|
||||
// within the same character.
|
||||
return _textures.GetOrUploadWithPaletteOverride(
|
||||
surfaceId, origTexOverride, entity.PaletteOverride, palHash);
|
||||
}
|
||||
else if (hasOrigTexOverride)
|
||||
{
|
||||
return _textures.GetOrUploadWithOrigTextureOverride(surfaceId, overrideOrigTex);
|
||||
}
|
||||
else
|
||||
{
|
||||
return _textures.GetOrUpload(surfaceId);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureInstanceAttribs(uint vao)
|
||||
{
|
||||
if (!_patchedVaos.Add(vao)) return;
|
||||
|
||||
_gl.BindVertexArray(vao);
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
|
||||
for (uint row = 0; row < 4; row++)
|
||||
{
|
||||
uint loc = 3 + row;
|
||||
_gl.EnableVertexAttribArray(loc);
|
||||
_gl.VertexAttribPointer(loc, 4, VertexAttribPointerType.Float, false, 64, (void*)(row * 16));
|
||||
_gl.VertexAttribDivisor(loc, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteMatrix(float[] buf, int offset, in Matrix4x4 m)
|
||||
{
|
||||
buf[offset + 0] = m.M11; buf[offset + 1] = m.M12; buf[offset + 2] = m.M13; buf[offset + 3] = m.M14;
|
||||
buf[offset + 4] = m.M21; buf[offset + 5] = m.M22; buf[offset + 6] = m.M23; buf[offset + 7] = m.M24;
|
||||
buf[offset + 8] = m.M31; buf[offset + 9] = m.M32; buf[offset + 10] = m.M33; buf[offset + 11] = m.M34;
|
||||
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_gl.DeleteBuffer(_instanceVbo);
|
||||
}
|
||||
|
||||
private readonly record struct GroupKey(
|
||||
uint Ibo,
|
||||
uint FirstIndex,
|
||||
int BaseVertex,
|
||||
int IndexCount,
|
||||
uint TextureHandle,
|
||||
TranslucencyKind Translucency);
|
||||
|
||||
private sealed class InstanceGroup
|
||||
{
|
||||
public uint Ibo;
|
||||
public uint FirstIndex;
|
||||
public int BaseVertex;
|
||||
public int IndexCount;
|
||||
public uint TextureHandle;
|
||||
public TranslucencyKind Translucency;
|
||||
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
|
||||
public int InstanceCount;
|
||||
public float SortDistance; // squared distance from camera to first instance, for opaque sort
|
||||
public readonly List<Matrix4x4> Matrices = new();
|
||||
}
|
||||
}
|
||||
39
src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
Normal file
39
src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Process-lifetime cache of <c>ACDREAM_USE_WB_FOUNDATION</c> env var.
|
||||
/// Read once at static-init time; all consumers import this rather than
|
||||
/// re-reading the env var per call (env-var lookups on Windows are not
|
||||
/// free at hot-path cadence).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Default-on as of Phase N.4 ship (2026-05-08).</b> The WB foundation
|
||||
/// (<c>WbMeshAdapter</c> + <c>WbDrawDispatcher</c>) is the production
|
||||
/// rendering path. Set <c>ACDREAM_USE_WB_FOUNDATION=0</c> to fall back
|
||||
/// to the legacy <c>InstancedMeshRenderer</c> path — kept as an escape
|
||||
/// hatch until N.6 fully replaces it.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Per-instance customized content (server <c>CreateObject</c> entities
|
||||
/// with palette / texture overrides) routes through
|
||||
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> regardless
|
||||
/// of the flag — the flag controls which DRAW path consumes those
|
||||
/// textures.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class WbFoundationFlag
|
||||
{
|
||||
private static bool _isEnabled =
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
|
||||
|
||||
public static bool IsEnabled => _isEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// FOR TESTS ONLY. Forces <see cref="IsEnabled"/> to <c>true</c> so
|
||||
/// integration tests can exercise the WB adapter path without having to
|
||||
/// set the env var before static initialisation. Never call from
|
||||
/// production code.
|
||||
/// </summary>
|
||||
internal static void ForTestsOnly_ForceEnable() => _isEnabled = true;
|
||||
}
|
||||
203
src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
Normal file
203
src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Meshing;
|
||||
using Chorizite.OpenGLSDLBackend;
|
||||
using Chorizite.OpenGLSDLBackend.Lib;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Silk.NET.OpenGL;
|
||||
using WorldBuilder.Shared.Models;
|
||||
using WorldBuilder.Shared.Services;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Single seam between acdream and WB's render pipeline. Owns the
|
||||
/// <c>ObjectMeshManager</c> instance and exposes a stable acdream-shaped API
|
||||
/// so the rest of the renderer doesn't need to know about WB's types directly.
|
||||
///
|
||||
/// <para>
|
||||
/// The adapter constructs its own <c>DefaultDatReaderWriter</c> internally; it
|
||||
/// does NOT share file handles with our <c>DatCollection</c>. This duplicates
|
||||
/// index-cache memory (~50–100 MB) but keeps the two subsystems fully decoupled.
|
||||
/// Acceptable for Phase N.4 foundation work (plan Adjustment 1).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
||||
{
|
||||
private readonly OpenGLGraphicsDevice? _graphicsDevice;
|
||||
private readonly DefaultDatReaderWriter? _wbDats;
|
||||
private readonly ObjectMeshManager? _meshManager;
|
||||
private readonly DatCollection? _dats;
|
||||
private readonly AcSurfaceMetadataTable _metadataTable = new();
|
||||
private readonly HashSet<ulong> _metadataPopulated = new();
|
||||
|
||||
/// <summary>
|
||||
/// True when this instance was created via <see cref="CreateUninitialized"/>;
|
||||
/// all public methods no-op when uninitialized.
|
||||
/// </summary>
|
||||
private readonly bool _isUninitialized;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the full WB pipeline: OpenGLGraphicsDevice → DefaultDatReaderWriter
|
||||
/// → ObjectMeshManager.
|
||||
/// </summary>
|
||||
/// <param name="gl">Active Silk.NET GL context. Must be bound to the current
|
||||
/// thread (construction runs GL queries; call from OnLoad).</param>
|
||||
/// <param name="datDir">Path to the dat directory (same as the one supplied
|
||||
/// to our DatCollection). DefaultDatReaderWriter opens its own file handles.</param>
|
||||
/// <param name="dats">acdream's DatCollection, used to populate the surface
|
||||
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
|
||||
/// the rest of the client; read-only access from the render thread.</param>
|
||||
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
|
||||
/// NullLogger internally.</param>
|
||||
public WbMeshAdapter(GL gl, string datDir, DatCollection dats, ILogger<WbMeshAdapter> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gl);
|
||||
ArgumentNullException.ThrowIfNull(datDir);
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_dats = dats;
|
||||
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
|
||||
_wbDats = new DefaultDatReaderWriter(datDir);
|
||||
_meshManager = new ObjectMeshManager(
|
||||
_graphicsDevice,
|
||||
_wbDats,
|
||||
NullLogger<ObjectMeshManager>.Instance);
|
||||
}
|
||||
|
||||
private WbMeshAdapter()
|
||||
{
|
||||
// Uninitialized constructor — only for tests / flag-off cases where
|
||||
// the caller wants a Dispose-safe no-op instance.
|
||||
_isUninitialized = true;
|
||||
}
|
||||
|
||||
/// <summary>Test/init helper — produces a Dispose-safe instance with no
|
||||
/// underlying mesh manager. Public methods are all no-ops.</summary>
|
||||
public static WbMeshAdapter CreateUninitialized() => new();
|
||||
|
||||
/// <summary>
|
||||
/// The surface metadata side-table populated on each first
|
||||
/// <see cref="IncrementRefCount"/>. Queried by the draw dispatcher
|
||||
/// to determine translucency, luminosity, and fog behavior per batch.
|
||||
/// </summary>
|
||||
public AcSurfaceMetadataTable MetadataTable => _metadataTable;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the WB render data for <paramref name="id"/>, or null if not
|
||||
/// yet uploaded or if this adapter is uninitialized. Increments WB's
|
||||
/// internal usage counter — use <see cref="TryGetRenderData"/> for
|
||||
/// render-loop lookups that should not affect lifecycle.
|
||||
/// </summary>
|
||||
public ObjectRenderData? GetRenderData(ulong id)
|
||||
{
|
||||
if (_isUninitialized || _meshManager is null) return null;
|
||||
return _meshManager.GetRenderData(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the WB render data for <paramref name="id"/> without
|
||||
/// modifying reference counts. Returns null if the mesh is not yet
|
||||
/// uploaded. Safe for render-loop lookups.
|
||||
/// </summary>
|
||||
public ObjectRenderData? TryGetRenderData(ulong id)
|
||||
{
|
||||
if (_isUninitialized || _meshManager is null) return null;
|
||||
return _meshManager.TryGetRenderData(id);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void IncrementRefCount(ulong id)
|
||||
{
|
||||
if (_isUninitialized || _meshManager is null) return;
|
||||
_meshManager.IncrementRefCount(id);
|
||||
|
||||
if (_metadataPopulated.Add(id))
|
||||
{
|
||||
PopulateMetadata(id);
|
||||
|
||||
// WB's IncrementRefCount alone only bumps a usage counter; it does
|
||||
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
|
||||
// so the background workers actually decode the GfxObj. The result
|
||||
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
|
||||
// which Tick() drains onto the GPU. Until that completes,
|
||||
// TryGetRenderData(id) returns null and the dispatcher silently
|
||||
// skips the entity — standard streaming flicker.
|
||||
//
|
||||
// isSetup: false — acdream's MeshRefs already carry expanded
|
||||
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
|
||||
// unused.
|
||||
_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void DecrementRefCount(ulong id)
|
||||
{
|
||||
if (_isUninitialized || _meshManager is null) return;
|
||||
_meshManager.DecrementRefCount(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame drain of the WB pipeline's main-thread work queues. MUST be
|
||||
/// called once per frame from the render thread. Without this, the staged
|
||||
/// mesh data queue grows unbounded (memory leak) and queued GL actions
|
||||
/// never execute.
|
||||
///
|
||||
/// <para>
|
||||
/// Order matters: <c>ProcessGLQueue</c> runs first to apply any pending GL
|
||||
/// state changes (e.g., texture uploads queued by background workers
|
||||
/// during mesh prep). Then we drain staged mesh data, calling
|
||||
/// <c>UploadMeshData</c> on each item to materialize the actual GL VAO /
|
||||
/// VBO / IBO resources. After Tick, <c>GetRenderData</c> for any id
|
||||
/// previously passed to <c>IncrementRefCount</c> may return non-null.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// No-op when the adapter is uninitialized (e.g., flag is off and the
|
||||
/// adapter was constructed via <c>CreateUninitialized</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (_isUninitialized) return;
|
||||
if (_disposed) return;
|
||||
|
||||
_graphicsDevice!.ProcessGLQueue();
|
||||
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
|
||||
{
|
||||
_meshManager.UploadMeshData(meshData);
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateMetadata(ulong id)
|
||||
{
|
||||
if (_dats is null) return;
|
||||
if (!_dats.Portal.TryGet<GfxObj>((uint)id, out var gfxObj)) return;
|
||||
|
||||
var subMeshes = GfxObjMesh.Build(gfxObj, _dats);
|
||||
for (int i = 0; i < subMeshes.Count; i++)
|
||||
{
|
||||
var sm = subMeshes[i];
|
||||
_metadataTable.Add(id, i, new AcSurfaceMetadata(
|
||||
sm.Translucency, sm.Luminosity, sm.Diffuse,
|
||||
sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_meshManager?.Dispose();
|
||||
_wbDats?.Dispose();
|
||||
_graphicsDevice?.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
|
@ -38,6 +39,17 @@ namespace AcDream.App.Streaming;
|
|||
/// </summary>
|
||||
public sealed class GpuWorldState
|
||||
{
|
||||
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
|
||||
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
|
||||
|
||||
public GpuWorldState(
|
||||
LandblockSpawnAdapter? wbSpawnAdapter = null,
|
||||
EntitySpawnAdapter? wbEntitySpawnAdapter = null)
|
||||
{
|
||||
_wbSpawnAdapter = wbSpawnAdapter;
|
||||
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
|
||||
}
|
||||
|
||||
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
|
||||
private readonly Dictionary<uint, (Vector3 Min, Vector3 Max)> _aabbs = new();
|
||||
|
||||
|
|
@ -132,6 +144,8 @@ public sealed class GpuWorldState
|
|||
}
|
||||
|
||||
_loaded[landblock.LandblockId] = landblock;
|
||||
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
|
||||
RebuildFlatView();
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +195,9 @@ public sealed class GpuWorldState
|
|||
|
||||
public void RemoveLandblock(uint landblockId)
|
||||
{
|
||||
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
|
||||
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
|
||||
|
||||
// Rescue persistent entities before removal. These get appended
|
||||
// to the _persistentRescued list; the caller is responsible for
|
||||
// re-injecting them (via AppendLiveEntity) into whatever landblock
|
||||
|
|
@ -233,6 +250,10 @@ public sealed class GpuWorldState
|
|||
{
|
||||
if (serverGuid == 0) return;
|
||||
|
||||
// Phase N.4 Task 17: release per-instance state for server-spawned
|
||||
// entities. No-op for atlas-tier entities (never registered).
|
||||
_wbEntitySpawnAdapter?.OnRemove(serverGuid);
|
||||
|
||||
bool rebuiltLoaded = false;
|
||||
|
||||
// Scan loaded landblocks. ToArray() so we can mutate _loaded inside.
|
||||
|
|
@ -288,6 +309,11 @@ public sealed class GpuWorldState
|
|||
/// </summary>
|
||||
public void AppendLiveEntity(uint landblockId, WorldEntity entity)
|
||||
{
|
||||
// Phase N.4 Task 17: route server-spawned entities through the
|
||||
// per-instance adapter. Atlas-tier entities (ServerGuid == 0) are
|
||||
// skipped by OnCreate — it returns null immediately for those.
|
||||
_wbEntitySpawnAdapter?.OnCreate(entity);
|
||||
|
||||
uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu;
|
||||
|
||||
if (_loaded.TryGetValue(canonicalLandblockId, out var lb))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using BCnEncoder.Decoder;
|
||||
using BCnEncoder.Shared;
|
||||
using Chorizite.OpenGLSDLBackend.Lib;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ public static class SurfaceDecoder
|
|||
/// when a palette is available.
|
||||
/// </summary>
|
||||
public static DecodedTexture DecodeRenderSurface(RenderSurface rs)
|
||||
=> DecodeRenderSurface(rs, palette: null);
|
||||
=> DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: false);
|
||||
|
||||
/// <summary>
|
||||
/// Decode a RenderSurface's pixel bytes into RGBA8 with optional palette support.
|
||||
|
|
@ -24,8 +25,11 @@ public static class SurfaceDecoder
|
|||
/// 16-bit value in SourceData is treated as an index into <see cref="Palette.Colors"/>.
|
||||
/// When <paramref name="isClipMap"/> is true on an indexed surface, palette indices
|
||||
/// below 8 are forced to fully-transparent (AC's clipmap alpha-key convention).
|
||||
/// When <paramref name="isAdditive"/> is true, A8/CUSTOM_LSCAPE_ALPHA surfaces
|
||||
/// replicate the byte into all four channels (R=G=B=A=val, for terrain alpha masks
|
||||
/// and additive surfaces). When false, R=G=B=255, A=val (WB FillA8 semantics).
|
||||
/// </summary>
|
||||
public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false)
|
||||
public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false)
|
||||
{
|
||||
if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0)
|
||||
return DecodedTexture.Magenta;
|
||||
|
|
@ -40,9 +44,11 @@ public static class SurfaceDecoder
|
|||
PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap),
|
||||
PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap),
|
||||
PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap),
|
||||
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs),
|
||||
PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive),
|
||||
PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap),
|
||||
PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap),
|
||||
PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs),
|
||||
PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs),
|
||||
_ => DecodedTexture.Magenta,
|
||||
};
|
||||
}
|
||||
|
|
@ -59,33 +65,7 @@ public static class SurfaceDecoder
|
|||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
int paletteMax = palette.Colors.Count - 1;
|
||||
for (int i = 0; i < rs.Width * rs.Height; i++)
|
||||
{
|
||||
// Read each 16-bit value little-endian as a palette index
|
||||
int src = i * 2;
|
||||
ushort idx = (ushort)(rs.SourceData[src] | (rs.SourceData[src + 1] << 8));
|
||||
if (idx > paletteMax) idx = 0;
|
||||
var c = palette.Colors[idx];
|
||||
|
||||
int dst = i * 4;
|
||||
// Clipmap alpha-key convention (ACViewer: if (isClipMap && color < 8) r=g=b=a=0):
|
||||
// palette indices 0..7 on clipmap surfaces represent transparent pixels.
|
||||
if (isClipMap && idx < 8)
|
||||
{
|
||||
rgba[dst + 0] = 0;
|
||||
rgba[dst + 1] = 0;
|
||||
rgba[dst + 2] = 0;
|
||||
rgba[dst + 3] = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
rgba[dst + 0] = c.Red;
|
||||
rgba[dst + 1] = c.Green;
|
||||
rgba[dst + 2] = c.Blue;
|
||||
rgba[dst + 3] = c.Alpha;
|
||||
}
|
||||
}
|
||||
TextureHelpers.FillIndex16(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
|
|
@ -109,30 +89,22 @@ public static class SurfaceDecoder
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA)
|
||||
/// into RGBA8 by replicating each alpha byte into all four channels. AC's
|
||||
/// terrain blending alpha masks are stored as PFID_CUSTOM_LSCAPE_ALPHA and
|
||||
/// other generic 8-bit alpha surfaces use PFID_A8; the bit layout is
|
||||
/// identical so one decoder handles both. Replicating into all four
|
||||
/// channels lets the fragment shader pull "the blend amount" from either
|
||||
/// .a or .r without special-casing.
|
||||
/// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) into RGBA8.
|
||||
/// When <paramref name="isAdditive"/> is true: R=G=B=A=val (terrain alpha masks and
|
||||
/// additive entity textures — the shader reads .r for the blend weight). When false:
|
||||
/// R=G=B=255, A=val (WB FillA8 semantics for non-additive entity textures).
|
||||
/// </summary>
|
||||
private static DecodedTexture DecodeA8(RenderSurface rs)
|
||||
private static DecodedTexture DecodeA8(RenderSurface rs, bool isAdditive)
|
||||
{
|
||||
int expected = rs.Width * rs.Height;
|
||||
if (rs.SourceData.Length < expected)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[expected * 4];
|
||||
for (int i = 0; i < expected; i++)
|
||||
{
|
||||
byte a = rs.SourceData[i];
|
||||
int d = i * 4;
|
||||
rgba[d + 0] = a;
|
||||
rgba[d + 1] = a;
|
||||
rgba[d + 2] = a;
|
||||
rgba[d + 3] = a;
|
||||
}
|
||||
if (isAdditive)
|
||||
TextureHelpers.FillA8Additive(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
else
|
||||
TextureHelpers.FillA8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
|
|
@ -143,15 +115,7 @@ public static class SurfaceDecoder
|
|||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[expected];
|
||||
// Source layout per pixel: B, G, R, A → swap to R, G, B, A
|
||||
for (int i = 0; i < rs.Width * rs.Height; i++)
|
||||
{
|
||||
int s = i * 4;
|
||||
rgba[s + 0] = rs.SourceData[s + 2]; // R <- R
|
||||
rgba[s + 1] = rs.SourceData[s + 1]; // G <- G
|
||||
rgba[s + 2] = rs.SourceData[s + 0]; // B <- B
|
||||
rgba[s + 3] = rs.SourceData[s + 3]; // A <- A
|
||||
}
|
||||
TextureHelpers.FillA8R8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
|
|
@ -168,29 +132,7 @@ public static class SurfaceDecoder
|
|||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
int paletteMax = palette.Colors.Count - 1;
|
||||
for (int i = 0; i < rs.Width * rs.Height; i++)
|
||||
{
|
||||
int idx = rs.SourceData[i];
|
||||
if (idx > paletteMax) idx = 0;
|
||||
var c = palette.Colors[idx];
|
||||
|
||||
int dst = i * 4;
|
||||
if (isClipMap && idx < 8)
|
||||
{
|
||||
rgba[dst + 0] = 0;
|
||||
rgba[dst + 1] = 0;
|
||||
rgba[dst + 2] = 0;
|
||||
rgba[dst + 3] = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
rgba[dst + 0] = c.Red;
|
||||
rgba[dst + 1] = c.Green;
|
||||
rgba[dst + 2] = c.Blue;
|
||||
rgba[dst + 3] = c.Alpha;
|
||||
}
|
||||
}
|
||||
TextureHelpers.FillP8(rs.SourceData, palette, rgba.AsSpan(), rs.Width, rs.Height, isClipMap);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
|
|
@ -207,16 +149,7 @@ public static class SurfaceDecoder
|
|||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
for (int i = 0; i < rs.Width * rs.Height; i++)
|
||||
{
|
||||
int src = i * 3;
|
||||
int dst = i * 4;
|
||||
// On-disk byte order: B, G, R (little-endian 24-bit BGR, same as DX PFID_R8G8B8)
|
||||
rgba[dst + 0] = rs.SourceData[src + 2]; // R
|
||||
rgba[dst + 1] = rs.SourceData[src + 1]; // G
|
||||
rgba[dst + 2] = rs.SourceData[src + 0]; // B
|
||||
rgba[dst + 3] = 0xFF; // A = opaque
|
||||
}
|
||||
TextureHelpers.FillR8G8B8(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
|
|
@ -245,6 +178,28 @@ public static class SurfaceDecoder
|
|||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
private static DecodedTexture DecodeR5G6B5(RenderSurface rs)
|
||||
{
|
||||
int expectedBytes = rs.Width * rs.Height * 2;
|
||||
if (rs.SourceData.Length < expectedBytes)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
TextureHelpers.FillR5G6B5(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
private static DecodedTexture DecodeA4R4G4B4(RenderSurface rs)
|
||||
{
|
||||
int expectedBytes = rs.Width * rs.Height * 2;
|
||||
if (rs.SourceData.Length < expectedBytes)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rgba = new byte[rs.Width * rs.Height * 4];
|
||||
TextureHelpers.FillA4R4G4B4(rs.SourceData, rgba.AsSpan(), rs.Width, rs.Height);
|
||||
return new DecodedTexture(rgba, rs.Width, rs.Height);
|
||||
}
|
||||
|
||||
private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format, bool isClipMap)
|
||||
{
|
||||
var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format);
|
||||
|
|
|
|||
|
|
@ -55,4 +55,27 @@ public sealed class WorldEntity
|
|||
/// visible trunk, producing "partial passthrough" bugs.
|
||||
/// </summary>
|
||||
public float Scale { get; init; } = 1.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Server-sent part-swap overrides from <c>AnimPartChange</c>. Each entry
|
||||
/// replaces a Setup part's GfxObj with an alternate model (clothing, weapons,
|
||||
/// helmets). Carried on the entity so <c>EntitySpawnAdapter</c> can populate
|
||||
/// <c>AnimatedEntityState</c>'s override map at spawn time. Empty for atlas-
|
||||
/// tier entities.
|
||||
/// </summary>
|
||||
public IReadOnlyList<PartOverride> PartOverrides { get; init; } = Array.Empty<PartOverride>();
|
||||
|
||||
/// <summary>
|
||||
/// Bitmask of hidden Setup parts. Bit <c>i</c> set hides part <c>i</c> at
|
||||
/// draw time. Sourced from the server's <c>CreateObject</c> record when
|
||||
/// present. Zero (no parts hidden) is the default.
|
||||
/// </summary>
|
||||
public ulong HiddenPartsMask { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight value type for a server-sent <c>AnimPartChange</c> (part index
|
||||
/// → replacement GfxObj id). Decouples <c>WorldEntity</c> (Core) from the
|
||||
/// network-layer <c>CreateObject.AnimPartChange</c> type.
|
||||
/// </summary>
|
||||
public readonly record struct PartOverride(byte PartIndex, uint GfxObjId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Meshing;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class AcSurfaceMetadataTableTests
|
||||
{
|
||||
[Fact]
|
||||
public void Add_ThenLookup_RoundTripsSameMetadata()
|
||||
{
|
||||
var table = new AcSurfaceMetadataTable();
|
||||
var meta = new AcSurfaceMetadata(
|
||||
Translucency: TranslucencyKind.AlphaBlend,
|
||||
Luminosity: 0.5f,
|
||||
Diffuse: 0.8f,
|
||||
SurfOpacity: 0.7f,
|
||||
NeedsUvRepeat: true,
|
||||
DisableFog: false);
|
||||
|
||||
table.Add(gfxObjId: 0x01000123ul, surfaceIdx: 2, meta);
|
||||
|
||||
Assert.True(table.TryLookup(0x01000123ul, 2, out var got));
|
||||
Assert.Equal(meta, got);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lookup_MissingKey_ReturnsFalse()
|
||||
{
|
||||
var table = new AcSurfaceMetadataTable();
|
||||
Assert.False(table.TryLookup(0xDEADBEEFul, 0, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_OverwritesPreviousMetadata()
|
||||
{
|
||||
var table = new AcSurfaceMetadataTable();
|
||||
var first = new AcSurfaceMetadata(TranslucencyKind.Opaque, 0f, 1f, 1f, false, false);
|
||||
var second = new AcSurfaceMetadata(TranslucencyKind.Additive, 1f, 1f, 1f, false, true);
|
||||
|
||||
table.Add(0xAAAA, 0, first);
|
||||
table.Add(0xAAAA, 0, second);
|
||||
|
||||
Assert.True(table.TryLookup(0xAAAA, 0, out var got));
|
||||
Assert.Equal(second, got);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_FromMultipleThreads_IsThreadSafe()
|
||||
{
|
||||
var table = new AcSurfaceMetadataTable();
|
||||
var threads = new System.Threading.Tasks.Task[8];
|
||||
for (int t = 0; t < 8; t++)
|
||||
{
|
||||
int threadIdx = t;
|
||||
threads[t] = System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
ulong key = (ulong)(threadIdx * 1000 + i);
|
||||
table.Add(key, 0, new AcSurfaceMetadata(
|
||||
TranslucencyKind.Opaque, 0f, 1f, 1f, false, false));
|
||||
}
|
||||
});
|
||||
}
|
||||
System.Threading.Tasks.Task.WaitAll(threads);
|
||||
|
||||
// 8000 entries should be present.
|
||||
for (int t = 0; t < 8; t++)
|
||||
for (int i = 0; i < 1000; i++)
|
||||
Assert.True(table.TryLookup((ulong)(t * 1000 + i), 0, out _));
|
||||
}
|
||||
}
|
||||
62
tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
Normal file
62
tests/AcDream.Core.Tests/Rendering/Wb/AnimPartChangeTests.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class AnimPartChangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetPartOverride_ResolvedAtLookup()
|
||||
{
|
||||
var state = MakeState();
|
||||
|
||||
state.SetPartOverride(partIdx: 5, gfxObjId: 0x01001234ul);
|
||||
|
||||
Assert.True(state.TryGetPartOverride(5, out var got));
|
||||
Assert.Equal(0x01001234ul, got);
|
||||
Assert.False(state.TryGetPartOverride(6, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPartOverride_TwiceForSamePart_TakesLatest()
|
||||
{
|
||||
var state = MakeState();
|
||||
|
||||
state.SetPartOverride(0, 0x01000001ul);
|
||||
state.SetPartOverride(0, 0x01999999ul);
|
||||
|
||||
Assert.True(state.TryGetPartOverride(0, out var got));
|
||||
Assert.Equal(0x01999999ul, got);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePartGfxObj_WithoutOverride_ReturnsSetupDefault()
|
||||
{
|
||||
var state = MakeState();
|
||||
|
||||
Assert.Equal(0x01000001ul,
|
||||
state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePartGfxObj_WithOverride_ReturnsOverride()
|
||||
{
|
||||
var state = MakeState();
|
||||
state.SetPartOverride(partIdx: 0, gfxObjId: 0x01999999ul);
|
||||
|
||||
Assert.Equal(0x01999999ul,
|
||||
state.ResolvePartGfxObj(partIdx: 0, setupDefault: 0x01000001ul));
|
||||
}
|
||||
|
||||
private static AnimatedEntityState MakeState() => new(MakeSequencer());
|
||||
|
||||
private static AnimationSequencer MakeSequencer()
|
||||
=> new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class AnimatedEntityStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultState_HasNoOverridesAndNoHiddenParts()
|
||||
{
|
||||
var state = MakeState();
|
||||
|
||||
Assert.False(state.IsPartHidden(0));
|
||||
Assert.False(state.IsPartHidden(63));
|
||||
Assert.False(state.TryGetPartOverride(0, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sequencer_AccessibleAsProperty()
|
||||
{
|
||||
var sequencer = MakeSequencer();
|
||||
var state = new AnimatedEntityState(sequencer);
|
||||
|
||||
Assert.Same(sequencer, state.Sequencer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Construct_WithNullSequencer_ThrowsArgumentNull()
|
||||
{
|
||||
Assert.Throws<System.ArgumentNullException>(
|
||||
() => new AnimatedEntityState(null!));
|
||||
}
|
||||
|
||||
private static AnimatedEntityState MakeState() => new(MakeSequencer());
|
||||
|
||||
private static AnimationSequencer MakeSequencer()
|
||||
=> new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
256
tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs
Normal file
256
tests/AcDream.Core.Tests/Rendering/Wb/EntitySpawnAdapterTests.cs
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class EntitySpawnAdapterTests
|
||||
{
|
||||
// ── Happy-path: server-spawned entity ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_ServerSpawnedEntity_RegistersAnimatedEntityState()
|
||||
{
|
||||
var cache = new CapturingTextureCache();
|
||||
var adapter = MakeAdapter(cache);
|
||||
var entity = MakeEntity(id: 1, serverGuid: 0xDEAD0001u);
|
||||
|
||||
var state = adapter.OnCreate(entity);
|
||||
|
||||
Assert.NotNull(state);
|
||||
Assert.Same(state, adapter.GetState(0xDEAD0001u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_ServerSpawnedEntity_SequencerIsNotNull()
|
||||
{
|
||||
var adapter = MakeAdapter();
|
||||
var entity = MakeEntity(id: 1, serverGuid: 0xDEAD0002u);
|
||||
|
||||
var state = adapter.OnCreate(entity);
|
||||
|
||||
Assert.NotNull(state!.Sequencer);
|
||||
}
|
||||
|
||||
// ── Atlas-tier filter ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_ProceduralEntity_ReturnsNullAndRegistersNothing()
|
||||
{
|
||||
var cache = new CapturingTextureCache();
|
||||
var adapter = MakeAdapter(cache);
|
||||
// ServerGuid == 0 → atlas-tier, must not be processed here.
|
||||
var entity = MakeEntity(id: 2, serverGuid: 0u);
|
||||
|
||||
var state = adapter.OnCreate(entity);
|
||||
|
||||
Assert.Null(state);
|
||||
Assert.Null(adapter.GetState(0u));
|
||||
// No texture decode should have been triggered.
|
||||
Assert.Empty(cache.Calls);
|
||||
}
|
||||
|
||||
// ── Palette-override texture decode ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithPaletteOverrideAndSurfaceOverrides_TriggersTextureCacheDecode()
|
||||
{
|
||||
var cache = new CapturingTextureCache();
|
||||
var adapter = MakeAdapter(cache);
|
||||
|
||||
var palette = new PaletteOverride(
|
||||
BasePaletteId: 0x04001234u,
|
||||
SubPalettes: new[]
|
||||
{
|
||||
new PaletteOverride.SubPaletteRange(0x04002000u, 0, 2),
|
||||
});
|
||||
|
||||
// Entity carries two parts each with one surface override.
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 10,
|
||||
ServerGuid = 0xBEEF0001u,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
PaletteOverride = palette,
|
||||
MeshRefs = new[]
|
||||
{
|
||||
new MeshRef(0x01000010u, Matrix4x4.Identity)
|
||||
{
|
||||
SurfaceOverrides = new Dictionary<uint, uint>
|
||||
{
|
||||
{ 0x08000100u, 0u }, // surfaceId → origTex (0 = none)
|
||||
},
|
||||
},
|
||||
new MeshRef(0x01000020u, Matrix4x4.Identity)
|
||||
{
|
||||
SurfaceOverrides = new Dictionary<uint, uint>
|
||||
{
|
||||
{ 0x08000200u, 0x05000300u }, // with origTex override
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
adapter.OnCreate(entity);
|
||||
|
||||
// One call per surface-with-override: (0x08000100, null) and (0x08000200, 0x05000300).
|
||||
Assert.Equal(2, cache.Calls.Count);
|
||||
|
||||
Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000100u
|
||||
&& c.OrigTexOverride == null
|
||||
&& c.Palette == palette);
|
||||
Assert.Contains(cache.Calls, c => c.SurfaceId == 0x08000200u
|
||||
&& c.OrigTexOverride == 0x05000300u
|
||||
&& c.Palette == palette);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithPaletteOverrideButNoSurfaceOverrides_DoesNotCallCache()
|
||||
{
|
||||
// Surfaces without SurfaceOverrides == null are decoded lazily at draw
|
||||
// time; the adapter only pre-warms what it knows at spawn time.
|
||||
var cache = new CapturingTextureCache();
|
||||
var adapter = MakeAdapter(cache);
|
||||
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 11,
|
||||
ServerGuid = 0xBEEF0002u,
|
||||
SourceGfxObjOrSetupId = 0x02000002u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
PaletteOverride = new PaletteOverride(0x04001235u, Array.Empty<PaletteOverride.SubPaletteRange>()),
|
||||
// MeshRef with NO SurfaceOverrides.
|
||||
MeshRefs = new[] { new MeshRef(0x01000011u, Matrix4x4.Identity) },
|
||||
};
|
||||
|
||||
adapter.OnCreate(entity);
|
||||
|
||||
Assert.Empty(cache.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_WithoutPaletteOverride_DoesNotCallCache()
|
||||
{
|
||||
var cache = new CapturingTextureCache();
|
||||
var adapter = MakeAdapter(cache);
|
||||
var entity = MakeEntity(id: 12, serverGuid: 0xBEEF0003u);
|
||||
|
||||
adapter.OnCreate(entity);
|
||||
|
||||
Assert.Empty(cache.Calls);
|
||||
}
|
||||
|
||||
// ── OnRemove ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_ReleasesPerEntityState()
|
||||
{
|
||||
var adapter = MakeAdapter();
|
||||
var entity = MakeEntity(id: 20, serverGuid: 0xCAFE0001u);
|
||||
|
||||
adapter.OnCreate(entity);
|
||||
Assert.NotNull(adapter.GetState(0xCAFE0001u));
|
||||
|
||||
adapter.OnRemove(0xCAFE0001u);
|
||||
Assert.Null(adapter.GetState(0xCAFE0001u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_UnknownGuid_NoOps()
|
||||
{
|
||||
var adapter = MakeAdapter();
|
||||
|
||||
// Must not throw.
|
||||
adapter.OnRemove(0xDEADBEEFu);
|
||||
}
|
||||
|
||||
// ── Multiple entities ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void OnCreate_MultipleEntities_EachGetsOwnState()
|
||||
{
|
||||
var adapter = MakeAdapter();
|
||||
var e1 = MakeEntity(id: 30, serverGuid: 0x11110001u);
|
||||
var e2 = MakeEntity(id: 31, serverGuid: 0x11110002u);
|
||||
|
||||
var s1 = adapter.OnCreate(e1);
|
||||
var s2 = adapter.OnCreate(e2);
|
||||
|
||||
Assert.NotNull(s1);
|
||||
Assert.NotNull(s2);
|
||||
Assert.NotSame(s1, s2);
|
||||
Assert.Same(s1, adapter.GetState(0x11110001u));
|
||||
Assert.Same(s2, adapter.GetState(0x11110002u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnRemove_OnlyReleasesTargetGuid()
|
||||
{
|
||||
var adapter = MakeAdapter();
|
||||
var e1 = MakeEntity(id: 40, serverGuid: 0x22220001u);
|
||||
var e2 = MakeEntity(id: 41, serverGuid: 0x22220002u);
|
||||
|
||||
adapter.OnCreate(e1);
|
||||
adapter.OnCreate(e2);
|
||||
adapter.OnRemove(0x22220001u);
|
||||
|
||||
Assert.Null(adapter.GetState(0x22220001u));
|
||||
Assert.NotNull(adapter.GetState(0x22220002u));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static EntitySpawnAdapter MakeAdapter(ITextureCachePerInstance? cache = null)
|
||||
{
|
||||
cache ??= new CapturingTextureCache();
|
||||
return new EntitySpawnAdapter(cache, _ => MakeSequencer());
|
||||
}
|
||||
|
||||
private static WorldEntity MakeEntity(uint id, uint serverGuid)
|
||||
=> new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) },
|
||||
};
|
||||
|
||||
private static AnimationSequencer MakeSequencer()
|
||||
=> new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
|
||||
|
||||
// ── Mocks / stubs ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Capture every call to GetOrUploadWithPaletteOverride so tests can
|
||||
/// assert without a live GL context.
|
||||
/// </summary>
|
||||
private sealed class CapturingTextureCache : ITextureCachePerInstance
|
||||
{
|
||||
public readonly record struct Call(uint SurfaceId, uint? OrigTexOverride, PaletteOverride Palette);
|
||||
public List<Call> Calls { get; } = new();
|
||||
|
||||
public uint GetOrUploadWithPaletteOverride(
|
||||
uint surfaceId,
|
||||
uint? overrideOrigTextureId,
|
||||
PaletteOverride paletteOverride)
|
||||
{
|
||||
Calls.Add(new Call(surfaceId, overrideOrigTextureId, paletteOverride));
|
||||
return 1u; // Fake GL handle.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
56
tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
Normal file
56
tests/AcDream.Core.Tests/Rendering/Wb/HiddenPartsTests.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class HiddenPartsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0b0000_0000ul, 0, false)]
|
||||
[InlineData(0b0000_0001ul, 0, true)]
|
||||
[InlineData(0b1000_0000ul, 7, true)]
|
||||
[InlineData(0b1000_0000ul, 6, false)]
|
||||
[InlineData(0xFFFF_FFFF_FFFF_FFFFul, 63, true)]
|
||||
public void IsPartHidden_RespectsBitmaskBit(ulong mask, int partIdx, bool expected)
|
||||
{
|
||||
var state = MakeState();
|
||||
state.HideParts(mask);
|
||||
Assert.Equal(expected, state.IsPartHidden(partIdx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPartHidden_NegativeIdx_ReturnsFalse()
|
||||
{
|
||||
var state = MakeState();
|
||||
state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
|
||||
Assert.False(state.IsPartHidden(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPartHidden_PartIdxOver64_ReturnsFalse()
|
||||
{
|
||||
var state = MakeState();
|
||||
state.HideParts(0xFFFF_FFFF_FFFF_FFFFul);
|
||||
Assert.False(state.IsPartHidden(64));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HideParts_DefaultsToNoneHidden()
|
||||
{
|
||||
var state = MakeState();
|
||||
for (int i = 0; i < 64; i++)
|
||||
Assert.False(state.IsPartHidden(i));
|
||||
}
|
||||
|
||||
private static AnimatedEntityState MakeState() => new(MakeSequencer());
|
||||
|
||||
private static AnimationSequencer MakeSequencer()
|
||||
=> new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader());
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class LandblockSpawnAdapterTests
|
||||
{
|
||||
[Fact]
|
||||
public void OnLandblockLoaded_RegistersIncrementForEachUniqueAtlasGfxObj()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
// Two procedural (ServerGuid=0) entities with different GfxObj ids.
|
||||
var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
|
||||
{
|
||||
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
|
||||
MakeAtlasEntity(id: 2, gfxObjIds: new[] { 0x01000030u }),
|
||||
});
|
||||
|
||||
adapter.OnLandblockLoaded(lb);
|
||||
|
||||
// Three unique ids registered.
|
||||
Assert.Equal(3, captured.IncrementCalls.Count);
|
||||
Assert.Contains(0x01000010ul, captured.IncrementCalls);
|
||||
Assert.Contains(0x01000020ul, captured.IncrementCalls);
|
||||
Assert.Contains(0x01000030ul, captured.IncrementCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnLandblockLoaded_DedupsSharedIdsAcrossEntities()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
|
||||
{
|
||||
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
|
||||
MakeAtlasEntity(id: 2, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
|
||||
});
|
||||
|
||||
adapter.OnLandblockLoaded(lb);
|
||||
|
||||
// Two unique ids despite two entities sharing both.
|
||||
Assert.Equal(2, captured.IncrementCalls.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnLandblockLoaded_SkipsServerSpawnedEntities()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
|
||||
{
|
||||
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u }),
|
||||
// ServerGuid != 0 → per-instance tier → must NOT register.
|
||||
MakePerInstanceEntity(id: 2, serverGuid: 0xCAFE0001u, gfxObjIds: new[] { 0x01000020u }),
|
||||
});
|
||||
|
||||
adapter.OnLandblockLoaded(lb);
|
||||
|
||||
// Only the atlas-tier entity's GfxObj is registered.
|
||||
Assert.Single(captured.IncrementCalls);
|
||||
Assert.Contains(0x01000010ul, captured.IncrementCalls);
|
||||
Assert.DoesNotContain(0x01000020ul, captured.IncrementCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnLandblockUnloaded_RegistersMatchingDecrements()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
|
||||
{
|
||||
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u, 0x01000020u }),
|
||||
});
|
||||
|
||||
adapter.OnLandblockLoaded(lb);
|
||||
adapter.OnLandblockUnloaded(0x12340000u);
|
||||
|
||||
Assert.Equal(captured.IncrementCalls.OrderBy(x => x), captured.DecrementCalls.OrderBy(x => x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnLandblockUnloaded_UnknownLandblock_NoOps()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
adapter.OnLandblockUnloaded(0xDEADBEEFu);
|
||||
|
||||
Assert.Empty(captured.DecrementCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnLandblockLoaded_SameLandblockTwice_DedupesAtTheLandblockLevel()
|
||||
{
|
||||
// If a landblock load fires twice (e.g. a streaming-controller bug),
|
||||
// we should not double-register. Second load is treated as a no-op
|
||||
// for ref-counting purposes.
|
||||
var captured = new CapturingAdapterMock();
|
||||
var adapter = new LandblockSpawnAdapter(captured);
|
||||
|
||||
var lb = MakeLandblock(landblockId: 0x12340000u, entities: new[]
|
||||
{
|
||||
MakeAtlasEntity(id: 1, gfxObjIds: new[] { 0x01000010u }),
|
||||
});
|
||||
|
||||
adapter.OnLandblockLoaded(lb);
|
||||
adapter.OnLandblockLoaded(lb);
|
||||
|
||||
// One unique id, one increment — not two.
|
||||
Assert.Single(captured.IncrementCalls);
|
||||
}
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private sealed class CapturingAdapterMock : IWbMeshAdapter
|
||||
{
|
||||
public List<ulong> IncrementCalls { get; } = new();
|
||||
public List<ulong> DecrementCalls { get; } = new();
|
||||
public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
|
||||
public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
|
||||
}
|
||||
|
||||
private static LoadedLandblock MakeLandblock(uint landblockId, WorldEntity[] entities)
|
||||
=> new LoadedLandblock(
|
||||
LandblockId: landblockId,
|
||||
Heightmap: new DatReaderWriter.DBObjs.LandBlock(), // empty default
|
||||
Entities: entities);
|
||||
|
||||
private static WorldEntity MakeAtlasEntity(uint id, uint[] gfxObjIds)
|
||||
=> MakeEntity(id, serverGuid: 0u, gfxObjIds);
|
||||
|
||||
private static WorldEntity MakePerInstanceEntity(uint id, uint serverGuid, uint[] gfxObjIds)
|
||||
=> MakeEntity(id, serverGuid, gfxObjIds);
|
||||
|
||||
private static WorldEntity MakeEntity(uint id, uint serverGuid, uint[] gfxObjIds)
|
||||
{
|
||||
var meshRefs = gfxObjIds
|
||||
.Select(g => new MeshRef(g, Matrix4x4.Identity))
|
||||
.ToList();
|
||||
return new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = gfxObjIds.FirstOrDefault(),
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = meshRefs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class MatrixCompositionTests
|
||||
{
|
||||
[Fact]
|
||||
public void Compose_EntityAnimRest_ProducesExpectedWorldMatrix()
|
||||
{
|
||||
var entityWorld = Matrix4x4.CreateTranslation(100, 200, 300);
|
||||
var animOverride = Matrix4x4.CreateRotationZ(MathF.PI / 4);
|
||||
var restPose = Matrix4x4.CreateTranslation(1, 0, 0);
|
||||
|
||||
var result = WbDrawDispatcher.ComposePartWorldMatrix(entityWorld, animOverride, restPose);
|
||||
|
||||
var expected = restPose * animOverride * entityWorld;
|
||||
AssertMatrixEqual(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_IdentityAnim_EqualsRestTimesEntity()
|
||||
{
|
||||
var entityWorld = Matrix4x4.CreateFromQuaternion(
|
||||
Quaternion.CreateFromYawPitchRoll(0.5f, 0, 0)) *
|
||||
Matrix4x4.CreateTranslation(10, 20, 30);
|
||||
var restPose = Matrix4x4.CreateTranslation(0.5f, -0.3f, 0.1f);
|
||||
|
||||
var result = WbDrawDispatcher.ComposePartWorldMatrix(
|
||||
entityWorld, Matrix4x4.Identity, restPose);
|
||||
|
||||
var expected = restPose * entityWorld;
|
||||
AssertMatrixEqual(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_AllIdentity_ReturnsIdentity()
|
||||
{
|
||||
var result = WbDrawDispatcher.ComposePartWorldMatrix(
|
||||
Matrix4x4.Identity, Matrix4x4.Identity, Matrix4x4.Identity);
|
||||
|
||||
AssertMatrixEqual(Matrix4x4.Identity, result);
|
||||
}
|
||||
|
||||
private static void AssertMatrixEqual(Matrix4x4 expected, Matrix4x4 actual, float eps = 1e-5f)
|
||||
{
|
||||
Assert.Equal(expected.M11, actual.M11, eps);
|
||||
Assert.Equal(expected.M12, actual.M12, eps);
|
||||
Assert.Equal(expected.M13, actual.M13, eps);
|
||||
Assert.Equal(expected.M14, actual.M14, eps);
|
||||
Assert.Equal(expected.M21, actual.M21, eps);
|
||||
Assert.Equal(expected.M22, actual.M22, eps);
|
||||
Assert.Equal(expected.M23, actual.M23, eps);
|
||||
Assert.Equal(expected.M24, actual.M24, eps);
|
||||
Assert.Equal(expected.M31, actual.M31, eps);
|
||||
Assert.Equal(expected.M32, actual.M32, eps);
|
||||
Assert.Equal(expected.M33, actual.M33, eps);
|
||||
Assert.Equal(expected.M34, actual.M34, eps);
|
||||
Assert.Equal(expected.M41, actual.M41, eps);
|
||||
Assert.Equal(expected.M42, actual.M42, eps);
|
||||
Assert.Equal(expected.M43, actual.M43, eps);
|
||||
Assert.Equal(expected.M44, actual.M44, eps);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Lib;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance: our <see cref="GfxObjMesh.Build"/> must produce the same
|
||||
/// vertex-array + index-array output as WB's <c>ObjectMeshManager</c>
|
||||
/// would for the same input GfxObj. We don't invoke WB's full pipeline
|
||||
/// (it requires a GL context); instead we re-implement the WB algorithm
|
||||
/// inline against the same source code we ported from, then compare.
|
||||
///
|
||||
/// <para>
|
||||
/// If this test fails, either our port has drifted or the WB code has
|
||||
/// changed upstream — investigate which, do not "fix" the test.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MeshExtractionConformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_QuadGfxObj_ProducesExpectedVerticesAndIndices()
|
||||
{
|
||||
var gfxObj = MakeUnitQuadGfxObj();
|
||||
|
||||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||||
|
||||
Assert.Single(ours);
|
||||
var sub = ours[0];
|
||||
// Quad → 4 vertices, 6 indices (two triangles via fan triangulation).
|
||||
Assert.Equal(4, sub.Vertices.Length);
|
||||
Assert.Equal(6, sub.Indices.Length);
|
||||
// Fan from vertex 0: (0,1,2) and (0,2,3).
|
||||
Assert.Equal(new uint[] { 0, 1, 2, 0, 2, 3 }, sub.Indices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DoubleSidedPoly_ProducesBothPosAndNegSubmeshes()
|
||||
{
|
||||
var gfxObj = MakeUnitQuadGfxObj();
|
||||
var poly = gfxObj.Polygons[0];
|
||||
poly.Stippling = StipplingType.Both;
|
||||
// NegSurface=0 so the neg side references a valid surface entry.
|
||||
poly.NegSurface = 0;
|
||||
|
||||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||||
|
||||
Assert.Equal(2, ours.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NoNegFlag_WithClockwiseSidesType_StillEmitsNegSide()
|
||||
{
|
||||
var gfxObj = MakeUnitQuadGfxObj();
|
||||
var poly = gfxObj.Polygons[0];
|
||||
poly.Stippling = StipplingType.None;
|
||||
poly.SidesType = CullMode.Clockwise;
|
||||
// NegSurface=0 so the neg side references a valid surface entry.
|
||||
poly.NegSurface = 0;
|
||||
|
||||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||||
|
||||
Assert.Equal(2, ours.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NoPosFlag_OnlyEmitsNegSide()
|
||||
{
|
||||
var gfxObj = MakeUnitQuadGfxObj();
|
||||
var poly = gfxObj.Polygons[0];
|
||||
poly.Stippling = StipplingType.NoPos | StipplingType.Negative;
|
||||
// NegSurface=0 so the neg side references a valid surface entry.
|
||||
poly.NegSurface = 0;
|
||||
|
||||
var ours = GfxObjMesh.Build(gfxObj, dats: null);
|
||||
|
||||
Assert.Single(ours);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a synthetic 1×1 quad GfxObj with vertex sequence [0,1,2,3]
|
||||
/// at corners (0,0,0)/(1,0,0)/(1,1,0)/(0,1,0). PosSurface=0,
|
||||
/// NegSurface=-1 (invalid — pos side only by default).
|
||||
/// No Stippling flags set by default — caller may add them per test.
|
||||
/// </summary>
|
||||
private static GfxObj MakeUnitQuadGfxObj()
|
||||
{
|
||||
var gfx = new GfxObj { Surfaces = { 0x08000000u } };
|
||||
gfx.VertexArray = new VertexArray
|
||||
{
|
||||
VertexType = VertexType.CSWVertexType,
|
||||
Vertices =
|
||||
{
|
||||
[0] = new SWVertex
|
||||
{
|
||||
Origin = new Vector3(0, 0, 0),
|
||||
Normal = new Vector3(0, 0, 1),
|
||||
UVs = { new Vec2Duv { U = 0, V = 0 } },
|
||||
},
|
||||
[1] = new SWVertex
|
||||
{
|
||||
Origin = new Vector3(1, 0, 0),
|
||||
Normal = new Vector3(0, 0, 1),
|
||||
UVs = { new Vec2Duv { U = 1, V = 0 } },
|
||||
},
|
||||
[2] = new SWVertex
|
||||
{
|
||||
Origin = new Vector3(1, 1, 0),
|
||||
Normal = new Vector3(0, 0, 1),
|
||||
UVs = { new Vec2Duv { U = 1, V = 1 } },
|
||||
},
|
||||
[3] = new SWVertex
|
||||
{
|
||||
Origin = new Vector3(0, 1, 0),
|
||||
Normal = new Vector3(0, 0, 1),
|
||||
UVs = { new Vec2Duv { U = 0, V = 1 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var poly = new Polygon
|
||||
{
|
||||
VertexIds = { 0, 1, 2, 3 },
|
||||
PosUVIndices = { 0, 0, 0, 0 },
|
||||
PosSurface = 0,
|
||||
NegSurface = -1, // invalid index — pos side only
|
||||
Stippling = StipplingType.None,
|
||||
SidesType = CullMode.None,
|
||||
};
|
||||
gfx.Polygons[0] = poly;
|
||||
return gfx;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Integration: verifies the pending-spawn list mechanism keeps working
|
||||
/// after Task 12 wired LandblockSpawnAdapter into GpuWorldState. Server-
|
||||
/// spawned entities (ServerGuid != 0) park in pending → drain on
|
||||
/// AddLandblock → end up in the flat view, but they are NEVER registered
|
||||
/// with the WB adapter (they're per-instance tier).
|
||||
///
|
||||
/// The adapter SHOULD see atlas-tier entities (ServerGuid == 0) that
|
||||
/// arrived in the AddLandblock's payload directly.
|
||||
/// </summary>
|
||||
public sealed class PendingSpawnIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Force-enable WbFoundationFlag for this test class.
|
||||
/// GpuWorldState gates its adapter calls on this static-cached flag;
|
||||
/// calling the internal test hook lets us exercise the full integration
|
||||
/// path without needing the env var set before process startup.
|
||||
/// </summary>
|
||||
static PendingSpawnIntegrationTests()
|
||||
{
|
||||
WbFoundationFlag.ForTestsOnly_ForceEnable();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveEntity_ParkedBeforeLandblock_DrainsButIsNotRegisteredWithAdapter()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var spawnAdapter = new LandblockSpawnAdapter(captured);
|
||||
var state = new GpuWorldState(spawnAdapter);
|
||||
|
||||
// Park a live (server-spawned) entity for landblock 0x1234FFFF BEFORE
|
||||
// the landblock streams in. ServerGuid != 0 makes this per-instance-tier.
|
||||
var liveEntity = MakeServerSpawned(
|
||||
id: 1, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
|
||||
// AppendLiveEntity takes the raw cell-form id; it canonicalises internally.
|
||||
state.AppendLiveEntity(0x12340011u, liveEntity);
|
||||
|
||||
Assert.Equal(1, state.PendingLiveEntityCount);
|
||||
Assert.Empty(captured.IncrementCalls); // not registered yet — landblock not loaded
|
||||
|
||||
// Now landblock arrives with ONE atlas-tier entity that brings its own
|
||||
// GfxObj, plus the pending live entity drains into it.
|
||||
var atlasEntity = MakeAtlas(id: 2, gfxObjId: 0x01000010u);
|
||||
var lb = new LoadedLandblock(
|
||||
LandblockId: 0x1234FFFFu,
|
||||
Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
|
||||
Entities: new[] { atlasEntity });
|
||||
state.AddLandblock(lb);
|
||||
|
||||
// Pending drained.
|
||||
Assert.Equal(0, state.PendingLiveEntityCount);
|
||||
|
||||
// Flat view contains both: the atlas one from the load + the drained pending.
|
||||
var allIds = state.Entities.Select(e => e.Id).ToHashSet();
|
||||
Assert.Contains(1u, allIds); // pending entity
|
||||
Assert.Contains(2u, allIds); // landblock entity
|
||||
|
||||
// Adapter only saw the atlas-tier GfxObj. The pending server-spawned
|
||||
// entity's GfxObj is NOT registered (filtered by ServerGuid != 0 in
|
||||
// LandblockSpawnAdapter).
|
||||
Assert.Single(captured.IncrementCalls);
|
||||
Assert.Contains(0x01000010ul, captured.IncrementCalls);
|
||||
Assert.DoesNotContain(0x01000099ul, captured.IncrementCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LiveEntity_AfterLandblock_RegistersImmediatelyWithoutAdapterCall()
|
||||
{
|
||||
// When a CreateObject arrives for an already-loaded landblock, it goes
|
||||
// straight into the flat view (not through pending). Adapter is NOT
|
||||
// re-invoked because the landblock load already happened.
|
||||
var captured = new CapturingAdapterMock();
|
||||
var spawnAdapter = new LandblockSpawnAdapter(captured);
|
||||
var state = new GpuWorldState(spawnAdapter);
|
||||
|
||||
var atlasEntity = MakeAtlas(id: 1, gfxObjId: 0x01000010u);
|
||||
var lb = new LoadedLandblock(
|
||||
LandblockId: 0x1234FFFFu,
|
||||
Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
|
||||
Entities: new[] { atlasEntity });
|
||||
state.AddLandblock(lb);
|
||||
|
||||
Assert.Single(captured.IncrementCalls); // atlas registered
|
||||
|
||||
// Now a live entity arrives — landblock is already loaded.
|
||||
var liveEntity = MakeServerSpawned(id: 2, serverGuid: 0xCAFE0001u, gfxObjId: 0x01000099u);
|
||||
state.AppendLiveEntity(0x12340022u, liveEntity);
|
||||
|
||||
// Adapter not invoked again — AppendLiveEntity doesn't drive ref counts.
|
||||
Assert.Single(captured.IncrementCalls);
|
||||
Assert.Equal(0, state.PendingLiveEntityCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LandblockUnload_ReleasesAtlasIds_PendingDoesNotRegress()
|
||||
{
|
||||
var captured = new CapturingAdapterMock();
|
||||
var spawnAdapter = new LandblockSpawnAdapter(captured);
|
||||
var state = new GpuWorldState(spawnAdapter);
|
||||
|
||||
var atlasEntity = MakeAtlas(id: 1, gfxObjId: 0x01000010u);
|
||||
var lb = new LoadedLandblock(
|
||||
LandblockId: 0x1234FFFFu,
|
||||
Heightmap: new DatReaderWriter.DBObjs.LandBlock(),
|
||||
Entities: new[] { atlasEntity });
|
||||
state.AddLandblock(lb);
|
||||
state.RemoveLandblock(0x1234FFFFu);
|
||||
|
||||
Assert.Equal(
|
||||
captured.IncrementCalls.OrderBy(x => x),
|
||||
captured.DecrementCalls.OrderBy(x => x));
|
||||
}
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private sealed class CapturingAdapterMock : IWbMeshAdapter
|
||||
{
|
||||
public List<ulong> IncrementCalls { get; } = new();
|
||||
public List<ulong> DecrementCalls { get; } = new();
|
||||
public void IncrementRefCount(ulong id) => IncrementCalls.Add(id);
|
||||
public void DecrementRefCount(ulong id) => DecrementCalls.Add(id);
|
||||
}
|
||||
|
||||
private static WorldEntity MakeAtlas(uint id, uint gfxObjId)
|
||||
=> MakeEntity(id, serverGuid: 0u, gfxObjId);
|
||||
|
||||
private static WorldEntity MakeServerSpawned(uint id, uint serverGuid, uint gfxObjId)
|
||||
=> MakeEntity(id, serverGuid, gfxObjId);
|
||||
|
||||
private static WorldEntity MakeEntity(uint id, uint serverGuid, uint gfxObjId)
|
||||
=> new WorldEntity
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = gfxObjId,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = new[] { new MeshRef(gfxObjId, Matrix4x4.Identity) },
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance: our <see cref="SetupMesh.Flatten"/> must produce the same
|
||||
/// (GfxObjId, Matrix4x4) sequence as WB's setup-parts walk for representative
|
||||
/// Setups. Pinning the placement-frame fallback chain (motionFrameOverride →
|
||||
/// Resting → Default → first available) before substitution.
|
||||
/// </summary>
|
||||
public sealed class SetupFlattenConformanceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Flatten_NoFrames_FallsBackToIdentity()
|
||||
{
|
||||
var setup = new Setup { Parts = { 0x01000001u } };
|
||||
// PlacementFrames deliberately empty — no DefaultScale entry either,
|
||||
// so scale defaults to Vector3.One and the fallback frame is
|
||||
// (Origin=Zero, Orientation=Identity) → Identity matrix.
|
||||
|
||||
var refs = SetupMesh.Flatten(setup);
|
||||
|
||||
Assert.Single(refs);
|
||||
Assert.Equal(0x01000001u, refs[0].GfxObjId);
|
||||
Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_WithDefaultFrame_AppliesFrameOriginAndOrientation()
|
||||
{
|
||||
var setup = new Setup { Parts = { 0x01000001u } };
|
||||
setup.PlacementFrames[Placement.Default] = new AnimationFrame(1)
|
||||
{
|
||||
Frames =
|
||||
{
|
||||
new Frame
|
||||
{
|
||||
Origin = new Vector3(10, 20, 30),
|
||||
Orientation = Quaternion.Identity,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var refs = SetupMesh.Flatten(setup);
|
||||
|
||||
Assert.Equal(new Vector3(10, 20, 30), refs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_WithRestingFrame_PrefersRestingOverDefault()
|
||||
{
|
||||
var setup = new Setup { Parts = { 0x01000001u } };
|
||||
setup.PlacementFrames[Placement.Default] = new AnimationFrame(1)
|
||||
{
|
||||
Frames = { new Frame { Origin = new Vector3(10, 20, 30), Orientation = Quaternion.Identity } },
|
||||
};
|
||||
setup.PlacementFrames[Placement.Resting] = new AnimationFrame(1)
|
||||
{
|
||||
Frames = { new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity } },
|
||||
};
|
||||
|
||||
var refs = SetupMesh.Flatten(setup);
|
||||
|
||||
Assert.Equal(new Vector3(99, 99, 99), refs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_WithMotionFrameOverride_PrefersOverrideOverResting()
|
||||
{
|
||||
var setup = new Setup { Parts = { 0x01000001u } };
|
||||
setup.PlacementFrames[Placement.Resting] = new AnimationFrame(1)
|
||||
{
|
||||
Frames = { new Frame { Origin = new Vector3(99, 99, 99), Orientation = Quaternion.Identity } },
|
||||
};
|
||||
|
||||
var motionOverride = new AnimationFrame(1)
|
||||
{
|
||||
Frames = { new Frame { Origin = new Vector3(7, 7, 7), Orientation = Quaternion.Identity } },
|
||||
};
|
||||
|
||||
var refs = SetupMesh.Flatten(setup, motionFrameOverride: motionOverride);
|
||||
|
||||
Assert.Equal(new Vector3(7, 7, 7), refs[0].PartTransform.Translation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flatten_DefaultScalePerPart_AppliedToTransform()
|
||||
{
|
||||
var setup = new Setup
|
||||
{
|
||||
Parts = { 0x01000001u, 0x01000002u },
|
||||
DefaultScale = { new Vector3(2, 2, 2), new Vector3(3, 3, 3) },
|
||||
};
|
||||
// No placement frames — fallback frame is identity pose; scale still applies.
|
||||
|
||||
var refs = SetupMesh.Flatten(setup);
|
||||
|
||||
Assert.Equal(2f, refs[0].PartTransform.M11);
|
||||
Assert.Equal(3f, refs[1].PartTransform.M11);
|
||||
}
|
||||
}
|
||||
65
tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
Normal file
65
tests/AcDream.Core.Tests/Rendering/Wb/WbMeshAdapterTests.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.Core.Tests.Rendering.Wb;
|
||||
|
||||
public sealed class WbMeshAdapterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Construct_WithNullGl_ThrowsArgumentNull()
|
||||
{
|
||||
// GL is the first guarded parameter; verifies the constructor validates inputs.
|
||||
// We can't pass a real GL (no context in tests), so we verify only the
|
||||
// null-GL guard. The real pipeline is tested via integration.
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WbMeshAdapter(gl: null!, datDir: "some/path", dats: null!, logger: NullLogger<WbMeshAdapter>.Instance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_OnUninitializedAdapter_DoesNotThrow()
|
||||
{
|
||||
var adapter = WbMeshAdapter.CreateUninitialized();
|
||||
adapter.Dispose(); // no-op when fields are null
|
||||
adapter.Dispose(); // idempotent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementRefCount_OnUninitializedAdapter_NoOps()
|
||||
{
|
||||
var adapter = WbMeshAdapter.CreateUninitialized();
|
||||
// Should not throw, even though there's no underlying mesh manager.
|
||||
adapter.IncrementRefCount(0x01000001ul);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecrementRefCount_OnUninitializedAdapter_NoOps()
|
||||
{
|
||||
var adapter = WbMeshAdapter.CreateUninitialized();
|
||||
adapter.DecrementRefCount(0x01000001ul);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRenderData_OnUninitializedAdapter_ReturnsNull()
|
||||
{
|
||||
var adapter = WbMeshAdapter.CreateUninitialized();
|
||||
Assert.Null(adapter.GetRenderData(0x01000001ul));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_OnUninitializedAdapter_DoesNotThrow()
|
||||
{
|
||||
var adapter = WbMeshAdapter.CreateUninitialized();
|
||||
adapter.Tick(); // no-op, no throw
|
||||
adapter.Tick(); // idempotent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_AfterDispose_DoesNotThrow()
|
||||
{
|
||||
var adapter = WbMeshAdapter.CreateUninitialized();
|
||||
adapter.Dispose();
|
||||
adapter.Tick(); // no-op, no throw
|
||||
}
|
||||
}
|
||||
|
|
@ -56,12 +56,10 @@ public class SurfaceDecoderTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_A8_ExpandsSingleByteToRgbaWithAlphaInAllChannels()
|
||||
public void Decode_A8_NonAdditive_ProducesWhitePlusAlpha()
|
||||
{
|
||||
// PFID_A8 is single-byte-per-pixel alpha. AC terrain blending alpha maps
|
||||
// are stored this way. WorldBuilder's GetExpandedAlphaTexture replicates
|
||||
// the byte into all four RGBA channels so fragment shaders can read the
|
||||
// blend value from any channel (convention: the alpha channel).
|
||||
// Default (isAdditive: false) = WB FillA8 semantics: R=G=B=255, A=val.
|
||||
// Used for non-additive entity surfaces where A8 is a pure alpha channel.
|
||||
var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; // 2x2 image
|
||||
var rs = new RenderSurface
|
||||
{
|
||||
|
|
@ -76,7 +74,34 @@ public class SurfaceDecoderTests
|
|||
Assert.Equal(2, decoded.Width);
|
||||
Assert.Equal(2, decoded.Height);
|
||||
Assert.Equal(16, decoded.Rgba8.Length);
|
||||
// Each input byte expands to (b, b, b, b) in RGBA output
|
||||
// Each input byte expands to (255, 255, 255, val) — white with varying alpha
|
||||
Assert.Equal(new byte[]
|
||||
{
|
||||
255, 255, 255, 0x00,
|
||||
255, 255, 255, 0x40,
|
||||
255, 255, 255, 0x80,
|
||||
255, 255, 255, 0xFF,
|
||||
}, decoded.Rgba8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_A8_Additive_ReplicatesByteToAllChannels()
|
||||
{
|
||||
// isAdditive=true = WB FillA8Additive semantics: R=G=B=A=val.
|
||||
// Used for terrain blending alpha masks (TerrainAtlas always passes isAdditive:true).
|
||||
var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; // 2x2 image
|
||||
var rs = new RenderSurface
|
||||
{
|
||||
Width = 2,
|
||||
Height = 2,
|
||||
Format = PixelFormat.PFID_A8,
|
||||
SourceData = src,
|
||||
};
|
||||
|
||||
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
|
||||
|
||||
Assert.Equal(16, decoded.Rgba8.Length);
|
||||
// Each input byte fans out to all four channels
|
||||
Assert.Equal(new byte[]
|
||||
{
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
|
|
@ -92,7 +117,7 @@ public class SurfaceDecoderTests
|
|||
// PFID_CUSTOM_LSCAPE_ALPHA (0xF4) is AC's custom format for terrain
|
||||
// blending alpha maps. Pixel layout is identical to PFID_A8 — one
|
||||
// byte of alpha per pixel — so the decoder routes both through the
|
||||
// same DecodeA8 implementation.
|
||||
// same DecodeA8 implementation. Default (isAdditive:false) → R=G=B=255, A=val.
|
||||
var src = new byte[] { 0x10, 0x20, 0x30, 0x40 }; // 2x2
|
||||
var rs = new RenderSurface
|
||||
{
|
||||
|
|
@ -107,10 +132,10 @@ public class SurfaceDecoderTests
|
|||
Assert.Equal(16, decoded.Rgba8.Length);
|
||||
Assert.Equal(new byte[]
|
||||
{
|
||||
0x10, 0x10, 0x10, 0x10,
|
||||
0x20, 0x20, 0x20, 0x20,
|
||||
0x30, 0x30, 0x30, 0x30,
|
||||
0x40, 0x40, 0x40, 0x40,
|
||||
255, 255, 255, 0x10,
|
||||
255, 255, 255, 0x20,
|
||||
255, 255, 255, 0x30,
|
||||
255, 255, 255, 0x40,
|
||||
}, decoded.Rgba8);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,369 @@
|
|||
using Chorizite.OpenGLSDLBackend.Lib;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Tests.Textures;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests proving byte-identical output between our hand-rolled
|
||||
/// SurfaceDecoder paths and WorldBuilder's TextureHelpers.Fill* methods.
|
||||
/// These tests run BEFORE any substitution — they prove equivalence first.
|
||||
/// If a test fails, the formats diverge and that's a real finding.
|
||||
/// </summary>
|
||||
public class TextureDecodeConformanceTests
|
||||
{
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
private static Palette MakePalette(params ColorARGB[] colors)
|
||||
{
|
||||
var pal = new Palette();
|
||||
foreach (var c in colors)
|
||||
pal.Colors.Add(c);
|
||||
return pal;
|
||||
}
|
||||
|
||||
private static ColorARGB Rgba(byte r, byte g, byte b, byte a = 0xFF)
|
||||
=> new ColorARGB { Red = r, Green = g, Blue = b, Alpha = a };
|
||||
|
||||
// Inline our current DecodeIndex16 logic for the conformance baseline.
|
||||
private static byte[] OurDecodeIndex16(byte[] src, Palette palette, int width, int height, bool isClipMap = false)
|
||||
{
|
||||
var rgba = new byte[width * height * 4];
|
||||
int paletteMax = palette.Colors.Count - 1;
|
||||
for (int i = 0; i < width * height; i++)
|
||||
{
|
||||
int s = i * 2;
|
||||
ushort idx = (ushort)(src[s] | (src[s + 1] << 8));
|
||||
if (idx > paletteMax) idx = 0;
|
||||
var c = palette.Colors[idx];
|
||||
int d = i * 4;
|
||||
if (isClipMap && idx < 8)
|
||||
{
|
||||
rgba[d + 0] = 0;
|
||||
rgba[d + 1] = 0;
|
||||
rgba[d + 2] = 0;
|
||||
rgba[d + 3] = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
rgba[d + 0] = c.Red;
|
||||
rgba[d + 1] = c.Green;
|
||||
rgba[d + 2] = c.Blue;
|
||||
rgba[d + 3] = c.Alpha;
|
||||
}
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// Inline our current DecodeP8 logic.
|
||||
private static byte[] OurDecodeP8(byte[] src, Palette palette, int width, int height, bool isClipMap = false)
|
||||
{
|
||||
var rgba = new byte[width * height * 4];
|
||||
int paletteMax = palette.Colors.Count - 1;
|
||||
for (int i = 0; i < width * height; i++)
|
||||
{
|
||||
int idx = src[i];
|
||||
if (idx > paletteMax) idx = 0;
|
||||
var c = palette.Colors[idx];
|
||||
int d = i * 4;
|
||||
if (isClipMap && idx < 8)
|
||||
{
|
||||
rgba[d + 0] = 0;
|
||||
rgba[d + 1] = 0;
|
||||
rgba[d + 2] = 0;
|
||||
rgba[d + 3] = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
rgba[d + 0] = c.Red;
|
||||
rgba[d + 1] = c.Green;
|
||||
rgba[d + 2] = c.Blue;
|
||||
rgba[d + 3] = c.Alpha;
|
||||
}
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// Inline our current DecodeA8R8G8B8 logic (BGRA on-disk → RGBA).
|
||||
private static byte[] OurDecodeA8R8G8B8(byte[] src, int width, int height)
|
||||
{
|
||||
var rgba = new byte[width * height * 4];
|
||||
for (int i = 0; i < width * height; i++)
|
||||
{
|
||||
int s = i * 4;
|
||||
rgba[s + 0] = src[s + 2]; // R
|
||||
rgba[s + 1] = src[s + 1]; // G
|
||||
rgba[s + 2] = src[s + 0]; // B
|
||||
rgba[s + 3] = src[s + 3]; // A
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// Inline our current DecodeR8G8B8 logic (BGR on-disk → RGBA with A=255).
|
||||
private static byte[] OurDecodeR8G8B8(byte[] src, int width, int height)
|
||||
{
|
||||
var rgba = new byte[width * height * 4];
|
||||
for (int i = 0; i < width * height; i++)
|
||||
{
|
||||
int s = i * 3;
|
||||
int d = i * 4;
|
||||
rgba[d + 0] = src[s + 2]; // R
|
||||
rgba[d + 1] = src[s + 1]; // G
|
||||
rgba[d + 2] = src[s + 0]; // B
|
||||
rgba[d + 3] = 0xFF; // A = opaque
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// Inline our current DecodeA8 logic (R=G=B=A=val — "additive" mode).
|
||||
private static byte[] OurDecodeA8(byte[] src, int width, int height)
|
||||
{
|
||||
var rgba = new byte[width * height * 4];
|
||||
for (int i = 0; i < width * height; i++)
|
||||
{
|
||||
byte a = src[i];
|
||||
int d = i * 4;
|
||||
rgba[d + 0] = a;
|
||||
rgba[d + 1] = a;
|
||||
rgba[d + 2] = a;
|
||||
rgba[d + 3] = a;
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// ---- tests -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Test 1: INDEX16 normal mode — 2×2 image with two palette entries.
|
||||
/// WB's FillIndex16 and our DecodeIndex16 must produce identical RGBA bytes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillIndex16_MatchesOurDecodeIndex16()
|
||||
{
|
||||
// 2×2 INDEX16: pixels 0,1,1,0 (indices into a 2-color palette)
|
||||
var src = new byte[]
|
||||
{
|
||||
0x00, 0x00, // pixel(0,0) → palette index 0
|
||||
0x01, 0x00, // pixel(1,0) → palette index 1
|
||||
0x01, 0x00, // pixel(0,1) → palette index 1
|
||||
0x00, 0x00, // pixel(1,1) → palette index 0
|
||||
};
|
||||
var palette = MakePalette(
|
||||
Rgba(0xFF, 0x00, 0x00), // index 0 = red
|
||||
Rgba(0x00, 0x00, 0xFF) // index 1 = blue
|
||||
);
|
||||
|
||||
var expected = OurDecodeIndex16(src, palette, 2, 2);
|
||||
|
||||
var actual = new byte[2 * 2 * 4];
|
||||
TextureHelpers.FillIndex16(src, palette, actual, 2, 2);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test 2: INDEX16 clipmap mode — indices below 8 must produce transparent pixels.
|
||||
/// Both implementations share the same clipmap alpha-key convention from retail ACViewer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillIndex16_ClipMap_MatchesOurClipMapBehavior()
|
||||
{
|
||||
// 4×1 INDEX16: indices 0, 1, 7, 8
|
||||
// In clipmap mode, indices 0..7 → transparent; index 8 → palette color.
|
||||
var src = new byte[]
|
||||
{
|
||||
0x00, 0x00, // index 0 → transparent
|
||||
0x01, 0x00, // index 1 → transparent
|
||||
0x07, 0x00, // index 7 → transparent
|
||||
0x08, 0x00, // index 8 → opaque
|
||||
};
|
||||
// Build a 16-entry palette so indices 0–8 are all valid.
|
||||
var palette = new Palette();
|
||||
for (int i = 0; i < 16; i++)
|
||||
palette.Colors.Add(Rgba(0xAA, 0xBB, 0xCC));
|
||||
|
||||
var expected = OurDecodeIndex16(src, palette, 4, 1, isClipMap: true);
|
||||
|
||||
var actual = new byte[4 * 1 * 4];
|
||||
TextureHelpers.FillIndex16(src, palette, actual, 4, 1, isClipMap: true);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test 3: P8 (8-bit palette index) — 2×2 image.
|
||||
/// WB FillP8 and our DecodeP8 must produce identical RGBA output.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillP8_MatchesOurDecodeP8()
|
||||
{
|
||||
// 2×2 P8: bytes are direct palette indices
|
||||
var src = new byte[] { 0x00, 0x01, 0x01, 0x00 };
|
||||
var palette = MakePalette(
|
||||
Rgba(0x10, 0x20, 0x30), // index 0
|
||||
Rgba(0x40, 0x50, 0x60) // index 1
|
||||
);
|
||||
|
||||
var expected = OurDecodeP8(src, palette, 2, 2);
|
||||
|
||||
var actual = new byte[2 * 2 * 4];
|
||||
TextureHelpers.FillP8(src, palette, actual, 2, 2);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test 4: A8R8G8B8 (BGRA on-disk → RGBA) — 2×1 image.
|
||||
/// WB FillA8R8G8B8 and our DecodeA8R8G8B8 both swap B↔R.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillA8R8G8B8_MatchesOurDecodeA8R8G8B8()
|
||||
{
|
||||
// On-disk layout: B, G, R, A per pixel
|
||||
var src = new byte[]
|
||||
{
|
||||
0x00, 0x00, 0xFF, 0xFF, // pixel 0: B=0, G=0, R=255, A=255 → red
|
||||
0xFF, 0x00, 0x00, 0x80, // pixel 1: B=255, G=0, R=0, A=128 → blue, semi-transparent
|
||||
};
|
||||
|
||||
var expected = OurDecodeA8R8G8B8(src, 2, 1);
|
||||
|
||||
var actual = new byte[2 * 1 * 4];
|
||||
TextureHelpers.FillA8R8G8B8(src, actual, 2, 1);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test 5: R8G8B8 (BGR on-disk → RGBA, alpha forced 255) — 2×1 image.
|
||||
/// Both implementations output R,G,B,255 for each 3-byte BGR triple.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillR8G8B8_MatchesOurDecodeR8G8B8()
|
||||
{
|
||||
// On-disk layout: B, G, R per pixel (24-bit BGR)
|
||||
var src = new byte[]
|
||||
{
|
||||
0x00, 0x00, 0xFF, // pixel 0: B=0, G=0, R=255 → red
|
||||
0x00, 0xFF, 0x00, // pixel 1: B=0, G=255, R=0 → green
|
||||
};
|
||||
|
||||
var expected = OurDecodeR8G8B8(src, 2, 1);
|
||||
|
||||
var actual = new byte[2 * 1 * 4];
|
||||
TextureHelpers.FillR8G8B8(src, actual, 2, 1);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test 6: A8 in additive mode — FillA8Additive replicates the byte into all four
|
||||
/// channels (R=G=B=A=val). This is identical to our current DecodeA8 behavior,
|
||||
/// which is used for terrain blending alpha masks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillA8Additive_MatchesOurDecodeA8()
|
||||
{
|
||||
// 4×1 single-byte-per-pixel alpha values
|
||||
var src = new byte[] { 0x00, 0x40, 0x80, 0xFF };
|
||||
|
||||
var expected = OurDecodeA8(src, 4, 1);
|
||||
|
||||
var actual = new byte[4 * 1 * 4];
|
||||
TextureHelpers.FillA8Additive(src, actual, 4, 1);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
// Spot-check: each input byte fans out to all four channels
|
||||
Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, actual[0..4]);
|
||||
Assert.Equal(new byte[] { 0x40, 0x40, 0x40, 0x40 }, actual[4..8]);
|
||||
Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, actual[12..16]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test 7: A8 non-additive (FillA8) documents WB's behavior that DIFFERS from ours.
|
||||
/// WB's FillA8 sets R=G=B=255 and A=input_byte.
|
||||
/// Our DecodeA8 sets R=G=B=A=input_byte (the additive mode, used for terrain blending).
|
||||
/// This test proves the divergence exists and documents the WB behavior explicitly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillA8_NonAdditive_ProducesWhitePlusAlpha()
|
||||
{
|
||||
var src = new byte[] { 0x00, 0x80, 0xFF }; // 3×1
|
||||
|
||||
var actual = new byte[3 * 1 * 4];
|
||||
TextureHelpers.FillA8(src, actual, 3, 1);
|
||||
|
||||
// WB non-additive: R=G=B=255, A=input byte
|
||||
Assert.Equal(new byte[] { 255, 255, 255, 0x00 }, actual[0..4]); // alpha=0
|
||||
Assert.Equal(new byte[] { 255, 255, 255, 0x80 }, actual[4..8]); // alpha=128
|
||||
Assert.Equal(new byte[] { 255, 255, 255, 0xFF }, actual[8..12]); // alpha=255
|
||||
|
||||
// Confirm this DIFFERS from our current DecodeA8 behavior (R=G=B=A=val).
|
||||
var ourDecoded = OurDecodeA8(src, 3, 1);
|
||||
Assert.NotEqual(ourDecoded, actual); // divergence is intentional — both are documented
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test 8: R5G6B5 (16-bit packed RGB, no alpha) — WB format we don't implement yet.
|
||||
/// Verifies the expected bit-expansion: 5-bit red → 8-bit by left-shifting 3,
|
||||
/// 6-bit green → 8-bit by left-shifting 2, 5-bit blue → 8-bit by left-shifting 3.
|
||||
/// Alpha is always 255.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillR5G6B5_ProducesExpectedRgba()
|
||||
{
|
||||
// Encode a single pixel: R=0x1F (31), G=0x3F (63), B=0x1F (31)
|
||||
// Packed as 16-bit little-endian: bits 15-11=R, 10-5=G, 4-0=B
|
||||
// val = (0x1F << 11) | (0x3F << 5) | 0x1F = 0xFFFF
|
||||
var src = new byte[] { 0xFF, 0xFF }; // 1×1 pixel: all channels maxed
|
||||
|
||||
var actual = new byte[1 * 1 * 4];
|
||||
TextureHelpers.FillR5G6B5(src, actual, 1, 1);
|
||||
|
||||
// R = (0x1F << 3) = 0xF8, G = (0x3F << 2) = 0xFC, B = (0x1F << 3) = 0xF8, A = 255
|
||||
Assert.Equal((byte)0xF8, actual[0]); // R
|
||||
Assert.Equal((byte)0xFC, actual[1]); // G
|
||||
Assert.Equal((byte)0xF8, actual[2]); // B
|
||||
Assert.Equal((byte)255, actual[3]); // A always opaque
|
||||
|
||||
// Test a second pixel: pure red = R=31, G=0, B=0
|
||||
// val = (0x1F << 11) = 0xF800
|
||||
var srcRed = new byte[] { 0x00, 0xF8 }; // little-endian 0xF800
|
||||
var actualRed = new byte[4];
|
||||
TextureHelpers.FillR5G6B5(srcRed, actualRed, 1, 1);
|
||||
Assert.Equal((byte)0xF8, actualRed[0]); // R = 31 << 3 = 0xF8
|
||||
Assert.Equal((byte)0x00, actualRed[1]); // G = 0
|
||||
Assert.Equal((byte)0x00, actualRed[2]); // B = 0
|
||||
Assert.Equal((byte)255, actualRed[3]); // A
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test 9: A4R4G4B4 (16-bit packed ARGB, 4 bits per channel) — WB format we don't implement yet.
|
||||
/// Each 4-bit value is expanded to 8-bit by multiplying by 17 (0x11),
|
||||
/// so 0xF → 255, 0x8 → 136, 0x0 → 0.
|
||||
/// Bit layout: val bits 15-12=A, 11-8=R, 7-4=G, 3-0=B.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FillA4R4G4B4_ProducesExpectedRgba()
|
||||
{
|
||||
// Encode one pixel: A=0xF(255), R=0xA(170), G=0x5(85), B=0x0(0)
|
||||
// val = (0xF << 12) | (0xA << 8) | (0x5 << 4) | 0x0 = 0xFA50
|
||||
// little-endian bytes: 0x50, 0xFA
|
||||
var src = new byte[] { 0x50, 0xFA }; // 1×1
|
||||
|
||||
var actual = new byte[1 * 1 * 4];
|
||||
TextureHelpers.FillA4R4G4B4(src, actual, 1, 1);
|
||||
|
||||
// R = 0xA * 17 = 170, G = 0x5 * 17 = 85, B = 0x0 * 17 = 0, A = 0xF * 17 = 255
|
||||
Assert.Equal((byte)(0xA * 17), actual[0]); // R = 170
|
||||
Assert.Equal((byte)(0x5 * 17), actual[1]); // G = 85
|
||||
Assert.Equal((byte)(0x0 * 17), actual[2]); // B = 0
|
||||
Assert.Equal((byte)(0xF * 17), actual[3]); // A = 255
|
||||
|
||||
// Also test the zero case: all channels 0
|
||||
var srcZero = new byte[] { 0x00, 0x00 };
|
||||
var actualZero = new byte[4];
|
||||
TextureHelpers.FillA4R4G4B4(srcZero, actualZero, 1, 1);
|
||||
Assert.Equal(new byte[] { 0, 0, 0, 0 }, actualZero);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue