V6f ran the bring-up host once under VK_LAYER_KHRONOS_validation and found
seven VUIDs, every one of them on the path any world frame takes (plan
§5.5.7). This closes all of them, plus a fourth defect in the same log that
§5.5.7 did not call out. The host now runs validation-clean: zero errors and
zero warnings over 39,855 frames.
Nothing outside Gpu/Vk/ is touched, so the GL backend executes not one changed
statement. The offline pixel gate says so too — 4.08e-05 differing fraction
against f8dbe2ee, which is exactly the value the campaign recorded as its own
same-commit control (§5.1's 15–23 pixel band).
The dynamic-descriptor limit was a decision, not a patch. V6b declared all ten
of set 0's bindings STORAGE_BUFFER_DYNAMIC on the reasoning that the contract
lets a renderer bind any range per draw. That is true and still cost nothing to
honour for four of them: a dynamic descriptor buys exactly one thing, the
ability to address the SAME buffer at a DIFFERENT offset without a descriptor
write, which is the shape of a ring allocation and of nothing else. So the
ring-fed bindings — instances, batches, clip slots, instance light sets — stay
dynamic, and the ones pointing at a long-lived buffer written whole and bound
once per pass carry their offset in the descriptor instead. Binding 9 is the
clearest of those: it is the GL-only uvec2 handle table, which the Vulkan
backend never binds at all.
That lands on four dynamic storage descriptors. The RX 9070 XT allows eight, so
eight would have worked here — but four is Vulkan's GUARANTEED minimum, which
means no conformant device can fail this layout, and V9's lavapipe row and the
deferred physical Linux row both depend on that. The count is asserted against
maxDescriptorSetStorageBuffersDynamic in the capability record, so a device that
cannot serve it is rejected at startup in the report under the same exit-code-4
contract as every other requirement, rather than failing silently at
vkCreatePipelineLayout the way this one did.
Depth-off pipelines were malformed in any pass that has depth. Dynamic rendering
bakes the depth/stencil attachment format into the pipeline and requires it to
equal the pass's; V6c set it only when the pipeline itself tested or wrote
depth. Debug lines, the retained UI and the sky are all depth-off and all
composite over the main pass, so this was not an edge case. The same
GpuPipelineDescription is legitimately used both ways — ui-text opens its own
depth-less pass — so the description cannot answer the question and the backend
builds both variants, binding whichever matches what vkCmdBeginRendering was
actually handed rather than what the pass asked for. Both are built at startup
against the persisted cache, so no frame compiles one. A slice entitled to
change the contract should add a depth-format field the way V6d added
ColorFormat; this is the honest expression of the gap until then.
vk-backbuffer-depth and vk-backbuffer-msaa-color were created UNDEFINED and
never moved. Both now barrier on every backbuffer pass — from UNDEFINED on the
first use after Configure, from attachment-optimal with a write-after-write
dependency thereafter. The dependency matters on its own account, not just the
layout: two passes in one frame write both images and so does the next frame,
and Vulkan orders nothing between render-pass instances.
The fourth defect is the one worth reading twice. CaptureBackbuffer transitioned
the LAST PRESENTED swapchain image to TRANSFER_SRC and copied out of it. After
vkQueuePresentKHR that image belongs to the presentation engine and its contents
are not ours to read — and the pixels were usually right, which is precisely the
problem. This campaign spent three sections of its own plan (§5.5.1–§5.5.3)
discovering how much a capture instrument that is "usually right" can cost, and
shipping that shape on the new backend would have made every Vulkan PNG, and the
V7 differential built on them, formally undefined. The frame now copies its own
output into a host-readable buffer while it still owns the image, and the
capture reads that. Retention is opt-in, armed when an artifact directory
exists: one full-resolution copy per frame is worth nothing to a player and is
the entire instrument to a gate. The old one-shot command pool, device-idle wait
and per-capture readback buffer go with it.
Two gaps found and recorded in §5.5.8 rather than fixed, both outside this
slice's brief. UniformSkyParams (set 1, binding 4) is not in the uniform set
layout, so whoever first draws sky on Vulkan must add it. And a binding pointed
at two different buffers within one frame silently corrupts the earlier draws,
on dynamic and plain descriptors alike, because descriptor contents are read at
execution time — no consumer does that today, but WbDrawDispatcher and
EnvCellRenderer each own their own instance and batch buffers and both bind
bindings 0, 1, 3, 4 and 5 in one frame, so the Vulkan world arm has to know
before it is written.
Gates: Release build; App tests 4,075 passed / 3 skipped (baseline 4,073 + the
two new capability cases); GL offline pixel gate PASS at 4.08e-05; one
validation-layer Vulkan run, clean, with the captured PNG inspected and correct
in orientation, colour and glyph coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign V slice V6e, last of three. Sky was the hardest of the four pairs
because it was the only one that still worked the way a 2004 shader works: a
dozen loose uniforms pushed one glUniform call at a time, and a texture bound to
unit 0 with a sampler object chosen per submesh. Vulkan GLSL has neither a
default uniform block nor a way to declare a bare sampler, so both had to move —
and the second one had a sting in it.
The uniforms go into a `SkyParams` std140 block at uniform binding 4, the new
pre-authorized constant in GpuBindingModel (1, 2 and 3 are SceneLighting, the
terrain clip block and terrain tiling; the contract test now proves the three
constants and that literal 2 do not collide). Three matrices are 192 bytes on
their own, so the 96-byte push-constant block was never in the running. The
block's member order IS its layout: std140 aligns a vec3 to 16 bytes while using
12, so each of the three lighting vectors is followed by the float that rides in
its pad word, which is why colours and per-surface scalars interleave rather
than grouping by meaning. SkyParamsLayoutTests asserts all twelve offsets and
the 256-byte size, because getting one member wrong would read the sun direction
as a colour with no compile error, no link error and no GL error to say so.
The texture is the interesting half. sky.frag now reads through the shared table
(ACDREAM_SAMPLE_2D), and a bindless handle BAKES its sampler — so the
per-submesh Repeat-versus-ClampToEdge choice, which used to be a glBindSampler
on unit 0, becomes which slot the submesh asks for. SkyRenderer interns one
handle per (texture, wrap) pair, exactly as ManagedGLTextureArray has done since
the world path went bindless, and exactly the shape Vulkan's table has, where an
entry is a combined image sampler. Same two SamplerCache objects, same wrap
behaviour, consulted once at interning instead of once per draw. A pleasant
consequence: the sky no longer touches texture unit 0, so the load-bearing
`BindSampler(0, 0)` restore at the end of the pass — there because the binding
was global state that would otherwise force ClampToEdge on the next renderer —
has nothing left to undo and is gone.
Gates. Release build clean; App tests 4,072 passed / 3 skipped (4,057 baseline,
plus the sentinel guard from the previous commit and fourteen sky-layout
assertions). Offline pixel gate against 95f8c25f: 18 px of 563,200 compared
(3.20e-05), inside the documented 15–23 px band.
That gate masks the sky for determinism, so it proves nothing about this commit
and the sky renderer has no automated pixel coverage at all. What was done
instead: a base-versus-head offline capture at ALL SEVEN day groups, built by
stashing the change and rebuilding so the two runs differ only in this commit.
Every pair matches in gradient, cloud sheet, horizon band and fog — including
day group 2's salmon cloud band and day group 6's green one, which between them
exercise texture sampling, per-vertex tint, blend mode and fog. Then 3/3
RENDERED on the desktop-witness repeat-connected gate.
That bounds the risk; it does not close it. The offline camera is fixed and
looks down, so a thin band of dome is all it ever sees: the sun and moon
(additive, high) and the rain cylinder (the one sky mesh that surrounds the
camera, and the one whose REPEAT wrap is most visible) remain unproven. Recorded
as user-gate debt in §5.1 alongside V2c's and V4e's particles — check it by
standing outside at dawn or dusk, and by standing in rain.
Manifest: 8/9 pairs compile. `terrain_modern` is the last production pair, and
it is blocked on V4d's content rather than on dialect — details in §5.5's slice
table. `mesh` has no consumer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign V slice V6d, commit 1 of 3. The third contract amendment of the campaign, in the same shape as GpuBlendMode.InverseAlpha (V4c) and GpuVertexFormat.UByte4UInt (V4d): a slice met a wall the pinned contract could not express, and the fix is a reviewed field rather than a backend working around it.
Vulkan's dynamic rendering bakes the colour-attachment format into the pipeline. VkPipelineRenderingCreateInfo has to name it at creation, and a pipeline whose declared format disagrees with the attachment it is used with is undefined. GpuPipelineDescription named SampleCount and nothing else about the target, so slice V6c had no way to ask the question and hard-coded VulkanTextureFormatMapping.CanonicalColorAttachmentFormat for every pipeline it built. It recorded that as a real expressiveness gap rather than hiding it, and named this commit as the honest fix.
GpuPipelineDescription.ColorFormat defaults to Rgba8UnormRenderTarget, which the Vulkan backend already maps to the swapchain's B8G8R8A8_UNORM, so every pipeline written before the field existed keeps exactly the format it was getting. GL ignores the field entirely: a GL framebuffer carries its own attachment formats and a program binds to whatever is attached, so there is nothing for the GL backend to declare. The substitution that makes an offscreen Rgba8UnormRenderTarget resolve to the swapchain's byte order stays — it is what lets a backbuffer pipeline and an offscreen pipeline share one description, and it is invisible above the API because an image is sampled through its format's component mapping.
The contract test asserts both halves that matter: the default is the render-target format (so nothing moves), and the field is really settable (so naming it is not decoration).
App tests 4,057 passed / 3 skipped, up one from the 4,056 baseline. Offline pixel gate against 234fe91d: differing fraction 2.84e-05, 16 pixels of 563,200 compared, inside the documented 15-23 pixel same-commit noise band and about 35x under the 0.001 threshold.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last of V6's three commits, and the one that makes the backend render.
Plan sections: 4.5 (pipelines and the persisted cache), 4.6 (shaders and the
committed .spv), 4.7 and 3.3 (clip space, the Y flip and winding), 4.9 and 4.10
(swapchain format and the scissor convention), 4.11 (the probe shader V5
deferred), 5.4 (Target: null means the swapchain image, literally).
WHAT RUNS. ACDREAM_RENDER_BACKEND=vulkan now renders a real scene through the
whole RHI on the RX 9070 XT: 60,000-plus frames per twelve-second run, 4x MSAA
resolving into a B8G8R8A8_UNORM swapchain, GPU timer scopes resolving, a
screenshot taken through IGpuDevice.CaptureBackbuffer, and a clean
CloseMainWindow exit with the allocator reporting three device-memory objects.
WHAT IT DRAWS, AND WHY IT IS NOT THE GAME. V6's milestone is "a full game frame
on Vulkan" and on this branch that cannot be the game's own frame. V4c and V4d
are parked by 5.5.5 so the world renderers are still raw GL; and the two
renderers that DO speak the RHI - TextRenderer and DebugLineRenderer, ported at
V4a - both throw for any device that is not a GlGpuDevice, because their loose
uniforms and their classic texture-unit sprite binding have no home in the
pinned contract yet. Converting them is a V4-class change with its own GL pixel
gate, outside this slice's file list.
So the backend is exercised through the contract by a scene of our own, and it
is not a toy. It uses a device-local mesh arena filled through the staging ring,
instance and batch data written straight into mapped ring memory, an offscreen
render target whose colour is registered into the global texture table and
sampled by a later pass, a BC1 texture with a CPU-built mip chain beside an
uncompressed one with a vkCmdBlitImage chain, one multi-draw-indirect covering
five quads with gl_DrawID selecting per-draw batch data, a second pipeline with
line-list topology bound mid-pass, dynamic cull/front-face/depth-write, push
constants, timer scopes, and an MSAA colour attachment resolving into the
swapchain image.
ORIENTATION, BY INSPECTION. Slice V5's screenshot was a uniform clear and its
orientation was right "by construction" - which a uniform clear cannot show. The
scene is therefore deliberately asymmetric in both axes: a quadrant card that is
red top-left, green top-right, blue bottom-left and white bottom-right, four
differently tinted markers at four different corners, and an open L of lines
whose short stub rises at its right end. The captured PNG reads correctly in
every one of those, including a miniature of the same card in the bottom-right
whose own quadrants are also the right way up. The negative viewport height, the
front-face inversion and the capture path agree.
THE SHADER TOOLCHAIN, AND WHAT IT FOUND. tools/compile-shaders.ps1 drives
tools/ShaderCompiler, a small out-of-solution .NET tool over Silk.NET.Shaderc -
the same shaderc glslc is built on, through the already-pinned Silk 2.23.0
family. glslc is preferred when a Vulkan SDK is present and reported when it is;
neither this machine nor CI has one, and requiring a 500 MB manual install
between a contributor and a working checkout is not a reasonable price for a
build step. The GLSL sources stay the single source of truth: the Vulkan dialect
arrives as a preamble injected after the #version line - ACDREAM_UBO_SET becomes
"set = 1,", the texture table becomes a set-2 descriptor array with a required
nonuniformEXT accessor, and the shared 96-byte push block is declared with each
loose uniform name defined onto its member. The only edits to a shader BODY are
mechanical and dialect-level: dropping default-block uniform declarations, which
Vulkan GLSL has no such thing as, and assigning explicit varying locations BY
NAME across a pair, because ordinal assignment would look identical today and
silently swap varyings the first time an author reordered a line.
Run over the eight production pairs, exactly one thing happened: none of them
compiled, and every failure is a specific source-level fact belonging to a
renderer-port slice that has not landed. debug_line needs uView/uProjection
converged into one uViewProjection - two matrices are 128 bytes and the shared
block is 96. mesh_modern and particle still pass a uvec2 bindless handle as a
varying, which is V4t's GpuTextureSlot retype. sky has ten loose uniforms and
wants a UBO. ui_text needs uScreenSize/uUseTexture/uTex. particle_mesh needs
uTextureIndex to become uTextureIndexA. terrain_modern needs V4d-1's matrix
convergence. mesh is the legacy pair with no RHI consumer at all. That inventory
is committed as shaders.manifest.json, with each source's SHA-256 and the
compiler's own message, and a test re-hashes it so an edited shader that never
got recompiled fails a build rather than shipping a stale binary.
vk_probe is the pair that does compile, and it is the shader 4.11 already asked
for: V5 recorded "build one real pipeline from the committed .spv" as its single
deliberate deviation because no toolchain existed. It is Vulkan-dialect only and
no GL renderer draws with it, so it forks nothing; it retires when the ported
world renderers become the backend's own proof.
DESCRIPTORS. Sets 0 and 1 are DYNAMIC buffer descriptors bound per flight slot,
so a per-draw range change costs a dynamic offset in vkCmdBindDescriptorSets
rather than a vkUpdateDescriptorSets in the hot path - which is what keeps 4.4's
zero-writes-per-frame property true for buffers as well as for textures. Ten
dynamic storage descriptors is above Vulkan's guaranteed minimum of four, so it
is a real requirement rather than a free choice, it fails loudly at layout
creation on a device that cannot serve it, and V9's lavapipe row must confirm
it. Unused bindings point at a shared dummy range so there is ONE set layout and
one pipeline layout; that is why binding a second pipeline mid-pass costs
nothing and disturbs neither the descriptors nor the push constants.
THE ONE MAPPING FUNCTION. VulkanViewportMapping holds the whole coordinate
reconciliation: negative viewport height, the front-face inversion that pairs
with it, and - separately - the scissor flip, which the viewport sign does NOT
perform. The V3 audit flagged that as a concrete V6 acceptance item and it is
the subtle one: vkCmdSetScissor is always top-left-origin, NdcScissorRect emits
GL bottom-left rectangles, and getting it wrong clips a doorway aperture from
the wrong edge in a scene that has one. Clip space needs nothing, as 4.7
concluded: the cameras already build [0,1]-convention projections.
CONTRACT GAP, RECORDED NOT PAPERED OVER. GpuPipelineDescription cannot name its
colour-attachment format, and Vulkan bakes that into a pipeline. Offscreen
targets therefore adopt the swapchain's B8G8R8A8_UNORM rather than a literal
RGBA order - invisible above the API, because an image is sampled through its
format's component mapping and the one CPU readback swizzles explicitly. The
honest fix is a colour-format field added in a reviewed contract commit, exactly
as GpuBlendMode.InverseAlpha and GpuVertexFormat.UByte4UInt were added when V4c
and V4d met the same wall. It is documented at
VulkanTextureFormatMapping.CanonicalColorAttachmentFormat.
The pipeline cache is persisted to the cache directory and validated by its
32-byte header against this device's vendor, device and cache UUID before use.
Drivers are required to ignore incompatible blobs, but "required to" is a poor
foundation for something that runs before anything else in the process, and the
check costs 32 bytes of comparison. Two consecutive launches report "cold" then
"reused".
Gates: Release build clean; App suite 4056 passed / 3 skipped (4037 at V6b plus
19 new); offline pixel gate PASS at a differing fraction of 5.15e-05 with a
same-commit control immediately after it at 2.84e-05 - 29 and 16 pixels of
563,200, the same class of ambient variation the campaign's 15-23 band records,
and roughly 19x under the 0.001 threshold on a commit that changes no GL code
path.
Validation layers could not be run: this machine has no Vulkan SDK, no
HKLM\SOFTWARE\Khronos\Vulkan\ExplicitLayers key, no VK_LAYER_PATH and no
VkLayer_khronos_validation.json anywhere on disk. Plan 7 already requires one
validation-clean run at V7; it needs the SDK installed first and is reported
rather than assumed here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second of V6's three commits: everything the fragment stage samples. Plan
sections 4.3 (textures and mip generation) and 4.4 (descriptors).
The descriptor table is the piece that retires GL_ARB_bindless_texture. One
update-after-bind, partially-bound, variable-count combined-image-sampler array
of 16384; registration appends exactly one vkUpdateDescriptorSets and nothing is
written at draw time, so steady state is zero descriptor writes per frame. A
slot is a (view, sampler) pair, exactly like a bindless handle, which is why the
CPU data model needs no change at all - GpuTextureSlot already carries the index
and V2 already moved every batch onto it.
Eviction is retirement-gated and the slot is scrubbed on the way out. Returning
a slot the moment a texture is deleted would let the LRU alias a live draw onto
a new texture, so the release is filed through the ledger; and when it runs the
slot is first overwritten with the default 1x1 white. A stale view descriptor
sitting in a partially-bound array is legal right up until something reads it,
at which point it is a use-after-free with no error attached. Writing the dummy
makes that impossible rather than unlikely.
The CPU block-compression codec is the slice's other substantial piece, and it
exists because Vulkan cannot blit into a compressed image. DAT surfaces arrive
as DXT1/3/5 with no mips, so the chain has to be decoded, box filtered and
re-encoded here. That is not merely a substitute for the missing blit: the GL
path calls glGenerateMipmap on compressed array textures, whose result is
explicitly implementation-defined, so this is the first time that part of the
pipeline has had a defined answer.
Two properties matter more than quality, and both are tested. It is
deterministic - integer arithmetic end to end, endpoints from the block's
bounding box, nearest-palette selection, no dithering and no iterative fit -
because the offline pixel gate compares captures from separate processes and a
chain that varied run to run would make every textured surface look like a
regression. And it preserves BC1's one-bit cut-out: a block containing any texel
below the alpha threshold is encoded in three-colour mode, because retail's
foliage and grates ARE that mode and quantising those texels to an opaque colour
would fill in every leaf. Plan 4.3's escape hatch stands if quality ever trips a
gate: store the affected textures as RGBA8 and blit their mips.
Uncompressed images do take the blit chain, added to the upload queue. Each
source level moves to TRANSFER_SRC for its blit and back to TRANSFER_DST
afterwards; leaving the chain in mixed layouts would be one barrier cheaper and
would then force the batch's final shader-read transition to name a different
old layout per level, so ending every level the same way is what keeps that
transition one barrier per image.
The upload queue now records the layout each image is in on ENTRY to a batch
rather than always naming UNDEFINED. UNDEFINED lets the driver discard existing
contents, which is right for a fresh image and wrong for the incremental
array-layer fills that mirror ManagedGLTextureArray - discarding there would
erase every layer uploaded earlier.
Render targets are single-sampled per the contract and carry SAMPLED usage
alongside COLOR_ATTACHMENT, so a paperdoll or appraisal view can be registered
into the table and drawn by the retained UI the moment its pass ends.
VulkanBackbufferAttachments owns the two attachments the swapchain does not: the
multisampled colour scratch that resolves into the swapchain image, and the
transient depth/stencil. Both are TRANSIENT_ATTACHMENT because nothing reads
either after the frame. Stencil is not optional - issue #117's portal punch
needs the aspect, which is why the V5 gate prefers D32_SFLOAT_S8_UINT over a
depth-only format.
Every format stays UNORM, and that is the V3 audit's finding rather than a
default. The plan previously specified an sRGB swapchain "matching the GL
FramebufferSrgb contract"; that contract does not exist, the renderer is plain
UNORM end to end, and shipping _SRGB would have brightened every frame and
passed silently until V7.
VulkanPipelineLayouts is extracted from V5's capability probe rather than
written beside it, and the probe now calls it. The probe's whole value is
proving the layouts the live backend builds can be built on this device; two
similar-looking definitions would have quietly ended that the first time one of
them changed.
Gates: Release build clean, App suite 4037 passed / 3 skipped (4014 at V6a plus
23 new), offline pixel gate PASS against the parent baseline at a differing
fraction of 4.26e-05 - 24 pixels of 563,200, one above the campaign's recorded
15-23 same-commit noise band and about 23x under the 0.001 threshold, on a
commit that changes no GL code path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first of V6's three commits, and the half of the Vulkan backend that has
nothing to do with drawing: where memory comes from, how per-frame data reaches
the GPU, and what makes it safe to reuse either.
Plan sections: 4.2 (bindings layer and the no-VMA decision), 4.3 (memory:
arena, staging ring, per-frame data), 4.8 (sync and the frame).
The allocator is hand-rolled, roughly as 4.2 sizes it. Silk ships no VMA, and a
third-party binding would be a native binary to carry across win-x64, linux-x64
and CI lavapipe for an allocation profile that is genuinely tame: two mesh arena
buffers, one staging ring, a per-flight ring buffer each, a few render targets
and a texture pool. What a custom allocator buys instead is exact accounting -
every byte is attributable to a memory type and a block - which is what
GpuMemoryTracker will want and what VMA would obscure.
Placement, block policy and heap choice are pure types with no Vulkan handle in
sight: VulkanMemoryBlockFreeList is first-fit with coalescing on release,
VulkanMemoryTypePool decides when a request is large enough to warrant a block
of its own, and VulkanMemoryTypeSelection maps each GpuMemoryResidency onto a
preference order of property masks. VulkanDeviceMemoryAllocator turns their
answers into vkAllocateMemory and one persistent vkMapMemory per host-visible
block. That split is deliberate: an allocator's real failure modes are
arithmetic - a mis-coalesced neighbour, an alignment that eats a block's tail, a
double release that quietly corrupts the used-byte count - and arithmetic does
not need a GPU to be wrong. Twenty-two tests cover exactly those.
The HostWritable row of the selection table is the campaign's CPU win stated as
data. It prefers a memory type that is both DEVICE_LOCAL and HOST_VISIBLE -
resizable BAR, present on the RX 9070 XT - so per-frame data is written once,
straight into memory the GPU reads, and falls back to ordinary host-visible
coherent memory when no such type exists. GpuCapabilityRecord's
SupportsPersistentlyMappedRings is the first capability that is true on this
backend and false on GL.
Mapping is per block, never per allocation, because Vulkan permits a memory
object to be mapped once - mapping per buffer would need one VkDeviceMemory per
buffer, which is precisely the allocation-count explosion the design exists to
avoid.
VulkanRingBufferState is markedly simpler than its GL sibling, and the
difference IS the point. GlRingBufferState has to track a dirty watermark and
prove its upload never overlaps an in-flight read, because a ring allocation
there writes into a managed array that is later copied into a GL buffer. Here
the allocation hands back memory the GPU reads directly: there is no upload step
to track. What is left is a cursor.
VulkanUploadQueue accumulates transfers rather than issuing them, for two
reasons that both come from Vulkan rather than from taste: copies must be
recorded into a command buffer, and they must be recorded outside a
dynamic-rendering block. So requests queue and drain at the one moment both hold
- immediately before a pass begins - which is the direct analogue of the GL
backend's flush-before-every-draw discipline at the granularity Vulkan needs.
The drain emits one batched buffer barrier for the whole batch, one of the four
to six 4.8 budgets per frame.
Staging exhaustion falls back to a temporary dedicated buffer retired through
the ledger. Section 4.3 already specifies that for oversized uploads; extending
it to "the ring is full of unretired frames" is the same shape and is a policy
rather than a workaround - the transfer stays correct and ordered, it just costs
one allocation.
VulkanFrameFlightController is the mechanical port 4.8 promised. GL's array of
fences becomes one timeline semaphore whose value is the frame serial, "has this
slot retired?" becomes "is the counter at least serial minus two?", and the
SortedDictionary retirement ledger keeps its keys because those keys were
already frame serials. One subtlety is worth stating: a release is filed against
the frame currently being RECORDED, not the last one completed, because commands
already recorded into the open frame may still read the resource. A test pins
that, since getting it wrong frees memory a pending command buffer reads and the
symptom would appear somewhere else entirely.
Frame acquire ordering is the other subtlety. TryBeginFrame waits on the flight
slot BEFORE acquiring its swapchain image, so the slot's acquire semaphore is
provably idle - signalling a semaphore a pending submit still waits on is the
classic Vulkan deadlock. When the acquire fails the serial is still signalled
through an empty submit, because a serial that never completes makes every later
frame wait forever.
The device is a partial class split along the V6 commit boundary: everything
here is memory and frames, while textures and the descriptor table (V6b) and
pipelines, passes and readback (V6c) throw with the slice named rather than
returning something that fails later and further away. Nothing constructs this
device yet - VulkanBringUpHost still presents its clear colour - so the GL path
executes not one new statement.
VK_EXT_debug_utils naming arrives with the allocator rather than at V6c, because
every resource wants a name from birth and the campaign has already spent days
on defects only visible from outside the API. It stays optional: absent
extension means every call is a no-op and no call site checks.
Gates: Release build clean, App suite 4014 passed / 3 skipped (3981 baseline
plus 33 new). One Issue181WallPressEquilibriumTests failure in the full run is
the known #250 zero-allocation flake and passes on a single run. Offline pixel
gate against the parent is a tripwire here - the backend is dark and no GL code
path changed - and is reported with the slice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Instance, physical-device selection, logical device, queues, swapchain, and the
three-layer capability gate, behind ACDREAM_RENDER_BACKEND=vulkan. Nothing of
the game renders through it. OpenGL stays the default and the only live backend
until V10, and with the variable unset or set to gl the GL path executes not one
new statement.
The shape of the slice. Plan §4.11 asks the Vulkan gate to mirror the GL one
exactly - passive record, active probes, an Evaluate producing operator-facing
sentences, NotSupportedException into Program.cs's exit-code-4 contract, and an
atomic JSON report. The harder question was where to put the seam, because a
capability gate is precisely the code you cannot exercise on the machine that
already passes it: this box has one discrete GPU, so device ranking, the split-
queue path, an sRGB-only surface, a minimised window and a device missing
descriptorBindingVariableDescriptorCount are all unreachable by running the
client. So every decision the gate makes is a pure function over plain records,
and the Silk interop layer only has to be right about which Vulkan field feeds
which property. VulkanPhysicalDeviceSelection ranks candidates,
VulkanExtensionSelection does the required-versus-optional set arithmetic,
VulkanSwapchainConfigurationFactory chooses format, present mode, image count,
extent, usage, transform and composite alpha, VulkanSwapchainRecreationPolicy
classifies every acquire and present result, and
VulkanCapabilityRequirements.Evaluate turns a captured record into failure
sentences. All of it is unit-tested with no driver, no device and no window.
This commit is the integration of that work onto the post-revert tree. The V5
branch was written on b064668b, before V4c/V4d were reverted, so GameWindow.cs
had to be merged rather than taken: the file here is eb2ba4e5's GameWindow plus
V5's fifteen-line backend branch, and it keeps _terrainModernShader, which the
revert restored and which the V5 branch never had. Every other file is byte-
identical to the branch - git diff e1ef4313 over Rendering/Gpu/Vk,
tests/.../Gpu/Vk and RenderBackendKind.cs is empty, no BOM was introduced, and
CRLF is uniform across all seventeen files.
Gate results, recorded verbatim.
Release build: succeeded, 0 warnings, 0 errors.
App tests, Release: Failed 0, Passed 3981, Skipped 3, Total 3984 - the 3,866
baseline plus V5's 115 new tests, exactly.
Offline pixel gate against eb2ba4e5: PASS world-offline.png, differing fraction
1.06534090909091E-05, which is 6 differing pixels out of the 563,200 compared
after the top 280 sky rows are masked. §5.1's re-measured same-commit control
band is 15-23 pixels at fraction <= 4.1e-05, so this sits below the noise floor
rather than merely inside it - the expected result for a slice that adds no
statement to the GL path.
Vulkan check (a), ACDREAM_RENDER_BACKEND=vulkan on the RX 9070 XT with an
automation artifact directory:
vulkan: capability gate passed (Windows, AMD Radeon RX 9070 XT, Vulkan
1.4.349, vendor 0x1002, device 0x7550, driver 2.0.395 (raw 0x0080018B));
swapchain B8G8R8A8Unorm/PresentModeImmediateKhr 1280x720 x3
vulkan: device selection - automatic: 'AMD Radeon RX 9070 XT' (DiscreteGpu,
15.92 GiB device-local) ranked first of 2 enumerated device(s).
[world-gate] screenshot-complete name=vulkan-bringup path=...
artifacts\vk-bringup\vulkan-bringup.png size=1280x720
vulkan: presented 64609 clear-colour frame(s); shutting down.
Exit code 0 on CloseMainWindow. The PNG is 5,238 bytes, 1280x720, and uniformly
RGBA(11,19,39,255) - exactly ClearColor [0.043, 0.075, 0.153, 1] scaled to
UNORM. Orientation is right-side-up by construction rather than by inspection,
which a uniform clear could not show: VulkanBackbufferSwizzle.ToGlOriginRgba
writes source row y into destination row height-1-y precisely because
FrameScreenshotController flips again on the way to the PNG, so the two
cancel. That double-flip is unit-tested.
Vulkan check (b), ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore:
[ERR] acdream's Vulkan renderer is unsupported by the selected device.
Platform: win-x64, Windows, AMD Radeon RX 9070 XT (DiscreteGpu), Vulkan
1.4.349, vendor 0x1002, device 0x7550, driver 2.0.395 (raw 0x0080018B)
- timelineSemaphore is required; the frame serial is the semaphore value.
Full capability report: ...\diagnostics\graphical-capabilities-vulkan.json
Exit code 4. The report records ForcedUnsupportedFeature timelineSemaphore,
TimelineSemaphore false against an otherwise complete feature set, and the
matching SupportFailures sentence, so the injected rejection is distinguishable
from a genuinely absent feature. Both enumerated devices, all five surface
formats, all four present modes and a clean FunctionProbe with no failures are
recorded beside it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Campaign V's V4c and V4d were reverted because the connected world went blank
roughly one launch in three, with no GL error anywhere and every added CPU-GPU
sync point suppressing it. The plan's section 5.5 records the best-supported
cause and makes this change binding before either slice may re-land: the frame
ring performed 10-40 partial glBufferSubData updates per frame into a buffer
object that already-submitted same-frame draws were still reading, and the
offline path that passed every gate issues only 2-4. That is the offline versus
connected axis, stated exactly.
A partial glBufferSubData into an in-use buffer does not have one defined
implementation. The driver may stall, may rename the whole data store and copy
the untouched remainder forward, or may route the write through an internal
staging copy, and which one it picks is a heuristic fed by the update pattern.
glMapBufferRange with GL_MAP_WRITE_BIT, GL_MAP_UNSYNCHRONIZED_BIT and
GL_MAP_INVALIDATE_RANGE_BIT removes the guess. The three bits say "I am writing
this range", "I am overwriting all of it", and "nothing in flight reads it" -
which is the ring's actual invariant rather than something the driver has to
infer. GlGpuBuffer.WriteRangeUnsynchronized is that write, and the ring no
longer calls Upload at all. Upload itself stays, synchronized, for the writers
whose ordering really is the driver's job: the mesh arena and texture staging.
The unsynchronized bit is an assertion, so the two invariants behind it are now
enforced rather than merely true. Across frames it belongs to
GpuFrameFlightController, which waits on a slot's fence in BeginFrame before
GlRingBufferState.Reset rewinds that slot. Within a frame it belongs to the
allocation cursor, which only moves forward, so each flush covers bytes strictly
above every byte already flushed. GlRingBufferState now carries the flushed
high-water mark explicitly and refuses a write below it, so a future change that
reused ring bytes mid-frame fails loudly here instead of producing an undefined
read on the GPU. MarkDirty is internal for the same reason AlignUp already was:
the guard is unreachable through Allocate by construction, and proving it fires
needs a direct call.
The texture handle table moved too, because it is the only other buffer this
backend rewrites while the frame's own draws are in flight, and leaving one
partial glBufferSubData in the pre-draw flush would have left a live instance of
the same mechanism sitting inside the very function this change exists to fix.
It cannot use the ring's single merged span: two registrations in one frame can
land on slots 5 and 50 with forty-four live slots between them, and a mapped
invalidating write over that whole span would let the driver discard live
bindless handles a submitted draw is reading. GlDirtySlotRuns therefore drains
the table one run of consecutive dirty slots at a time. Every slot in a run is
safe on its own terms: RegisterTexture writes a slot fresh from the allocator
that no batch has ever indexed, and ReleaseTextureSlot's zeroing write already
runs inside a retirement callback, after the fence covering every frame that
could still reference it.
Nothing about renderer-visible behaviour changes. No renderer, no shader and no
CPU data layout is touched; only how the same bytes reach the same buffers.
SupportsPersistentlyMappedRings stays false, since a map-per-flush is not a
persistent mapping - its comment was rewritten because it claimed the backend
never writes into mapped memory, which is no longer true.
Gates. Release build green. App tests 3,862 passed / 3 skipped, against a
3,846 / 3 baseline measured on this tree plus the 16 tests added here (one
full-suite baseline run failed WorldRenderFrameBuilder's runtime-root-source
test, which passes alone and passed on the rerun - a pre-existing ordering
flake, not a regression). Offline pixel gate against 61f3c5d8: 30 differing
pixels of 563,200 compared, a fraction of 5.33e-05, nineteen times under the
0.001 threshold. Four captures were taken to bound the noise rather than assume
it: two same-commit control pairs differ by 15 and 12 pixels, and the three
cross-capture pairs by 30, 27 and 30, with comparable maximum channel deltas
throughout. The difference is capture noise in the animated surfaces, not a
rendering change.
This commit is the precondition, not the re-land. V4c follows as a
revert-of-its-revert on top of this ring, gated by the repeat-run connected gate
at ten of ten rendered.
No divergence-register row: this changes no retail-facing behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A scouting pass over V4d stopped before writing code and reported three gaps between terrain and the pinned contract. All three verified against source.
The load-bearing one: terrain_modern.vert declares locations 2-5 as uvec4 and TerrainModernRenderer feeds them with glVertexAttribIPointer, but GpuVertexFormat had no integer format and the encoder only issued glVertexAttribPointer. GL leaves an integer shader input undefined if it arrives through the float path, and Vulkan needs the format named as R8G8B8A8_UINT rather than _UNORM, so UByte4Normalized cannot stand in for it. Those packed bytes carry terrain-type, road and split-direction codes that drive every blend decision, so normalising them would have produced garbage rather than an approximation. Adds GpuVertexFormat.UByte4UInt and an integer branch in the encoder.
Also adds a uniform binding for terrain's 36-float per-layer tiling array, which at 144 bytes cannot ride in the 96-byte push-constant block or Vulkan's guaranteed 128-byte ceiling, and has no uniform-array verb to reach it otherwise.
Corrects two V4d plan rows: TerrainAtlas belongs to V4t with the rest of the texture stack, and terrain has no GPU timer to port since its diagnostics use a CPU stopwatch. The uView/uProjection convergence gets its own pixel-gated sub-commit because it moves a matrix product from per-vertex GPU evaluation to a CPU multiply, and that rounding effect should be attributable on its own.
Files #250: two zero-allocation tests fail about one run in three on an unchanged tree, independent of this campaign. That noise trains everyone to re-run until green, which is how a real regression gets waved through.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A scouting pass over V4c stopped before writing code and reported two structural blockers. Both verified against source.
The pinned V0 contract was missing a blend mode. WbDrawDispatcher.ApplyRetailBlend selects three blend functions from each DAT surface's TranslucencyKind, and InvAlpha - OneMinusSrcAlpha over SrcAlpha - had no representation. Blend is baked into the pipeline and is not dynamic, so it could not be handled at the encoder, and folding it onto StraightAlpha would have silently changed how every inverse-alpha surface composites. ParticleRenderer needs it too. The contract grows here, in one reviewed commit, rather than a slice inventing a workaround for it.
Retiring V2's interim handle table turns out to be its own slice. The renderers only intern bindless handles; the raw ulong is produced by the texture caches, baked into ObjectRenderBatch, and carried by GroupKey - the bucketing key V4c is forbidden to change - and by CachedBatch, where it gates cache validity. That is now V4t, with its own pixel gate. Until it lands, the world renderers bind their existing interim tables through the encoder as ordinary storage buffers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts ceec3bc4. Two independent reasons, either sufficient.
The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.
The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.
This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.
The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.
Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.
Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.
Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.
Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):
- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
The cache assumes it is the sole writer of GL program/blend/depth/cull
state, which was true while it had zero real consumers, but every
still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
mutates that same GL state directly and never informs the cache. Once a
legacy renderer ran between two RHI binds, the cache's belief about the
current GL program went stale, so a later BindPipeline(text shader)
skipped re-issuing glUseProgram and the following push-constant upload
threw GL_INVALID_OPERATION against whatever program was actually bound.
Reset() at the frame boundary is the same defensive move BeginPass
already makes after a forced clear (see its comment); it costs one
redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
computed from GpuPipelineDescription.SampleCount at BindPipeline time -
mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
toggle.
Collateral, scoped to keep the port real rather than a stub:
- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
slot (the device's default white texture), so the old sentinel would
have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
public AcDream.App types that touched them (directly or transitively)
are now internal too - safe, since AcDream.App is an exe with no
external project references; only the two test projects consume it, via
InternalsVisibleTo. A handful of unrelated types the sweep caught
(ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
were reverted back to public where making them internal would have
either cascaded into unrelated files or broken xUnit's public-member
discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
produce (V4g's scope) into the device's texture table for
UiViewport.TextureHandle, via a temporary
GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
conformance tests keyed to TextRenderer's old multi-resource
construction shape (Shader + per-flight FrameBufferSet array + white
texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
- that shape is gone, replaced by one IGpuPipeline created through
IGpuDevice. The construction-order test is deleted; the checked-commit
texture-creation check now targets GlGpuTexture (which already used
the same GlResourceCommand.CreateName primitive before this slice).
Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
skipped (was 3,843/3 entering this slice - net 3 fewer tests:
TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
method). Full solution: 8,908 passed / 5 skipped across all nine test
projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
vs this commit): differing fraction 0.318% (1,791/563,200 compared
pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
than waved through: a diff heatmap plus 4x crops at the differing
clusters show zero differences anywhere in the retained UI, terrain,
scenery, or static meshes - every differing pixel sits on continuously-
animated ambient content (flying-insect sprites over the swamp, foliage
sparkle/dew glints) whose exact phase depends on elapsed wall-clock
time, the same category the gate's own sky-masking rationale already
documents and the campaign doc's coverage table explicitly excludes
("Not covered - particles"). Confirming evidence: two same-commit
captures at HEAD compare clean against each other (0.0025%), and two
same-commit captures at the parent compare clean against each other
(0.0044%) - only base-vs-head is consistently elevated, which is what
frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
ring resets, the render-state reset above) would produce against a
fixed wall-clock capture deadline, not a rendering defect. Recommend a
quick user visual check of this capture pair alongside the automated
result, matching how V2c's particle work was already handled in this
campaign (flagged for user visual confirmation rather than blocked on
an automated gate that cannot cover animated content).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements GlGpuDevice and the rest of AcDream.App.Rendering.Gpu.Gl,
filling the V0-pinned IGpuDevice contract on OpenGL 4.3. This is the
first of the port slices described in
docs/plans/2026-07-27-vulkan-campaign.md: every later renderer port
(V2 onward) needs a real, driver-proven GL implementation of the RHI
to port onto, and the GL backend is deliberately built to be
behaviour-preserving rather than optimal, because that is what turns
each subsequent slice's pixel gate into a strict identity check
instead of a moving target. The Vulkan backend (V5+) is where the
actual efficiency gains land.
GlGpuDevice is a fresh root, not derived from Chorizite's
BaseGraphicsDevice/OpenGLGraphicsDevice - shedding that inheritance is
one of the things this campaign explicitly does. It owns its own
BindlessSupport instance rather than sharing the legacy WB render
path's, which is what lets it be constructed the moment a GL context
and a GpuFrameFlightController exist, with no dependency on when
WorldRenderCompositionPhase happens to detect bindless support later
in startup. The ring buffer keeps a managed staging array plus a real
GL buffer per flight slot and flushes with one BufferSubData
immediately before each Draw/DrawIndexed/MultiDrawIndexedIndirect
(never at bind time, since a renderer may still write after binding);
V1 throws on an over-capacity ring request rather than growing it,
since nothing consumes the device yet and a silent grow would hide a
future renderer's real working set. The texture table is a bump/free-
list allocator over a managed uvec2 handle array, gated through the
frame-flight retirement queue so a released slot cannot be reused
while a submitted frame might still read it. Push constants are
applied by uniform name on the currently-bound program, cached per
program, and explicitly re-applied whenever BindPipeline switches
programs - GL uniforms are per-program state, so the "survives
pipeline changes within a pass" guarantee the interface documents (a
freebie on Vulkan's shared pipeline layout) has to be emulated here.
BindlessSupport gained one additive method,
GetResidentHandle(texture, sampler), calling the same
ArbBindlessTexture.GetTextureSamplerHandle entry point
ManagedGLTextureArray already uses through a different path. The
existing GetResidentHandle(texture) cannot express
IGpuDevice.RegisterTexture's documented pair semantics ("the same
texture registered with two samplers occupies two slots"), so this
was the minimal change needed rather than a workaround.
The pure bookkeeping - ring watermark/alignment arithmetic, the
texture-slot allocator, render-state diffing, the push-constant field-
to-uniform-name table, and GL format mapping - lives in small GL-free
classes so it is unit-testable without a live context, following the
same seam pattern GpuFrameFlightController already uses for its fence
API. GlGpuTimerPool follows suit with an injectable timer-query API.
The device is constructed in HostInputCameraCompositionPhase
immediately after the frame-flight controller (the same phase that
already builds GpuFrameFlightController), rather than in
WorldRenderCompositionPhase as first considered: GlGpuDevice's self-
contained bindless detection means it has no ordering dependency on
the legacy WB path's BindlessSupport, so it can be proven against the
real driver as early as possible while keeping the composition change
to one phase. Composition, publication, and shutdown wiring follow
the existing acquire/publish/fault-injection pattern exactly, and GPU
device disposal is scheduled through the frame-flight retirement queue
before that queue itself is torn down. Nothing consumes the device
yet - that starts at V4a - so this slice's pixel gate is trivially a
tripwire.
App tests: 3834 passed / 3 skipped (V0 baseline 3785 + 49 new: ring,
texture-slot, render-state, push-constant, format-mapping, enum-
mapping, and timer-pool tests, plus one new fault-injection point in
the existing composition theory).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>