Records V2's landing (V2ad365476e, V2b1f1f6c08, V2ca85743f7) in the campaign doc's status banner and slice table, alongside the actual per-renderer table-ownership shape (each of WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, and ParticleRenderer owns its own GlBindlessHandleTable rather than one shared TextureCache-owned instance) and the measured pixel-gate differing-pixel fractions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
516 lines
34 KiB
Markdown
516 lines
34 KiB
Markdown
# Campaign V — OpenGL → Vulkan rendering migration
|
||
|
||
**Status:** Active. V0 (pinned RHI contract) landed 2026-07-27. V1 (GL backend
|
||
implementation, dark) landed 2026-07-27. V2 (shader dialect + texture-index
|
||
migration on GL, three sub-commits) landed 2026-07-27.
|
||
**Scope:** Windows x64 + Linux x64. No macOS.
|
||
**End state:** one Vulkan 1.3 backend; the OpenGL backend is deleted.
|
||
|
||
---
|
||
|
||
## 1. Why
|
||
|
||
acdream's mandatory modern GL path (GL 4.3 core + `ARB_bindless_texture` +
|
||
`ARB_shader_draw_parameters` + MDI + SSBOs, with no fallback) is built on an API
|
||
that is no longer evolving, and its hardware floor is narrow: bindless textures
|
||
are absent on Intel integrated GPUs and on every Mesa software / D3D12 stack.
|
||
That floor is exactly what parked Slice L at its L1 checkpoint — WSLg correctly
|
||
rejects our renderer because Mesa's D3D12 and llvmpipe drivers do not advertise
|
||
`GL_ARB_bindless_texture`.
|
||
|
||
Vulkan 1.3 makes the same rendering strategy portable: descriptor indexing (the
|
||
bindless replacement) is a core feature, not a vendor extension, and it works on
|
||
RADV, NVIDIA, Intel, and lavapipe. Two secondary wins follow: explicit present
|
||
control (a direct lead on issue #235's capped/RDP cadence alias) and lower CPU
|
||
cost per frame, because per-frame data can be written straight into mapped
|
||
memory instead of copied through `BufferSubData`.
|
||
|
||
**This is a compatibility and efficiency campaign, not a rescue.** The GL path
|
||
works and is fast. Nothing here changes what the game looks like.
|
||
|
||
---
|
||
|
||
## 2. Goal and acceptance
|
||
|
||
Replace the OpenGL renderer with a single Vulkan 1.3 backend, preserving retail
|
||
pixels exactly and improving cost.
|
||
|
||
| Dimension | GL baseline | Campaign target | Cutover floor |
|
||
|---|---|---|---|
|
||
| CPU frame p50 | 1.869 ms | ≤ 1.60 ms | ≤ 1.869 ms |
|
||
| GPU frame p50 | 1.096 ms | ≤ 1.00 ms | ≤ 1.096 ms |
|
||
| Working set | 652 MiB | ≤ 600 MiB | ≤ 652 MiB |
|
||
| Private set | 928 MiB | ≤ 860 MiB | ≤ 928 MiB |
|
||
| Managed alloc / frame | ~0 B | 0 B | 0 B |
|
||
| CPU/GPU p99 | measured at V8 | ≤ GL p99 | ≤ GL p99 |
|
||
|
||
Pixel acceptance: `dotnet AcDream.Cli.dll compare-screenshots expected.png
|
||
actual.png out.json` at channel tolerance 2 and maximum differing fraction
|
||
0.001, MSAA off, `ACDREAM_DAY_GROUP` pinned, at every deterministic checkpoint
|
||
of the connected lifecycle route.
|
||
|
||
**Out of scope (user decision, 2026-07-27):** the ImGui developer stack
|
||
(`AcDream.UI.ImGui`, `ImGuiBootstrapper`, the DevTools menu bar) is not ported,
|
||
and UI Studio (`StudioWindow`, `PanelFbo`) is parked. Both are deleted at V11
|
||
and remain recoverable from git. A follow-up issue tracks re-homing the
|
||
Settings and Debug panels onto the retained UI through a new `IPanelRenderer`
|
||
implementation — the panels themselves need no rewrite because they already
|
||
target only `AcDream.UI.Abstractions`. Until that lands, keybind remapping falls
|
||
back to editing `keybinds.json`.
|
||
|
||
---
|
||
|
||
## 3. Architecture: a Vulkan-shaped RHI, implemented by GL first
|
||
|
||
### 3.1 The decision
|
||
|
||
The renderers port **one at a time onto a minimal Vulkan-shaped RHI while GL is
|
||
still the shipping backend**. Each port slice is pixel-gated against its parent
|
||
commit on the *same* backend, so a divergence is attributed to one slice rather
|
||
than discovered at a big-bang integration. Only then does the Vulkan backend
|
||
implement the same interfaces, gated by a GL-versus-Vulkan differential.
|
||
|
||
The alternatives were rejected for concrete reasons:
|
||
|
||
- **Per-renderer duplication** (`WbDrawDispatcherVk` beside the GL one) would
|
||
fork roughly 15,000 lines of CPU logic — bucketing, `RetailAlphaQueue`
|
||
ordering, LRU eviction, arena management — that has nothing to do with the
|
||
graphics API and everything to do with retail fidelity. `WbDrawDispatcher` is
|
||
4,449 + 809 lines but holds only ~62 GL call sites, clustered in the per-frame
|
||
uploads, the two multi-draw brackets, and teardown. The API surface is small;
|
||
the fidelity logic is large. Forking the wrong one of those is how subtle
|
||
regressions enter.
|
||
- **A serialized command IR** adds a third representation and a per-frame
|
||
translation cost, against the efficiency goal, for generality nothing asked
|
||
for. The RHI *is* the prepared-frame-data seam, expressed as typed calls.
|
||
|
||
GL 4.3 implements every Vulkan-shaped concept cheaply: pipelines become a
|
||
program bind plus a cached state apply; ring allocations sit on the existing
|
||
fence-bounded dynamic buffers; a descriptor-table index becomes an indirection
|
||
through a storage buffer of bindless handles; passes are a no-op bracket. **The
|
||
GL backend is deliberately behaviour-preserving and never improved** — it keeps
|
||
`BufferSubData` — which is precisely what makes each port slice's pixel gate a
|
||
strict identity check. The efficiency wins land in the Vulkan backend only.
|
||
|
||
### 3.2 Location and isolation
|
||
|
||
Namespaces inside `AcDream.App`, not a new project:
|
||
|
||
- `AcDream.App.Rendering.Gpu` — the contract (landed at V0)
|
||
- `AcDream.App.Rendering.Gpu.Gl` — GL backend (deleted at V11)
|
||
- `AcDream.App.Rendering.Gpu.Vk` — Vulkan backend
|
||
|
||
A separate project would force a public surface or `InternalsVisibleTo` churn
|
||
for twelve `internal` renderers, and its only benefit — compile-time proof that
|
||
renderers cannot reach GL — arrives anyway at V11 when the `Silk.NET.OpenGL`
|
||
package reference is dropped. Until then the guarantee comes from an
|
||
architecture test added at V4h that asserts no type outside `Gpu.Gl` and a small
|
||
allowlist references `Silk.NET.OpenGL`. Deletion at cutover is one directory and
|
||
one `PackageReference`.
|
||
|
||
### 3.3 The contract (pinned at V0)
|
||
|
||
`src/AcDream.App/Rendering/Gpu/`:
|
||
|
||
| Type | Responsibility |
|
||
|---|---|
|
||
| `IGpuDevice` | Resource creation, the global texture table, frame lifecycle, the deferred device-action queue (replaces `QueueGLAction`), backbuffer capture, retirement queue. |
|
||
| `IGpuFrame` | One frame: ring allocations, `BeginPass`, submit/present on `End`. |
|
||
| `IGpuPassEncoder` | Records one pass: bind pipeline/buffers, push constants, dynamic cull/front-face/depth-write, viewport/scissor, `Draw`, `DrawIndexed`, `MultiDrawIndexedIndirect`, timer scopes. |
|
||
| `GpuRingAllocation` | A `ref struct` slice of the frame's upload ring: buffer, aligned offset, CPU-writable span. Replaces every per-frame `BufferSubData`. |
|
||
| `IGpuBuffer` / `IGpuTexture` / `IGpuSampler` | Resources. Disposal routes through the retirement queue, never freeing under a live frame. |
|
||
| `IGpuPipeline` + `GpuPipelineDescription` | Shader pair plus all state Vulkan bakes: blend, depth, cull default, front face, alpha-to-coverage, topology, sample count. |
|
||
| `GpuPassDescription` | Attachments with load/store ops, clear values, sample count, resolve. |
|
||
| `IGpuRenderTarget` | Offscreen colour(+depth) whose colour is sampleable after the pass. |
|
||
| `IGpuTimerPool` | GPU timings from retired frames. |
|
||
| `GpuCapabilityRecord` | Backend-neutral capability view; computes `SupportFailures`, feeding the existing exit-code-4 contract. |
|
||
| `GpuTextureSlot` | Index into the global texture table — the backend-neutral replacement for a bindless handle. |
|
||
| `RecordingGpuDevice` (in the test project) | In-memory double: records calls in order and backs ring allocations with real memory, so renderer tests run with no GPU. |
|
||
|
||
**Design notes worth keeping in mind while implementing:**
|
||
|
||
- `GpuTextureSlot.Unassigned` is a loud sentinel (`uint.MaxValue`), never a
|
||
usable slot, and must never reach a shader. Renderers needing a fallback take
|
||
`IGpuDevice.DefaultTextureSlot`, a really registered 1×1 white texture. This
|
||
is deliberate: silently resolving an unset index to slot 0 is the failure mode
|
||
that produced the magenta 1×1 UI placeholder bug.
|
||
- `GpuRingAllocation` is a `ref struct` so the compiler forbids storing it past
|
||
the frame that owns the memory.
|
||
- Renderers always speak GL conventions for winding and viewport origin. The
|
||
Vulkan backend renders with a negative viewport height and inverts front-face
|
||
in exactly one mapping function. No renderer performs that flip itself.
|
||
|
||
### 3.4 The binding model (`GpuBindingModel`)
|
||
|
||
Dual-legal for GL GLSL and Vulkan GLSL, exploiting `GL_KHR_vulkan_glsl`'s rule
|
||
that an omitted `set` qualifier means set 0.
|
||
|
||
- **set 0** — storage buffers, bindings 0–8 exactly as the shaders declare them
|
||
today (instances, batches, clip regions, clip slots, global lights, instance
|
||
light sets, instance indoor, instance alpha, selection lighting), plus
|
||
**binding 9 = texture table**, which is the GL-only emulation (a buffer of
|
||
`uvec2` bindless handles) and is deleted with the GL backend.
|
||
- **set 1** — uniform buffers. `SceneLighting` keeps `binding = 1`. Today
|
||
`mesh_modern` relies on GL keeping SSBO and UBO binding tables separate so the
|
||
`BatchBuffer` SSBO and the `SceneLighting` UBO can both be binding 1. Vulkan
|
||
has one binding namespace per set, so moving UBOs to their own set preserves
|
||
both numbers and removes the collision.
|
||
- **set 2** — the global sampled-texture descriptor array: variable count,
|
||
partially bound, update-after-bind, capacity 16384.
|
||
- **Push constants** — one shared 96-byte `GpuPushConstants` block (of the 128
|
||
Vulkan guarantees): `ViewProjection`, `DrawIdOffset`, `LightingMode`,
|
||
`RenderPass`, `LightDebug`, `TextureIndexA/B`, two spare scalars. One shared
|
||
block means one pipeline layout, so switching pipelines mid-pass does not
|
||
invalidate bound descriptors. The GL backend maps each field to the
|
||
correspondingly named uniform and skips those a program does not declare.
|
||
|
||
`BatchData`'s `uvec2 textureHandle` becomes `uint textureIndex` plus a pad word
|
||
at V2. The 16-byte std430 stride is unchanged, so every existing CPU writer
|
||
keeps its offsets. **That single change is what makes the CPU-side data model
|
||
backend-neutral, and it lands on GL, pixel-gated, long before Vulkan exists.**
|
||
|
||
---
|
||
|
||
## 4. Vulkan technical decisions
|
||
|
||
### 4.1 Floor
|
||
|
||
Vulkan 1.3 core plus `VK_KHR_swapchain` (and the platform surface extensions).
|
||
Optional and never required: `VK_EXT_memory_budget` (telemetry),
|
||
`VK_EXT_debug_utils` (object naming in dev builds), `VK_KHR_present_wait` (an
|
||
issue #235 experiment).
|
||
|
||
Required device features, each with a reason:
|
||
|
||
| Feature | Why |
|
||
|---|---|
|
||
| `multiDrawIndirect` | The three MDI dispatch sites are the entire draw architecture. |
|
||
| `drawIndirectFirstInstance` | Indirect commands carry a non-zero `firstInstance` as the per-group instance base. |
|
||
| `shaderDrawParameters` | `gl_DrawID`. Resets per `vkCmdDrawIndexedIndirect` exactly as GL's does, so the issue #52 `uDrawIDOffset` pattern carries over unchanged. |
|
||
| `shaderClipDistance` (≥ 8) | Phase U.3's per-cell screen-space clip gate. |
|
||
| `textureCompressionBC` | DXT1/3/5 DAT surfaces upload as BC1/2/3 with no transcode. |
|
||
| `samplerAnisotropy` | Sampler-quality parity. |
|
||
| `timelineSemaphore` | One monotonic serial replaces the GL fence array; the existing retirement ledger keeps its serial keys. |
|
||
| `hostQueryReset` | Reset timestamp pools from the CPU instead of burning command-buffer calls. |
|
||
| descriptor-indexing set (`runtimeDescriptorArray`, `descriptorBindingPartiallyBound`, `…SampledImageUpdateAfterBind`, `…UpdateUnusedWhilePending`, `…VariableDescriptorCount`) | The global texture table replacing bindless handles. |
|
||
| `dynamicRendering`, `synchronization2`, `maintenance4` | No render-pass/framebuffer objects; barrier2; relaxed shader interface rules. |
|
||
|
||
Explicitly **not** required: `bufferDeviceAddress` (every buffer is descriptor
|
||
bound; it would buy nothing and costs capture-tool compatibility), any
|
||
compute/geometry/tessellation feature (acdream has no such shaders),
|
||
`fillModeNonSolid` (debug lines use `LINE_LIST` topology).
|
||
|
||
Limits to assert in the probe: `maxPushConstantsSize ≥ 128`,
|
||
`timestampComputeAndGraphics`, `maxDescriptorSetUpdateAfterBindSampledImages ≥
|
||
16384`, `maxPerStageDescriptorUpdateAfterBindSampledImages ≥ 16384`.
|
||
|
||
### 4.2 Bindings layer
|
||
|
||
`Silk.NET.Vulkan` + `Silk.NET.Vulkan.Extensions.KHR` at **2.23.0**, matching the
|
||
pinned Silk family. It is blittable-struct and function-pointer based, so with
|
||
`stackalloc`/`fixed` for the small arrays passed to submits and barriers it
|
||
allocates nothing per frame — which the 0 B/frame target requires.
|
||
|
||
**No VMA dependency.** Silk does not ship it, third-party .NET bindings are a
|
||
native-binary and maintenance liability across win-x64/linux-x64/CI-lavapipe,
|
||
and acdream's allocation profile is tame: two mesh arena buffers, one staging
|
||
ring, a handful of per-frame buffers, ~4 render targets, and a texture pool. A
|
||
custom allocator (~400 lines, first-fit free list over 128 MiB device-local
|
||
blocks per memory type, dedicated allocations at ≥ 32 MiB) keeps
|
||
`vkAllocateMemory` counts two orders of magnitude below the limit and plugs
|
||
straight into `GpuMemoryTracker` for exact accounting, which VMA would obscure.
|
||
|
||
### 4.3 Memory
|
||
|
||
- **Mesh arena** — two `DEVICE_LOCAL` buffers mirroring `GlobalMeshBuffer`
|
||
exactly: 384 MiB vertex, 128 MiB index (`VK_INDEX_TYPE_UINT16`; the existing
|
||
cap is already expressed in `sizeof(ushort)`). Keep the reclaimable-range
|
||
allocator, growth quanta, budgeted incremental grow-and-copy (now
|
||
`vkCmdCopyBuffer`), retirement-ledger deletes, and the 896 MiB dual-generation
|
||
ceiling.
|
||
- **Staging ring** — one persistently mapped `HOST_VISIBLE|COHERENT` buffer
|
||
(48 MiB), watermarked per flight slot, recycled when the slot retires.
|
||
Oversized uploads take a temporary dedicated buffer retired through the ledger.
|
||
- **Per-frame data** — the CPU win. Each MDI renderer gets, per flight slot, one
|
||
persistently mapped buffer holding its instance/batch/clip/light/indoor/alpha/
|
||
selection sections at fixed aligned offsets, plus indirect commands and the
|
||
SceneLighting block. Prefer `DEVICE_LOCAL|HOST_VISIBLE` (ReBAR — present on
|
||
the RX 9070 XT, RADV, and modern NVIDIA), fall back to `HOST_VISIBLE|COHERENT`.
|
||
The bucketing code writes structs **directly into mapped memory**; today's
|
||
write-to-array-then-`BufferSubData` (driver validation, copy, rename tracking)
|
||
simply stops existing.
|
||
- **Textures** — device-local pool. Formats stay UNORM (`BC1/2/3_UNORM`,
|
||
`R8G8B8A8_UNORM`, `R8_UNORM`); sRGB correctness lives at the framebuffer, as
|
||
it does on GL today. 2D arrays are allocated full-size and filled
|
||
incrementally, mirroring `ManagedGLTextureArray`.
|
||
- **Mip generation** — DAT surfaces ship no mips. Uncompressed formats get a
|
||
`vkCmdBlitImage` chain at upload. **BC formats cannot be blit targets**, so
|
||
their chains are built on the CPU at decode time (box filter + a small managed
|
||
BC encoder, deterministic and unit-testable) — which also replaces today's
|
||
driver-defined behaviour for `glGenerateMipmap` on compressed arrays. Escape
|
||
hatch if encoder quality ever trips the pixel gate: store the affected
|
||
textures as RGBA8 and blit their mips.
|
||
|
||
### 4.4 Descriptors
|
||
|
||
Two persistent sets, one shared pipeline layout, **zero descriptor writes per
|
||
frame**.
|
||
|
||
- **Set 0** — one `COMBINED_IMAGE_SAMPLER` binding, 16384 variable count,
|
||
`PARTIALLY_BOUND | UPDATE_AFTER_BIND | UPDATE_UNUSED_WHILE_PENDING`, fragment
|
||
stage. A slot is a (view, sampler) pair — exact parity with bindless handles,
|
||
which are also per texture+sampler. Registration appends one descriptor write;
|
||
eviction returns the slot to a free list gated on frame retirement, and the
|
||
slot is defensively overwritten with a dummy before reuse. This removes the
|
||
entire `MakeTextureHandleResident` churn.
|
||
- **Set 1** — per-renderer, per-flight-slot storage buffers at the nine
|
||
`GpuBindingModel` bindings plus the SceneLighting UBO, all pointing into that
|
||
renderer's mapped per-slot buffer at fixed offsets. Written once at startup;
|
||
rewritten only when a buffer grows, gated on that slot's retirement. Bindings a
|
||
given renderer does not use still bind a shared dummy range so there is one
|
||
layout and no permutations.
|
||
|
||
### 4.5 Pipelines
|
||
|
||
Core 1.3 dynamic state covers viewport, scissor, cull mode, front face, depth
|
||
test/write/compare, stencil test/ops, and topology class — which folds the GL
|
||
pass matrix's cull/depth-mask/stencil toggles into command-time calls. Blend and
|
||
alpha-to-coverage are **not** dynamic, so they define the pipeline list:
|
||
mesh opaque / alpha / additive, terrain, sky, particle alpha / additive,
|
||
particle-mesh alpha / additive, debug line, UI text, plus offscreen variants
|
||
only where the target's format or sample count differs. **Expect 11–14
|
||
pipelines.**
|
||
|
||
All are known statically and **built at startup** against a `VkPipelineCache`
|
||
persisted to `ApplicationPathSet.CacheDirectory` (validated by header UUID).
|
||
First launch pays a few hundred milliseconds once; later launches are
|
||
milliseconds, and no frame ever compiles — which also removes GL's hidden
|
||
first-draw driver-recompile hitches.
|
||
|
||
Depth/stencil: prefer `D32_SFLOAT_S8_UINT`, fall back `D24_UNORM_S8_UINT`. The
|
||
stencil aspect is required by #117's portal punch.
|
||
|
||
### 4.6 Shaders
|
||
|
||
The eight GLSL pairs stay the single source of truth. Vulkan-dialect changes:
|
||
`set`/`binding` qualifiers per §3.4; `texture(uTextures[nonuniformEXT(idx)], …)`
|
||
replacing the bindless `sampler2DArray(handle)` reconstruction; `gl_DrawIDARB` →
|
||
`gl_DrawID`; `gl_BaseInstanceARB + gl_InstanceID` → `gl_InstanceIndex` (Vulkan's
|
||
already includes `firstInstance`); the loose uniforms move into the push-constant
|
||
block. std430 SSBO layouts, the std140 SceneLighting block, and
|
||
`gl_ClipDistance[8]` port byte-identically.
|
||
|
||
`nonuniformEXT` is **required, not optional**: within one MDI dispatch different
|
||
draws read different `Batches[]` entries, and "dynamically uniform" is defined
|
||
over the whole dispatch on some implementations. The qualifier costs nothing
|
||
measurable on RDNA or NVIDIA and removes a class of silent corruption.
|
||
|
||
**Compilation: committed `.spv` artifacts** produced by
|
||
`tools/compile-shaders.ps1` (glslang/glslc), plus a test that hashes the GLSL
|
||
sources into a committed manifest and fails when they drift. CI runners have no
|
||
Vulkan SDK, and runtime shaderc would add a native dependency and startup cost
|
||
for shaders that never change at runtime.
|
||
|
||
### 4.7 Clip space — no projection change needed
|
||
|
||
**Verified:** acdream's cameras already build projections with
|
||
`Matrix4x4.CreatePerspectiveFieldOfView`, which is D3D convention with NDC z in
|
||
[0, 1] — documented at `src/AcDream.App/Rendering/PortalProjection.cs:12-13`,
|
||
where the GL-convention near test was previously a real bug. Vulkan's clip
|
||
convention *is* [0, 1], so the matrices are consumed as-is. The GL path has been
|
||
compressing [0, 1] clip z into the upper half of the depth buffer, so Vulkan
|
||
doubles effective depth precision for free.
|
||
|
||
Consequence to expect at V7: **window-space depth values shift, so z-fight
|
||
patterns on near-coplanar retail geometry may differ.** This is the one
|
||
pre-approved divergence class; each instance gets a compare mask or a per-stop
|
||
relaxation plus a divergence-register row.
|
||
|
||
Y-flip is handled by a negative viewport height (core since 1.1), which keeps
|
||
winding and cull semantics identical to GL. Reversed-Z remains an easy future
|
||
option and is explicitly not required for parity.
|
||
|
||
### 4.8 Sync and the frame
|
||
|
||
Two frames in flight; one primary command buffer per frame from a per-slot
|
||
`vkResetCommandPool`; no secondary buffers (single render thread); one
|
||
graphics+present queue with transfers riding it (an async transfer queue is a
|
||
deferred option, not a need — uploads are already budget-throttled). Per-slot
|
||
binary acquire semaphores, per-image binary render-done semaphores, and **one
|
||
timeline semaphore whose value is the frame serial** — so `GpuFrameFlightController`
|
||
ports almost mechanically, its `SortedDictionary<long, List<Action>>` retirement
|
||
ledger keeping its keys.
|
||
|
||
Frame skeleton (synchronization2 throughout): wait timeline ≥ serial − 2 → run
|
||
retirements → reset pool → write per-frame data into mapped slot buffers →
|
||
acquire → record [uploads: copies, one batched image barrier to
|
||
`TRANSFER_DST`, copies/blits, one batched barrier to `SHADER_READ_ONLY` plus a
|
||
buffer barrier to vertex/indirect stages] → [offscreen passes] → [main pass:
|
||
MSAA colour `CLEAR/DONT_CARE` resolving to the swapchain, transient depth
|
||
`CLEAR/DONT_CARE`, sky → terrain → entities → envcells → particles → weather →
|
||
UI] → [optional screenshot copy] → barrier to `PRESENT_SRC` → submit → present.
|
||
Budget: roughly 4–6 batched `vkCmdPipelineBarrier2` calls per frame.
|
||
|
||
### 4.9 Swapchain, present, pacing
|
||
|
||
Surface through Silk windowing (`GraphicsAPI.DefaultVulkan`, `IWindow.VkSurface`)
|
||
so the existing GLFW platform selection, `ACDREAM_DISPLAY_PROTOCOL`, and window
|
||
lifecycle are unchanged. Format `B8G8R8A8_SRGB` preferred (matching the GL
|
||
`FramebufferSrgb` contract: shaders write linear, the attachment encodes);
|
||
screenshots swizzle BGRA→RGBA on the CPU to preserve
|
||
`FrameScreenshotController`'s RGBA byte contract. Present modes: `FIFO` when
|
||
VSync is on; `IMMEDIATE` preferred then `MAILBOX` when off, with
|
||
`FramePacingController` and its platform waiters continuing to drive the software
|
||
cap. `OUT_OF_DATE` recreates immediately, `SUBOPTIMAL` at the next frame
|
||
boundary, both through `FramebufferResizeController`.
|
||
|
||
### 4.10 Capability gate
|
||
|
||
Mirrors the GL three-layer shape exactly — passive record, **active** probes, an
|
||
`Evaluate` that throws `NotSupportedException` → `Program.cs` exit code 4 → an
|
||
atomic `graphical-capabilities.json`. The Vulkan active probe is stronger than
|
||
the GL one: it creates the real device with the production feature chain, builds
|
||
the real descriptor layouts and one real pipeline from the committed `.spv`, and
|
||
renders a 64×64 offscreen triangle sampling a table slot, then reads the pixels
|
||
back. Device selection: discrete > integrated > virtual > CPU, tie-broken by
|
||
largest device-local heap, with an `ACDREAM_VULKAN_DEVICE` override recorded in
|
||
the report.
|
||
|
||
---
|
||
|
||
## 5. Slice sequence
|
||
|
||
Every slice ends with `dotnet build` and the App test suite green, its gate
|
||
passed, and one commit. GL remains the default backend through V9; all Vulkan
|
||
work is dark behind `ACDREAM_RENDER_BACKEND` (default `gl`).
|
||
|
||
**Run the suite in Release: `dotnet test … -c Release`.** Some tests assert
|
||
Release-only behaviour and legitimately fail in Debug —
|
||
`LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty`
|
||
covers the far-tier strip that `LandblockStreamer.cs:505` deliberately turns into
|
||
a loud `Debug.Assert` in Debug builds. A Debug run therefore shows one failure
|
||
that is neither a regression nor yours. The V0 baseline is **3,785 passed /
|
||
3 skipped** in Release (3,763 pre-campaign plus 22 contract tests).
|
||
|
||
### 5.1 The offline pixel gate
|
||
|
||
"Pixel gate" means `tools/run-offline-pixel-gate.ps1`: capture at the parent
|
||
commit, capture at slice HEAD, compare with the `compare-screenshots` CLI at
|
||
tolerance 2 / fraction 0.001.
|
||
|
||
The client is launched **without `ACDREAM_LIVE`**, so it renders the world
|
||
straight from the DATs. No session is created and no ACE state can be disturbed,
|
||
which means this gate runs unattended — it needs neither the live server nor the
|
||
user. That matters: seven slices (V2, V4a–V4g) are renderer ports whose whole
|
||
acceptance criterion is "no pixel changed."
|
||
|
||
```
|
||
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-base # at the parent commit
|
||
tools/run-offline-pixel-gate.ps1 -Out artifacts/gate-head -Baseline artifacts/gate-base
|
||
```
|
||
|
||
**Determinism was measured, not assumed.** Two captures at the same commit
|
||
initially differed in 0.29% of pixels — far above the 0.001 threshold. The
|
||
differences were confined to the top ~180 rows: the sky animates (clouds scroll,
|
||
the sun moves) and the Dereth clock advances with wall time, so two launches can
|
||
never agree there. Everything below the horizon was stable. With the top 280 rows
|
||
masked, two independent same-commit pairs differ by **15 and 17 pixels out of
|
||
563,200 compared** — a fraction of 0.000027, roughly a 33× margin under the
|
||
threshold. The gate is a strict identity check on everything it covers, rather
|
||
than a loose tolerance that would hide real regressions.
|
||
|
||
**Coverage.** Terrain and terrain blending, scenery, static world meshes, water,
|
||
fog, and the entire retained UI (vitals, spell bar, toolbar, chat, radar).
|
||
|
||
**Not covered — these still need a user visual gate:** sky (masked), EnvCell
|
||
interiors, particles, and the paperdoll/appraisal viewports, because the offline
|
||
scene is a fixed outdoor view with no camera control. Slices **V4e** (particles),
|
||
**V4f** (sky), and **V4g** (viewports) therefore carry a user gate in addition to
|
||
their automated one.
|
||
|
||
MSAA is left at the quality preset for GL-versus-GL self-differentials, where it
|
||
is deterministic. The V7 GL-versus-Vulkan differential must force MSAA off,
|
||
because sample positions are not specified across implementations.
|
||
|
||
| Slice | Scope | Gate |
|
||
|---|---|---|
|
||
| **V0** ✅ | Pinned RHI contract, `RecordingGpuDevice`, contract tests, this document, roadmap entry. | build + tests + contract tests |
|
||
| **V1** ✅ | GL backend: `GlGpuDevice` (no Chorizite inheritance), buffers (`BufferSubData`, behaviour-preserving), ring over the existing fence-bounded pattern, textures + the binding-9 handle table, samplers, pipelines, timers, backbuffer capture. Constructed in composition (`HostInputCameraCompositionPhase`, right after the frame-flight controller); no consumers yet. | build + tests + GL unit tests + pixel gate (trivially identical — a tripwire) |
|
||
| **V2** ✅ | Shader dialect + texture-index migration **on GL**: `uvec2 textureHandle` → `uint textureIndex`, a binding-9 handle table, `common.glsl` preamble, CPU batch-struct change. Sub-commits: V2a mesh (`d365476e`), V2b terrain (`1f1f6c08`), V2c particles (`a85743f7`). Each renderer (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) owns its own `GlBindlessHandleTable` rather than one shared `TextureCache`-owned instance — see the note below, which the per-slice commit messages elaborate on. | pixel gate per sub-commit (V2a 2.84e-05, V2b 2.49e-05 differing-pixel fraction against parent, both well under the 0.001 threshold and within the documented ~33x same-commit noise margin). V2c has no automated pixel coverage (particles are outside the offline gate's fixed view) — flagged for a user visual check. |
|
||
| **V3** | Clip-space and sRGB audit: verify every projection producer is [0,1] convention, confirm clip-plane derivation, record the sRGB swapchain decision and the depth-precision divergence class here. | pixel gate + connected lifecycle |
|
||
| **V4a** | `TextRenderer` (three fence-buffered VBO sets → ring allocations), `BitmapFont`, `DebugLineRenderer`, the UI RenderSurface upload path, `UiViewport`'s texture handoff. | pixel gate (UI-heavy checkpoints) |
|
||
| **V4b** | `GlobalMeshBuffer` + `ObjectMeshManager` onto `IGpuBuffer`; arena, LRU and ledger logic untouched. | pixel gate |
|
||
| **V4c** | **The large one.** `WbDrawDispatcher` + `EnvCellRenderer`: per-frame uploads → rings, MDI brackets → pipelines + `MultiDrawIndexedIndirect`, `ClipFrame`, `SceneLightingUboBinding`, timer scopes. `RetailAlphaQueue` untouched. | pixel gate at several checkpoints + connected lifecycle |
|
||
| **V4d** | `TerrainModernRenderer` + `TerrainAtlas`. | pixel gate |
|
||
| **V4e** | `ParticleRenderer` (after V4c — shared alpha-queue contract). | pixel gate (particle-heavy checkpoint) |
|
||
| **V4f** | `SkyRenderer` + weather. | pixel gate (dawn/dusk, day group pinned) |
|
||
| **V4g** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit |
|
||
| **V4h** | Frame-spine formalization: pass executors emit real declared `BeginPass`/`EndPass` (clears and framebuffer management move out of the spine and into pass load/store ops), flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) |
|
||
| **V5** | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 |
|
||
| **V6** | Vulkan RHI backend, dark, three sequential commits: **a** allocator/buffers/staging/rings/timeline; **b** textures/BC mips/samplers/descriptor table/render targets/MSAA resolve; **c** `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names. **Milestone: full game frame on Vulkan.** | per-commit build + tests; VK renders world, UI, paperdoll, portals |
|
||
| **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK |
|
||
| **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor |
|
||
| **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job |
|
||
| **V10** | Cutover: Vulkan default, GL reachable by env var for one slice, gate scripts default to VK. | complete Release suite + retail expected PNGs **on VK** (baselines not regenerated) + both connected routes + **user visual sign-off** |
|
||
| **V11** | GL deletion and closeout: delete `Gpu/Gl`, `OpenGLGraphicsDevice`, `ManagedGL*`, `GLSLShader`, `GLHelpers`, `GLStateScope`, `RenderStateCache`, `BindlessSupport`, `GraphicalGlFunctionProbe`, the GL branch in `GameWindow`, the ImGui project and Studio; drop the GL and (if the audit is clean) Chorizite packages; file the retained-UI dev-panels follow-up; swap CI assertions to VK; update the divergence register, architecture doc, code-structure doc, and rendering memory crib; re-measure memory. | complete Release suite + both connected routes + working-set re-measure |
|
||
|
||
### 5.2 Why V2's handle table is not the device's table
|
||
|
||
The obvious reading of V2 — "have the texture caches call
|
||
`IGpuDevice.RegisterTexture`" — does not work at V2, and the reason is worth
|
||
recording so nobody re-derives it later.
|
||
|
||
`GlGpuDevice` flushes its dirty texture table immediately before each draw it
|
||
records. At V2 the draws still go through raw GL inside `WbDrawDispatcher`, which
|
||
the device knows nothing about, so the device would never flush — the table would
|
||
be stale on the GPU. Making it work would need a manual `FlushTextureTable()`
|
||
escape hatch plus a way to bind the device's buffer from raw GL code, which leaks
|
||
the backend straight back through the seam we are building.
|
||
|
||
So V2 keeps the indirection entirely inside the existing GL world: the texture
|
||
caches own a small handle-table storage buffer at binding 9 and flush it on their
|
||
existing schedule. **V4c then deletes that interim table** when `WbDrawDispatcher`
|
||
moves onto the encoder and the device's table — with its retirement-gated slot
|
||
recycling — becomes reachable. Two small, separately pixel-gated changes beat one
|
||
entangled one; separating the data-model change from the RHI plumbing change is
|
||
precisely what de-risks V4c, the largest slice in the campaign.
|
||
|
||
**Sequencing invariants.** The app ships on GL until V10. V0→V1→V2→V3→V4a…V4h
|
||
are strictly sequential. The only permitted parallelism is V5 alongside V4d
|
||
and/or V4f (fully disjoint files), and optionally V9's `.github`/`tools`-only
|
||
work alongside V8. While V4c runs, nothing else touches `Rendering/Wb`.
|
||
|
||
---
|
||
|
||
## 6. Risk register
|
||
|
||
| Risk | Mitigation |
|
||
|---|---|
|
||
| Y-flip and winding | Negative viewport height; the front-face inversion lives in one backend mapping function; the differential catches any residue. |
|
||
| Depth-precision shift (z-fight patterns) | The only pre-approved divergence class; each instance gets a mask or per-stop relaxation plus a divergence-register row. |
|
||
| sRGB mismatch (global gamma shift) | Decided at V3 from the actual GL state; a mismatch fails every pixel at V7, so it cannot pass silently. |
|
||
| MSAA sample positions differ across backends | Strict gates run MSAA off; MSAA on gets a relaxed (0.01) visual smoke; a register row lands at V11. |
|
||
| ~15,000 lines of renderer churn destabilizing retail fidelity | CPU logic never forks; each port is self-differential on the still-shipping backend; V0 pins the contract so subagents never negotiate APIs; the architecture test prevents seam erosion. |
|
||
| Driver matrix — only one physical GPU (RX 9070 XT) | Conservative universal feature floor; lavapipe in CI as a second real implementation; one validation-layer-clean run at V7; the physical Linux row is deferred exactly as Slice L deferred it. |
|
||
| Swapchain lifecycle (resize, minimize, RDP) | Owned explicitly at V5 and exercised by the connected lifecycle gate. |
|
||
| App tests breaking as renderers change signatures | `RecordingGpuDevice` ships at V0; each slice updates its renderers' test constructions in the same commit. |
|
||
| Hidden Chorizite consumers | V1 builds the device root without Chorizite inheritance; V4h audits the remainder; the package drops at V11 only if that audit is clean. |
|
||
| `.spv` staleness | Single GLSL source, committed `.spv`, regeneration script, and a CI hash-freshness check. |
|
||
|
||
---
|
||
|
||
## 7. Execution rules for slice subagents
|
||
|
||
- Sonnet implementers by default. V0, V4c, V4h, and V6 are load-bearing — their
|
||
diffs are reviewed in the parent session before commit.
|
||
- **One agent per slice, always.** Coupled sub-slices (V2a–c, V6a–c) are
|
||
sequential commits by the same agent. Never fan out across files two slices
|
||
share.
|
||
- Every subagent prompt carries: this document's section numbers for the pinned
|
||
contract, the slice's file list, the gate definition, "build and tests green,
|
||
one commit," and the divergence-register same-commit rule.
|
||
- No slice regenerates expected retail baselines. They are immutable for the
|
||
duration of the campaign.
|
||
- Connected gates need the live ACE server and the user's machine. The visual
|
||
sign-off at V10 is a required user stop; there are no others besides gate
|
||
failures.
|