feat(render): Campaign V slice V0 — pin the Vulkan-shaped RHI contract
Campaign V migrates the renderer from OpenGL 4.3+extensions to a single
Vulkan 1.3 backend on Windows x64 and Linux x64, then deletes the GL path.
Motivation is compatibility and efficiency, not rescue: mandatory
GL_ARB_bindless_texture is the exact floor that parked Slice L (Mesa
D3D12/llvmpipe lack it) while Vulkan descriptor indexing is core, and
per-frame data can be written straight into mapped memory rather than
copied through BufferSubData.
V0 pins the contract every later slice codes against. Nothing consumes it
yet, so this commit changes no runtime behavior.
The seam is a minimal Vulkan-shaped RHI implemented FIRST on GL. That
ordering is the point: the twelve renderers then port one at a time under a
strict pixel gate on the still-shipping backend, so a divergence is
attributed to one slice instead of surfacing at a big-bang integration.
Duplicating renderers per backend was rejected because WbDrawDispatcher is
4,449 lines holding only ~62 GL call sites — the API surface is small and
the retail-fidelity CPU logic is large, and forking the latter is how subtle
regressions enter.
Contract highlights:
- GpuBindingModel pins set/binding numbers dual-legal for GL and Vulkan
GLSL. Storage bindings 0-8 keep today's shader numbering; UBOs move to
their own set, which resolves the binding=1 collision GL only tolerates
because it keeps SSBO and UBO tables separate.
- GpuRingAllocation is a ref struct replacing every per-frame
BufferSubData; the compiler forbids outliving the owning frame.
- GpuTextureSlot replaces bindless handles. Unassigned is a loud
uint.MaxValue sentinel rather than a silent resolve to slot 0 — the
failure mode behind the magenta 1x1 UI placeholder bug. Renderers
needing a fallback take the device's really-registered default slot.
- Renderers always speak GL winding/viewport conventions; the Vulkan
backend compensates with a negative viewport height in exactly one
mapping function.
Verified while writing the plan: acdream's cameras already build
[0,1]-NDC projections (PortalProjection.cs:12-13), which is Vulkan's
convention. No projection rework is needed and depth precision improves,
at the cost of shifted z-fight patterns — the one pre-approved divergence
class, registered per instance at V7.
Gate: Release build green; App suite 3,785 passed / 3 skipped (3,763
baseline plus 22 new contract tests). Note for later slices, recorded in
the plan: run the suite in Release. LandblockBuildOriginTests'
far-strip test asserts behavior that LandblockStreamer.cs:505 deliberately
turns into a loud Debug.Assert in Debug builds, so a Debug run shows one
pre-existing failure that is not a regression.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f6275f4501
commit
621b16364b
17 changed files with 2577 additions and 0 deletions
|
|
@ -14,6 +14,26 @@ correctly report that Mesa D3D12/llvmpipe lacks mandatory bindless textures.
|
|||
A supported physical Linux AMD/NVIDIA driver row remains the first gate when
|
||||
Slice L resumes; the physical gate and L2–L6 are deferred.
|
||||
|
||||
**Campaign V — OpenGL → Vulkan (active, started 2026-07-27):** the renderer
|
||||
migrates to a single Vulkan 1.3 backend on Windows x64 and Linux x64, and the
|
||||
OpenGL backend is deleted at the end. The plan is
|
||||
[`2026-07-27-vulkan-campaign.md`](2026-07-27-vulkan-campaign.md). Motivation is
|
||||
compatibility and efficiency, not rescue: mandatory `GL_ARB_bindless_texture` is
|
||||
the exact floor that parked Slice L (Mesa D3D12/llvmpipe lack it), while Vulkan's
|
||||
descriptor indexing is core, and per-frame data can be written straight into
|
||||
mapped memory instead of copied through `BufferSubData`. Method follows the
|
||||
I5→I6→I7 precedent: a Vulkan-shaped RHI implemented **first on GL** so each of the
|
||||
twelve renderers ports one at a time under a strict pixel gate on the still-
|
||||
shipping backend, then a Vulkan implementation of the same contract, a
|
||||
GL-versus-Vulkan differential, a perf gate, cutover, and deletion. Slices V0–V11.
|
||||
V0 pinned the contract (`src/AcDream.App/Rendering/Gpu/`, `RecordingGpuDevice`,
|
||||
55 contract tests). The ImGui developer stack and UI Studio are **not** ported and
|
||||
are deleted at V11; re-homing the dev panels onto the retained UI is a tracked
|
||||
follow-up. Targets versus the GL baseline (520 FPS, CPU/GPU p50 1.869/1.096 ms,
|
||||
652 MiB working set): CPU p50 ≤ 1.60 ms, GPU p50 ≤ 1.00 ms, working set
|
||||
≤ 600 MiB, 0 B/frame managed allocation — with parity on all four as the cutover
|
||||
floor.
|
||||
|
||||
---
|
||||
|
||||
## Current program: world interaction completion (M4 prelude)
|
||||
|
|
|
|||
458
docs/plans/2026-07-27-vulkan-campaign.md
Normal file
458
docs/plans/2026-07-27-vulkan-campaign.md
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
# Campaign V — OpenGL → Vulkan rendering migration
|
||||
|
||||
**Status:** Active. V0 (pinned RHI contract) 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).
|
||||
|
||||
"Pixel gate" means: capture the deterministic checkpoints from the parent
|
||||
commit's build, capture again at slice HEAD, compare with the
|
||||
`compare-screenshots` CLI at tolerance 2 / fraction 0.001, MSAA off,
|
||||
`ACDREAM_DAY_GROUP` pinned.
|
||||
|
||||
| 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, the ambient-encoder relaxation, timers, `IGpuFenceApi` re-home, backbuffer capture. Constructed in composition; 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`, binding-9 table, `common.glsl` preamble, CPU batch-struct change, caches registering into the device table. Sub-commits: V2a mesh, V2b terrain, V2c particles. | pixel gate per sub-commit |
|
||||
| **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 `BeginPass`/`EndPass`, the ambient relaxation is removed, 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 |
|
||||
|
||||
**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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue