acdream/docs/plans/2026-07-27-vulkan-campaign.md
Erik 79ee2361ad docs(render): close Campaign V slice V4a and file #249
V4a landed on the second attempt at 096dd203, verified independently: offline pixel gate passing at 22 differing pixels against an 8-26 same-commit noise band, App suite at exactly the 3,843/3 baseline, 26 files touched, no encoding damage. Three audits of the reverted first attempt found defects that outlive it - resident bindless handles never released, no test coverage for the Multisample state dimension, and no encoding guard - now tracked as #249.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 19:41:08 +02:00

41 KiB
Raw Blame History

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 08 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), matching GL exactly. There is no sRGB anywhere in the pipeline — not on upload, not in the shaders, not at the framebuffer (V3 audit, §4.10). 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 1114 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_DrawIDARBgl_DrawID; gl_BaseInstanceARB + gl_InstanceIDgl_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 46 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_UNORM — see §4.10, this was corrected at V3 and is the single highest-severity finding of the audit. 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 V3 audit findings (2026-07-27)

Slice V3 audited every projection producer, every depth-range assumption, the clip-plane derivation, the sRGB path, and MSAA control. The central claim held — but one plan assumption was wrong, and catching it is why V3 exists.

Confirmed. Every projection that reaches a shader is built by Matrix4x4.CreatePerspectiveFieldOfView (world, portal tunnel, paperdoll, appraisal cameras; terrain, mesh, particles, debug lines and sky all consume the same matrices). There are no orthographic projections in production code at all — the retained UI's ui_text.vert converts pixel coordinates straight to NDC with a constant z = 0, so V4a has no matrix to convert, only a Y-sign to check. So: no projection rework, exactly as designed.

SkyProjection.WithDepthRange is the only hand-written matrix edit, assigning M33/M43 directly. It re-derives the same D3D-convention near/far mapping (it even throws on an orthographic input) rather than a GL-style 2/(f-n) scale — correct, but the sharpest edge in the codebase and a required cross-check at V6.

Phase U.3's clip planes are derived and consumed entirely in clip space with plane.z always 0, so they are insensitive to both the depth convention and the viewport Y flip. No change needed.

Corrected — sRGB. The plan previously specified a B8G8R8A8_SRGB swapchain "matching the GL FramebufferSrgb contract." That contract does not exist. EnableCap.FramebufferSrgb is enabled only inside the throwaway 2×2 capability probe (GraphicalGlFunctionProbe.cs:419-429) and disabled immediately; it is never enabled on the real backbuffer. No texture is uploaded in an sRGB internal format (TextureFormatExtensions has none), and no shader performs any gamma conversion. The renderer is plain UNORM end to end. The correct Vulkan swapchain format is B8G8R8A8_UNORM; shipping _SRGB would have applied an unwanted encode to already-display-space values — a global brightening across every frame, and precisely the failure mode §6 lists as "cannot pass silently." It would have passed silently right up to V7.

Separately: the capability gate requires sRGB-framebuffer support that the renderer never uses. Harmless today, but the Vulkan gate must not carry the stale requirement forward.

MSAA. ACDREAM_MSAA_SAMPLES overrides the quality preset (QualityPreset.cs:43-59) and 0 forces MSAA off, but it is read at window creation and cannot change mid-session. The V7 differential script must therefore launch both backends with ACDREAM_MSAA_SAMPLES=0 rather than toggling a setting.

Two concrete acceptance items carried to V6/V7.

  1. Scissor Y convention. NdcScissorRect.ToPixels emits GL bottom-left-origin pixel rectangles. Vulkan's vkCmdSetScissor is always top-left-origin — the negative viewport height does not flip the scissor. The contract already says callers keep GL convention and the backend converts (IGpuPassEncoder.SetScissor), so the Vulkan encoder must do that flip. A scissored aperture — a doorway — is the right differential-gate target.
  2. FrustumCuller near plane extracts col4 + col3, the GL [-1,1] Gribb- Hartmann formula, against [0,1]-convention matrices; the correct extraction is col3 alone. Proven over-inclusive rather than over-culling, so it is not a visibility bug, and it is pure CPU math untouched by the backend swap. Filed as a tracked issue rather than fixed here — it is not Campaign V's scope.

4.11 Capability gate

Mirrors the GL three-layer shape exactly — passive record, active probes, an Evaluate that throws NotSupportedExceptionProgram.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, V4aV4g) 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 textureHandleuint 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 PrivateEntityViewportRendererIGpuRenderTarget; 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 (V2ac, V6ac) 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.

7.1 Rules learned from the V4a revert (2026-07-27)

The first V4a attempt (ceec3bc4) was reverted at 9aaf97e7. Three rules come out of it, binding on every remaining slice.

1. During the transition, an RHI pass must not leak GL capability state. Every world renderer is still raw GL until V4c/V4d, so they inherit whatever capability state the previous pass left enabled. V4a deleted TextRenderGlStateScope — which saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE, disabled them for the text pass, and restored them on exit — and baked that state into a pipeline instead, with nothing restoring it. The world then drew without multisampling from the first UI frame on, changing the silhouette edge of every object in the scene.

So: GlGpuPassEncoder.Dispose saves and restores the capability state its pipelines change, for as long as raw-GL renderers coexist. This is not a workaround; it is what keeps the GL backend's stated behaviour-preserving property true at a seam where two worlds meet. It is deleted at V4h once nothing raw-GL remains. For the same reason, the GL render-state cache must be reset at BeginPass, not merely per frame — a raw-GL renderer running between two RHI passes in the same frame desynchronises it just as effectively.

This is the third time the project has hit this exact class: see the memory notes on self-contained render state and on issue #52, where an earlier migration lost cull state the same way. Audit per-pass GL state before declaring a port done.

2. A failing gate blocks the commit. The pixel gate failed at 0.318% against a 0.001 threshold and the slice committed anyway, attributing the difference to ambient animation. The control refuted it: same-commit captures differ by 819 pixels at both commits, versus 1,791 across the change. If a gate fails, either find the root cause or stop and report — never rationalise past it, and never relax the threshold.

3. Stay inside the slice's file list. The brief was ~10 files; the commit touched 334, including 323 public-to-internal conversions and 55 test files, and retired two conformance tests. Out-of-scope churn makes a diff unreviewable and forces revert of good work along with bad. Do not change type visibility, do not delete or weaken tests, and do not refactor adjacent code. If the slice genuinely cannot land without one of those, stop and report instead.

Outstanding hardening from the V4a audits. Three independent audits of the reverted attempt found defects that outlive it and are tracked as #249:

  1. GlGpuDevice.ReleaseTextureSlot frees the table index but never calls BindlessSupport.MakeNonResident. Deleting a texture whose handle is still resident is undefined under GL_ARB_bindless_texture, and every released slot leaks a resident handle for the process lifetime. This is V1 code, present on the current tree.
  2. No test covers the Multisample render-state dimension. Mistyping the comparison in GlRenderStateCache would leave the whole suite green — the very regression that reverted V4a.
  3. There is no .editorconfig charset rule and no .gitattributes text rule. The first attempt silently re-encoded 259 files and corrupted non-ASCII text in 116 of them, and no gate noticed.

Pre-approved transitional seam. The retained UI draws the paperdoll and appraisal viewport textures, which are produced by renderers that stay raw GL until V4g. The GL backend may therefore expose a documented way to register an externally-owned GL texture as a table slot, used only by that path, removed at V4g. Approved here so a slice does not have to invent it mid-implementation — which is what turned it into an undocumented escape hatch the first time.