Chapter 23 Native Modern GPU APIs
Five public identities belong in this cluster, but they do not share one implementation. They share an explicit-API design vocabulary: pipelines and resource transitions are visible, shader delivery is a first-class build concern, and a successful device creation says little about which CNA features the path actually proves.
| Identity | Extended draw route | RT2D / MRT | Query | Shader boundary |
|---|---|---|---|---|
| VULKAN | native | yes / yes | yes | CNA GLSL compiled to SPIR-V |
| SDL_GPU | native with bounded colored tail | yes / yes | no real query | API-specific precompiled bytecode |
| WEBGPU | native with bounded colored tail | yes / one attachment only | no real query | WGSL and native WebGPU binding model |
| METAL | native | yes / no | no | embedded MSL compiled at runtime |
| IGL | native | yes / yes | explicit no | IGL shader stages on OpenGL or Vulkan |
The table states observed behavior as well as capability bits. SDL_GPU inherits the base null query while the default capability policy reports support. WEBGPU likewise reports MRT and query support, but rejects more than one target and has no real query object. Those are contract defects tracked by the audit, not reasons to promote a no-throw smoke test to feature evidence.
The Vulkan history illustrates a recurring defect shape: an interface accepts a complete state packet while the implementation consumes only part of it.
23.1 Historical Vulkan campaign baseline
The recorded Vulkan validation campaign reported 4,371 passes in a 4,373-case configured population, with two hardware-dependent skips; its renderer-specific ctest population passed 126 of 127. These are campaign populations, not a universal current test count; the counting model is in Chapter 69. The single remaining failure, Vulkan_DepthBias, is a narrow one: only the most extreme tested magnitude of depth bias produces no observable effect (§23.5), a symptom shared with an equivalent finding on D3D9. Historically this renderer also carried five failing BlendState tests and three segfaulting skinned-model content tests. Both were closed before the recorded campaign baseline, without test exclusions.
23.2 BlendState: native mapping and historical defect
The single most serious historical bug on this renderer, in the project’s own words, is that its blend-state implementation was “almost entirely fake”: one hardcoded blend equation was applied regardless of what BlendState actually requested, confirmed failing across five independent pixel tests before the fix. The fix replaced this with a per-Blend / BlendFunction mapping, applied consistently across all nine of this renderer’s own pipeline-creation call sites — meaning the fix had to be threaded through every place a graphics pipeline object gets built, not just one central function.
23.3 Stencil testing: another discarded-parameter defect
DepthStencilState (Chapter 16) had an almost identical history. The renderer’s state-application entry point accepted fifteen parameters but stored only two of them (the depth-enable and depth-write flags) — every stencil-related parameter passed through the call was simply discarded, and the pipeline’s own stencil-test-enable flag was never set at all, on any pipeline. Five separate, independently-run checks (the enable flag itself, the stencil masks, front-face operations, two-sided stencil mode, and reference-stencil propagation) each separately confirmed this failure mode. The fix added real per-pipeline depth-compare operations, full front- and back-face VkStencilOpState configuration, and dynamic-state reference/mask values — and, as a direct side effect of the same fix, connected ReferenceStencil’s independent-override path (Chapter 16) on Vulkan specifically. BGFX has since closed the same gap with its own dynamic rebuild and differential test; EasyGL remains the audited exception. A second, smaller bug found in the same investigation: depth format selection tried a stencil-less format before it tried any stencil-capable alternative.
23.4 OcclusionQuery: current contract and render-pass limit
Before its fix, occlusion queries on this renderer were disconnected from draw submission. The renderer defers 3D and 2D draws into a pending-batch snapshot, recorded into real Vulkan commands only once per frame, well after Begin() / End() had already returned synchronously; queries therefore always reported zero regardless of visibility. The fix resolved three design questions: how to tag an individual pending draw with the query that should observe it; whether a single query may legitimately span multiple draw calls (resolved: yes, as long as they share one render pass, tracked via contiguous-run detection, with an explicit, documented limitation that a query spanning a render-pass boundary is not correctly summed — a real capability gap against what a real device could do, stated plainly rather than silently assumed); and how per-frame query-pool resets need to be sequenced before any render pass begins. Verified in both directions — a visible quad producing a positive pixel count, an occluded quad behind a nearer opaque object producing none — plus a genuine multi-draw-span case (two non-overlapping half-quads inside one Begin / End span, summing correctly), discriminating for real in this project’s own software Vulkan implementation.
23.4.1 Using OcclusionQuery on Vulkan
The real OcclusionQuery surface is small — Begin(), End(), getIsCompleteProperty(), getPixelCountProperty() — and the standard visibility-culling pattern is exactly what the verification above tested directly:
A query may cover several draws within one render pass. The pinned Vulkan route does not sum a query correctly across a render-pass boundary. Keep render-target changes outside an OcclusionQuery’s Begin() / End() span.
23.5 Remaining limitation
One unresolved observation remains: the extreme-magnitude RasterizerState.DepthBias case produced no visible bias in the recorded environment. The same result occurred on D3D9 (Chapter 28), so the evidence does not attribute it to Vulkan rather than the shared driver environment.
23.6 Two corrected render-target black-frame defects
Historical note. docs/rendertarget-support.md still lists two black-frame defects as open. The pinned source and regressions close both; the document is historical.
The first defect was not cube-specific. VulkanRenderer::Clear() once changed only global clear values, while command recording discovered passes solely from draw calls. A clear-only target therefore never entered command recording:
The first repair added a deduplicated target list so a clear could create a pass without a draw. The current architecture has superseded that intermediate mechanism: each public Clear() is now a PendingClear carrying its target, bind-cycle segment, monotonic command order, selected aspects, values, and an owning target reference. Command recording discovers clear-only segments from that stream itself. The dedicated clear-only RenderTarget2D regression still fills two targets without a draw and samples both, while the newer ordered-clear suite proves this is no longer merely a pass-existence fix.
The second regression renders SpriteBatch content into all six faces of a RenderTargetCube, unbound, then sampled through EnvironmentMapEffect. The original regression now produces the expected blue center across five repeated runs and its registered invocation. The project did not bisect the fixing commit. A later cube-view/layout rewrite is a plausible cause, but the relevant transition is mip-count-gated while this fixture uses one level. The defect is verified closed; its fixing commit is unknown.
23.7 Clear is aspect-selective and issue-ordered
Focused attachment tests establish that colour, depth, and stencil values reach Vulkan. REMED-GFX-129 closes the ordering contract: every public clear is now an ordered command at its exact position among SpriteBatch and 3D draws, with independent target/depth/stencil flags. A leading clear on a discard cycle may fold into the render-pass load action; a clear after any draw is emitted as vkCmdClearAttachments after replaying the preceding command slice. Preserve targets use the latter path even for a leading explicit clear, so their load operation does not swallow it. ClearDepth() also records a clear-only segment rather than merely updating a global fallback value.
Two renderer-neutral suites make those details observable. The eight-mask Vulkan_GraphicsDevice_ClearOptions fixture establishes old colour/depth/stencil contents and encodes each surviving aspect into distinct colour probes. The ordered-clear fixture queues draw–clear, clear–draw, multiple-clear, backbuffer, 2D-target, cube-face, and MRT sequences without an intervening readback or present. A renderer that collapsed them into one pass-opening value would therefore fail even if its eventual clear colour were correct.
Render-target usage now follows one shared predicate: only DiscardContents maps to discard; PreserveContents and PlatformContents both map to preservation. Vulkan receives that Boolean for 2D and cube targets. Its single-sample and MSAA render-pass caches provide matching clear/load variants, and preserving depth/stencil attachments receive the layouts and store operations needed to survive a full unbind/rebind cycle. Cube colour is per face while its depth/stencil image is deliberately shared by all six faces, matching FNA. The cube-usage, per-face-MSAA, and depth/stencil-usage suites distinguish those paths, including Platform. Discard still produces the deterministic shared black/max-depth/zero-stencil bind clear, and an explicit ordered clear supersedes either usage policy. See §19.4.1.
23.8 A confirmed non-gap: device-feature gating
Not every investigation into this renderer found a bug. A dedicated check of whether optional device features (fillModeNonSolid, needed for wireframe rendering, and samplerAnisotropy) are properly negotiated before being requested found the gating already correctly implemented — both are queried via vkGetPhysicalDeviceFeatures before being enabled, with a graceful fallback if the underlying device lacks either one. This is included here deliberately: a book that only ever reports bugs found would give a skewed picture of a renderer whose overall health is, in fact, good.
23.9 Notable feature-matrix specifics
A cube-map render target’s MSAA route uses a multisampled 2D texture array and a separately cube-flagged resolve texture. Multiple render targets are capped at four by shared code mirroring FNA’s MAX_RENDERTARGET_BINDINGS; the cap does not come from Vulkan’s device limit. Texture2D uploads above mip level zero allocate and update the requested subresource, and a minification test samples its authored color. Chapter 19, §19.3.3, gives the cross-renderer proof boundary. Non- Color formats remain blocked by the shared Texture::ValidateFormat contract described in Chapter 22, not by a Vulkan-specific refusal.
23.10 Render-target mipmaps, MSAA, and viewport
Historical note. docs/rendertarget-support.md lists render-target mip generation, MSAA, and custom viewport effects as open for Vulkan. All three are implemented at the pin; those status rows predate the current source and tests.
VulkanRenderTargetRenderer::MaybeGenerateMips() runs a per-level vkCmdBlitImage cascade against resolved level-0 content after the render pass. Render-target MSAA covers both RenderTarget2D and RenderTargetCube. Each SpriteBatch batch and 3D draw captures the active viewport and applies it while replaying its render-target segment. Custom sub-regions therefore work on the backbuffer and RenderTarget2D without collapsing to the final value at Present() time. Dedicated target and deferred-capture pixel tests cover both scopes and multiple viewport changes inside one unflushed bind cycle.
Scissor follows the same per-command rule. Each queued draw retains the rectangle and ScissorTestEnable; replay clamps an enabled rectangle to the current target and expands disabled or degenerate state to its full extent before vkCmdSetScissor. The target and deferred-scissor oracles establish that later state cannot retroactively unclip earlier queued draws. Pass-opening full-target state is a default; each draw replaces it with its snapshot.
23.11 How a swapchain gets built: format, color space, and present mode
VulkanRenderer::CreateSwapchain() is where two real, easy-to-get-wrong decisions get made once, at swapchain-creation time, rather than left to chance: which pixel format the swapchain presents through, and which VkPresentModeKHR actually governs frame pacing. Both are worth reading directly rather than assumed, since Vulkan’s own API surface makes it easy to pick a format that looks equivalent but silently changes what a game sees on screen.
23.11.1 UNORM, not SRGB: a deliberate color-space decision
A physical device typically reports several supported (VkFormat, VkColorSpaceKHR) swapchain pairs, and it is common in Vulkan tutorials to prefer an SRGB-variant format (e.g. VK_FORMAT_B8G8R8A8_SRGB) for its automatic linear-to-sRGB gamma encode on every presented pixel. This renderer deliberately does the opposite, and says why directly in its own source comment: real XNA’s default backbuffer format, SurfaceFormat::Color (Appendix A), is linear — a separate, CNAEXT ColorSrgbEXT value exists specifically for the gamma-encoded variant (Appendix E) — so an SRGB swapchain format would apply an automatic gamma encode XNA itself never performed, silently darkening every frame relative to what a real XNA game produced. The fix is a preference search rather than a hardcoded pick, since not every device lists the exact pair in the same order:
The two fields answer two different questions, which is exactly the subtlety worth stating plainly: colorSpace here is VK_COLOR_SPACE_SRGB_NONLINEAR_KHR regardless — that value describes the display’s own expected color space (the near-universal default virtually every consumer monitor and every Vulkan implementation supports), not whether the swapchain itself gamma-encodes on write. format is the field that controls the encode: _UNORM writes the raw bytes a game submits with no transformation, while a sibling _SRGB format of the identical byte layout would gamma-encode automatically. Picking UNORM with the standard nonlinear color space is therefore not a contradiction — it is precisely “present to a normal monitor, but do not add an encode step XNA’s own Color format never had.” A porting engineer who free-associates “SRGB” with “correct color space, always prefer it” and swaps this one line risks introducing a real, visible darkening regression across every scene in the game, not just an edge case.
That deliberate UNORM choice is fixed renderer policy, not a response to the caller’s current BackBufferFormat. The Vulkan factory discards both presentation format ordinals: it prefers this same B8G8R8A8 UNORM pair even when public PresentationParameters reports something else, and always creates a device-selected depth image (D24S8, then D32S8, then D32) even for DepthFormat::None. Render-target depth formats use a newer, genuinely per-target selection path and must not be confused with this default-backbuffer behavior. SDL can still change the window to fullscreen; a later out-of-date/resize rebuild follows the surface size without consuming isFullScreen itself.
23.11.2 SwapInterval to VkPresentModeKHR: the same three-way switch Chapter 6 introduced
PresentationMode’s PresentInterval (Immediate / Default-a.k.a.-One / Two, Chapter 6) is not merely stored on this renderer — it selects among the device’s actually-supported present modes with an explicit fallback chain for each case, verified directly against CreateSwapchain():
FIFO is the only present mode the Vulkan specification guarantees every conformant implementation supports — choosing it as the unconditional starting value, and as every fallback’s own final fallback, is what makes swapchain creation itself never fail for lack of an exotic present mode. PresentInterval::Two’s mapping to FIFO_RELAXED is a deliberately different request from Immediate’s: FIFO Relaxed still waits for vsync when the application is keeping up, and only presents immediately (accepting a single tear) on the specific frame where the application would otherwise have missed vsync and stalled — the Vulkan-native shape of “prefer vsync, but don’t let a single slow frame cascade into a visible stutter,” not a request for tearing as a matter of course the way Immediate is. A game that sets PresentInterval::Two expecting literally half the refresh rate (the historical, D3D9-era “present interval two” reading some porting engineers carry over) will not get that on this renderer — it gets FIFO Relaxed’s own, different throughput/latency tradeoff instead, worth checking explicitly against a real target frame-rate requirement before assuming interval semantics carried over unchanged from a Direct3D background.
That switch runs only in CreateSwapchain(), from the interval captured by the renderer constructor. VulkanGraphicsRenderer does not override IGraphicsRenderer::SetSwapInterval(), so a later GraphicsDevice::Reset() stores the new public value but cannot update swapInterval_ or recreate the swapchain for it. This is especially visible through Game: its device is eagerly built with default PresentInterval::Default before the derived constructor can set GraphicsDeviceManager.SynchronizeWithVerticalRetrace=false. The subsequent ApplyChanges() therefore still leaves Vulkan in FIFO mode. Only constructing a GraphicsDevice directly with the desired parameters (or using CNA’s separate test-only full-renderer recreation helper) can currently change that initial choice. No dedicated test queries or times the real Vulkan present mode.
23.12 Two frames in flight, and the deferred-present trick that makes readback pixel-safe
Everything in §23.11 covers how the swapchain itself gets built; this section covers what actually happens every frame once it exists. This renderer double-buffers its own CPU-side work against the GPU — MaxFramesInFlight is a real, fixed constexpr int value of 2, read directly from VulkanRenderer.hpp — with one VkFence, one pair of VkSemaphores (image-available, render-finished), and one command buffer per in-flight frame, indexed by a wrapping currentFrame_ counter.
23.12.1 The ordinary present path
Present() calls the renderer’s real SubmitFrame(bool deferSwap) with deferSwap=false. Reading it directly shows five sequential steps, each with a real purpose rather than boilerplate:
The opening vkWaitForFences is the throttle that makes double-buffering safe at all: before this frame’s slot can be reused, the GPU must have finished the previous frame that used the same slot two frames ago, or the CPU would start overwriting a command buffer the GPU might still be reading. vkAcquireNextImageKHR signals imageAvailableSemaphores_[currentFrame_] when the swapchain image is actually ready to be written to (not necessarily immediately — the presentation engine may still be displaying it); vkQueueSubmit makes the GPU work wait on that same semaphore before touching the image, and signals renderFinishedSemaphores_[currentFrame_] when done; and vkQueuePresentKHR waits on that render-finished semaphore before handing the image to the presentation engine. Three separate synchronization primitives, three separate questions each one alone answers: is the CPU allowed to reuse this frame slot yet (fence), is the GPU allowed to start rendering into this image yet (image-available semaphore), and is the presentation engine allowed to display it yet (render-finished semaphore).
23.12.2 The deferred path: why GetBackBufferData cannot simply call Present
GraphicsDevice::GetBackBufferData (Chapter 12) needs a real answer to a question the ordinary present path never has to ask: are the exact pixels the GPU just rendered still readable, or has the presentation engine already taken ownership of the image and possibly begun compositing it to the screen? Reading ReadBackbuffer()’s own code comment states the race directly: “SubmitFrame(true) renders and waits for the GPU but HOLDS the present, so we read the staging buffer below BEFORE the image is handed to the presentation engine — no race possible.” The mechanism is the same SubmitFrame() used for ordinary presentation, called with deferSwap=true instead:
RecordCommandBuffer has already copied the whole swapchain image into a real staging buffer (readbackStagingBuf_) as part of the same command buffer the ordinary render work goes into — so this second vkWaitForFences call (on top of the one at the very start of SubmitFrame) blocks until that copy is guaranteed complete on the GPU side, before ReadBackbuffer maps readbackStagingMem_ and reads it directly. Only after the CPU has finished reading does ReadBackbuffer call FinishDeferredPresent(), which performs the vkQueuePresentKHR call that SubmitFrame(true) deliberately skipped — late, but still exactly once, so no frame is silently dropped from the screen because it was read back.
23.12.3 Two additional readback findings
Reading ReadBackbuffer()’s own surrounding logic in full surfaces two further details worth knowing, neither one hypothetical:
- A real read cache, not a re-render-every-call assumption.
-
A naive readback implementation might call SubmitFrame(true) unconditionally on every GetBackBufferData call — but the real code guards it behind hasNewWork || !readbackStagingValid_, where hasNewWork checks whether any pending 3D or batched 2D draws actually exist since the last present. The code’s own comment states why this matters concretely: re-presenting an already-rendered, empty-queue frame would re-render whatever Clear color is currently set and silently destroy the content every read after the first one would have returned, for any caller reading the same frame’s backbuffer more than once (a golden-image test that reads several sub-regions of one frame, for instance).
- vkAcquireNextImageKHR can genuinely fail on the very first frame.
-
A real, environment-specific comment in the same function notes that swapchain-out-of-date is “common on first frame under Wayland/RADV” — handled by zeroing the output buffer and returning, rather than throwing, so a caller can detect a blank frame and retry instead of crashing outright on a platform combination this specific to trigger.
A genuinely small but real detail rounds out the picture: swapchain images are frequently VK_FORMAT_B8G8R8A8_UNORM (per the color-space decision in §23.11), which stores channels in the opposite byte order from the RGBA layout every other renderer and Color itself use — ReadBackbuffer checks swapchainFormat_ directly and performs a real per-pixel B / R channel swap only when the format is actually BGRA, rather than assuming one fixed byte order across every device this renderer might run on.
23.13 The 512-live-descriptor-set ceiling
Every Texture2D and every RenderTarget2D constructed on this renderer allocates one VkDescriptorSet from a single shared pool, sized at construction time and never grown:
VulkanTextureRenderer’s own constructor and VulkanRenderTargetRenderer’s own constructor both allocate from this identical descriptorPool_ — confirmed by reading both call sites directly, not just the pool’s own creation code — so the real budget a game actually has is 512 simultaneously live Texture2D plus RenderTarget2D instances combined on this renderer, not 512 of each independently. The VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT flag means this is a real budget of concurrently undisposed resources, not a lifetime total: each renderer’s own ReleaseVulkanResources() calls vkFreeDescriptorSets on Dispose(), returning its slot to the pool for reuse. A game that creates and disposes textures across many frames — streaming a level’s assets in and out, for instance — never accumulates toward the ceiling as long as disposal genuinely keeps pace with creation.
The failure mode itself is a clean, loud exception rather than a silent cap or a corrupted allocation, confirmed by reading the allocation call site directly:
A 513th simultaneously-live Texture2D or RenderTarget2D throws at construction time, on this renderer specifically — not a per-frame budget, and not shared with any other renderer’s own resource limits (EasyGL and BGFX have no equivalent fixed-pool ceiling at all; each stock effect type on this same Vulkan renderer additionally has its own, separately-sized dedicated pool — descriptorPoolEnvMap_, descriptorPoolSkinned_, descriptorPoolPbr_, and others — so this specific 512-set ceiling applies only to plain Texture2D / RenderTarget2D allocation, not to how many objects can simultaneously use a given stock effect). A game targeting Vulkan specifically with a very large, non-atlased texture library, or one that creates many short-lived render targets without disposing the previous batch first, is the realistic way to reach this ceiling in practice — worth knowing as a real, renderer-specific constraint before assuming texture/render-target counts that work fine on EasyGL port unchanged to Vulkan.
23.14 Coverage gap
One honest gap in testing, not rendering: at the pinned revision there is no dedicated pixel test for SpriteBatch’s sort-mode/rotation/scale/crop/flip behavior, for SpriteFont glyph placement, or for a multi-mesh Model hierarchy on this renderer specifically — confirmed by directly searching the test suite for matching test names and finding none, despite basic SpriteBatch/2D-demo smoke tests passing. This does not mean the underlying shared C++ code is wrong (it is the same code every other renderer runs), only that this specific renderer has not had these specific features independently re-verified against its own rendering path.
23.15 METAL: a narrow implementation with explicit refusals
METAL is macOS-only, enables Objective-C++, and links Apple’s system frameworks. Its MSL lives as an embedded source string and is passed to newLibraryWithSource: at runtime. That gives it a simpler packaging story than the SPIR-V pipelines, but also makes compiler and device creation part of runtime startup.
The implementation is intentionally transparent about its present boundary. It has native extended primitive submission and RenderTarget2D, but deliberately rejects MRT, MSAA, custom effects, occlusion queries, instancing, multi-stream input, and backbuffer readback. These refusals cite missing macOS pixel evidence rather than pretending that interface presence proves rendering correctness.
Its automated macOS path proves construction, clear/present, and capability reporting; it does not yet establish representative 3D draw pixels. That is a sharper statement than calling the renderer either complete or untested. The device path executes, while much of the feature surface remains deliberately outside the claimed evidence boundary.
23.15.1 Why explicit refusal is useful
The renderer contract’s default-true capability polarity makes accidental overclaiming easy. Metal’s policy demonstrates the safer integration pattern: enumerate the unsupported surface, throw at the boundary, and widen it only when a macOS-specific observable proves the new path. Callers get an early diagnostic instead of a later blank frame.
23.16 IGL: one CNA identity over two IGL backends
IGL integrates Meta’s IGL 1.1.1 and can create an OpenGL or Vulkan device below the one CNA identity. Its renderer owns effect-aware 3D draws, sprite submission, real 2D/cube targets, two-to-four-target MRT, and volume-texture upload/sampling. It has no IGL occlusion-query API; CNA returns an immediately complete zero-sample object and reports the capability false, avoiding a spin or false GPU promise.
The backend distinction remains evidence-relevant. OpenGL and Vulkan have separate context, coordinate and resource behavior even though GetGraphicsRendererType() says IGL. The release examples bring up both backends, but their existence is not a claim that this edition reran every IGL check. Volume upload and sampling are supported; volume readback is an explicit IGL 1.1.1 gap and raises rather than fabricating voxels.
23.17 Comparing the five evidence tiers
VULKAN has the broadest renderer-specific pixel history in this group. SDL_GPU is exercised through its currently selected native driver, which is not proof of every driver SDL can choose. WEBGPU has real application and pixel evidence but retains the MRT/query capability overclaims above. METAL has a real macOS lifecycle gate while its 3D pixel surface remains explicitly unproven. IGL has backend-specific example evidence and explicit capability boundaries that must be named as OpenGL or Vulkan.
For portability work, record both axes: which CNA identity ran and which native driver or device path served it. An abstraction selecting Vulkan on one machine and Metal on another does not turn one successful run into proof of both.
23.18 Legacy WebGPU Chapter Material
WEBGPU is CNA’s fifth graphics renderer, and its youngest: the project owner explicitly lifted a former prohibition on this renderer and authorized its implementation. It targets native wgpu-native (pinned to version 29.0.1.1), selected the same way as every other renderer via -DCNA_GRAPHICS_RENDERER=WEBGPU. Its own tracking plan spans fourteen phases and over 130 individually numbered tasks — large enough that this chapter covers its trajectory rather than every task.
23.19 What “experimental” concretely means here
Unlike the four renderers covered in the previous three chapters, WEBGPU is deliberately excluded from the cross-renderer feature matrix used throughout Chapters 30–23, on the matrix’s own stated grounds that its feature surface was not yet broad enough for a meaningful side-by-side comparison at the time that matrix was written. This chapter instead draws on the renderer’s own dedicated documentation and milestone history.
23.20 The native 2D baseline
Early milestones established device/surface setup, clear/present, a working Texture2D path, vertex/index buffer uploads, and a WGSL-based SpriteBatch implementation. A 120-frame clear-and-present lifecycle test, and a manually-reviewed SpriteBatch validation scene (covering cropping, tint/alpha, rotation, flips, filtering, and all three address modes), both passed before the native 2D baseline was declared closed. The most instructive milestone here, though, is an independent one: a real, independently-developed XNA-style game (a Windows Phone Speedy Blupi port, from the mobile-eggbert sibling repository) was run against this renderer on a real desktop, reached its main menu, rendered its SpriteBatch content pixel-correctly, and played through a real animated cutscene — with progress bar and cross-fade — triggered by a simulated button click, with no WebGPU validation errors of any kind. This is a genuinely different, and stronger, kind of proof than any of this renderer’s own purpose-built test scenes: an unmodified, independently-authored game simply working.
23.21 A bug a manual screenshot review missed, and a pixel assertion caught
One specific finding is worth naming as a methodological lesson in its own right: the SpriteBatch pipeline’s blend factors did not actually match the non-premultiplied output the shader itself produced, so a translucent sprite — one with an alpha value strictly between fully transparent and fully opaque — rendered fully opaque instead. This was completely invisible to the earlier manual screenshot review covering rotation, flips, and filtering; it was only caught once a real pixel-value assertion replaced “does this look right in a screenshot.” The fix matched Vulkan’s own blend-factor pairing. This is the same lesson Chapter 13 drew from SDL_Renderer’s ignored-transform-matrix bug: a rendering pipeline can look entirely correct in every way a human screenshot review checks, and still be measurably wrong.
23.22 3D depth testing and the gap it exposed
The first 3D draw path (DrawColoredPrimitives / DrawIndexedColoredPrimitives) was verified with a genuine near/far depth-ordering test rather than trusting draw order alone. Building it surfaced a real, previously-invisible gap: applying a DepthStencilState had no effect on this renderer at all before this point — the apply-state entry point was entirely unimplemented, meaning GraphicsDevice.DepthStencilState had silently done nothing on WebGPU up to that point. From there, PbrEffect (a real glTF 2.0 metallic-roughness BRDF, ported line for line from EasyGL’s own GLSL implementation) and skinned variants of both SkinnedEffect and PbrEffect (bone-palette skinning up to 72 bones, again ported directly from EasyGL) were added and both pass their dedicated test suites in full.
23.22.1 PbrEffect: a CNAEXT metallic-roughness material
PbrEffect is worth a concrete look before moving on, since — unlike every stock effect in Chapter 15 — it has no real-XNA counterpart at all: real XNA 4.0 predates the glTF 2.0 metallic-roughness PBR model by years, so this is a CNAEXT addition ported line for line from EasyGL’s own GLSL implementation, not a port of anything Microsoft ever shipped. Its property surface mixes ordinary IEffectMatrices/IEffectLights / IEffectFog plumbing (identical in shape to every stock effect) with a genuinely new four-texture-map PBR material:
The MetallicRoughnessMap single-texture, two-channel packing above is not an implementation shortcut CNA introduced — it is glTF 2.0’s own real specified layout for this material model (green channel roughness, blue channel metallic, red and alpha unused), and PbrEffect follows it exactly so a metallic-roughness texture exported by any standards-conformant glTF pipeline can be bound directly with no channel-repacking step. This is also, concretely, the effect Chapter 38’s two independent skinning systems both connect to on this renderer: the WebGPU port adds a skinned variant of PbrEffect alongside skinned SkinnedEffect, so a glTF character rig authored with a metallic-roughness material and driven by AnimationPlayer reaches GPU skinning through this effect rather than through SkinnedEffect at all.
23.23 Render targets, environment mapping, instancing, and mip generation
RenderTarget2D support combines depth and stencil into a single format regardless of what was requested (unlike Vulkan’s newer per-render-target depth-format selection, Chapter 23), and needed a real type-safety fix when sampling a render target back as an ordinary texture. MSAA support, once implemented, initially appeared to fail its own dedicated test — but investigation found the infrastructure was already correct, and the reported failure was a defect in the test itself (a missing RasterizerState::CullNone), not in the MSAA implementation. EnvironmentMapEffect and genuine hardware instancing were added together, with instancing implemented as a real per-instance vertex stream needing no bind-group changes, unlike the effect families that do require one per draw. RenderTargetCube support surfaced a cross-cutting sprite-geometry bug that has since been fixed: the original queue path computed clip-space positions from the backbuffer’s logical dimensions even while a differently-sized off-screen target was bound. REMED-GFX-019 now derives dimensions from the current RenderTarget2D or cube face instead. Mip generation for Texture2D and TextureCube is implemented as a genuine filtered downsample (a series of full-screen-triangle render passes, since wgpu-native v29 has no direct blit-image-equivalent call). Notably, this renderer auto-regenerates the chain after every level-zero write to a plain Texture2D or TextureCube. Real XNA/FNA and the other audited plain-texture paths keep upper levels explicitly authored; SDL_GPU is the narrow exception for plain cubes, where a complete level-zero face upload also invokes its native generator, while its plain 2D and 3D textures remain authored-level resources. WebGPU’s behavior prevents undefined upper levels after a level-zero upload, but it is a deliberate timing divergence rather than an unqualified superiority: a later level-zero write can overwrite upper levels that a game authored earlier. Render-target mip generation remains the separate unbind/pass-finalization contract.
The default surface is equally independent of public presentation-format fields, but its choice is unusually visible. ConfigureSurface() prefers BGRA8-sRGB, then RGBA8-sRGB, before either UNORM variant. Thus a public Color request can accompany a native sRGB swapchain, the opposite default-color policy from Vulkan. Its fixed depth texture uses Depth24PlusStencil8. The renderer recreates it for every non-minimized surface, including when the public depth value is None. SDL performs any fullscreen window transition; the next frame detects its physical size and reconfigures the surface, but neither requested format enum participates.
23.23.1 The corrected render-target-size trap
The former sprite-geometry bug is worth walking through because it produced no error of any kind — only a subtly wrong result:
Before REMED-GFX-019, the same Draw() call intended for the target was converted as though the target were the window, silently changing its scale and placement. The current QueueSprite() instead selects the bound 2D target’s width/height, the bound cube face’s size, or the backbuffer logical viewport as three explicit cases before converting pixels to NDC.
The dedicated WebGPU_SpriteBatch_RenderTarget regression makes the distinction discriminating rather than visual-only: a backbuffer is paired with asymmetric and targets; the same destination rectangle must occupy the same target-relative pixels in both. It also covers target–backbuffer–target isolation, a non-identity SpriteBatch transform, texture orientation, and a cube face. Probe pixels inside the old half-scale rectangle but outside the correct one must remain black. The old practical prohibition on differently-sized targets is therefore no longer needed.
23.23.2 RenderTargetCube and its former silent cast failure
RenderTargetCube support (Task WEBGPU-114) landed after the plain RenderTarget2D path above, and reused most of its design directly: one shared 6-array-layer WGPUTexture (the same layout WebGPUTextureCubeRenderer uses for an ordinary sampled, readback-capable TextureCube), one shared sizesize depth/stencil texture reused across all six faces (only one face is ever bound at a time, the same choice Vulkan’s own RenderTargetCube renderer makes), and a WGPUTextureViewDimension_2D view per face for the render-pass color attachment alongside one WGPUTextureViewDimension_Cube view spanning all six layers for sampling the whole thing back later.
That whole-cube sampling view is where a real, previously-invisible bug lived. Before this task, EnvironmentMapEffect’s envMap field on this renderer was hardcoded to the concrete type const WebGPUTextureCubeRenderer* — so binding an ordinary TextureCube as EnvironmentMapEffect.EnvironmentMap worked, but binding a RenderTargetCube (a different concrete class implementing the same ITextureCubeRenderer interface) did not: the hardcoded-type cast failed silently, and the effect fell back to its own 11 white cube default instead of throwing or producing an obviously wrong image. The fix is a small shared interface, read directly from the real header:
Both concrete classes now implement IWebGPUCubeSamplable alongside their own primary interface — WebGPUTextureCubeRenderer and WebGPURenderTargetCubeRenderer alike — and the draw-dispatch code resolves through it with a single dynamic_cast rather than the old hardcoded concrete-type cast:
modules/renderers/webgpu/examples/webgpu_rendertargetcube_test.cpp’s own Check C is built specifically to prove this fix rather than merely exercise the feature: it clears all six faces of a real RenderTargetCube to blue, binds it as EnvironmentMapEffect.EnvironmentMap, draws a full-screen reflective quad, and asserts the center pixel is genuinely blue — a check that could not have passed under the old hardcoded-type cast no matter how correctly the rest of the render-target machinery worked, since the cast itself was the failure point.
The same test file’s Check D is worth walking through on its own, because it targets a different, easy-to-get-wrong question: does switching into a cube face and back correctly restore the backbuffer’s own render pass, with nothing leaking either direction?
This mirrors the identical architecture check Task WEBGPU-53/54 already established for plain RenderTarget2D in the section above, extended to confirm the same "eager flush on target switch" design generalizes correctly to a third target kind rather than being coincidentally correct for the two it was originally built against. Two scope cuts are documented honestly rather than silently: mip-chain regeneration throws for RenderTargetCube (mipMap=true is rejected, matching plain RenderTarget2D’s own precedent on this renderer), and MSAA is not implemented — MultiSampleCount is accepted and ignored, always reporting 0 honestly rather than silently pretending to honor a request it cannot fulfil.
23.23.3 Why block-compressed texture upload remains open
The “no renderer anywhere in the project does real block-compressed texture upload” gap named below is worth a fuller account on this renderer specifically, because it was genuinely investigated here rather than assumed unfixable. A direct diagnostic against this project’s own pinned wgpu-native v29.0.1.1, run on the real development GPU, confirmed the hardware side is not the obstacle at all: wgpuAdapterHasFeature(adapter, TextureCompressionBC) returned true, and requesting a device with that feature in requiredFeatures succeeded too — this machine’s real adapter genuinely supports DXT1/3/5 upload today. The actual blocker sits one layer up, and applies project-wide, not just to WEBGPU: Texture2D::FromStream() always fully CPU-decompresses DXT-compressed source bytes to plain RGBA8 before any renderer ever sees them, and the shared CNA::Internal::Graphics::ImageData struct every renderer’s texture-creation path consumes has no field at all for a surface format or a compressed-bytes flag — it is hardcoded to a plain std::vector<uint8_t> of RGBA8 pixels. A WebGPU-only workaround was considered and deliberately rejected: hand-crafting a direct IGraphicsRenderer::CreateTexture() call with raw BC1 bytes stuffed into that RGBA8-typed field would compile and might even render correctly on this one renderer, but would be reachable by no real game (nothing in the actual Texture2D loading path could ever produce it) and would misrepresent ImageData’s own documented contract — exactly the shape of shortcut this project’s own culture treats as worse than leaving the gap honestly open. Closing this for real needs a cross-renderer ImageData design change (or a parallel compressed-texture entry point) plus a matching Texture2D.cpp change to stop always-decompressing DXT source data — deliberately scoped out of any single renderer’s own task list, tracked as a dedicated follow-up instead.
23.24 Three former limitations that are no longer true
The WebGPU plan moved quickly after this chapter’s first draft, so three earlier shorthand limitations need a precise correction rather than being allowed to turn into folklore. First, an arbitrary plain Texture2D now has a real renderer-level GetData() path. That method on WebGPUTextureRenderer copies the requested mip level into a temporary MAP_READ buffer with WebGPU’s required row alignment, waits through the asynchronous map callback, and then extracts the requested rectangle. The dedicated WebGPU_Texture2D_GetData test proves three independently useful cases: an exact full-gradient round trip, a non-origin sub-rectangle, and a distinct level-one mip round trip. That is deliberately tested through IGraphicsRenderer, not merely through the public Texture2D: the latter normally returns its shared CPU pixel shadow for a plain texture, so an XNA-layer round trip alone could pass while this GPU copy path remained a silent base-class no-op.
Second, culling, scissor, and viewport state are no longer merely stored values. Culling is baked into the WebGPU 3D pipeline key and mapped to WGPUCullMode; a differential pixel test proves that a deliberately chosen winding disappears under one XNA cull mode and remains visible under the other. Scissor and viewport are genuine dynamic render-pass state via wgpuRenderPassEncoderSetScissorRect and wgpuRenderPassEncoderSetViewport. Wiring the latter exposed an otherwise-hidden logical-versus-physical-size hazard: CNA’s stored logical viewport can exceed the real swapchain dimensions. The renderer therefore clamps it at application time, which is both a WebGPU API requirement and what restored the pre-existing 2D and 3D regression tests. Every queued SpriteBatch or 3D command captures its own viewport and scissor state at the public draw call. Replay applies those snapshots dynamically through the two native setters on the backbuffer, 2D-target, and cube-face paths; consecutive commands with identical state skip the redundant native call. Thus several changes inside one deferred pass no longer collapse to the last live value. The shared deferred pixel fixtures prove the per-command result, while the WebGPU cardinality tests prove it did not require a pipeline variant, render-pass split, extra submit, or one setter call for every same-state draw. A disabled or zero scissor expands to the full current target instead of issuing an invalid zero-area rectangle.
Third, this is not a claim that every state object is complete. FillMode’s WireFrame value is stored but polygon draws now refuse it because the pinned WebGPU API offers no polygon-mode switch. The renderer stores the complete stencil configuration but has deliberately not yet baked stencil operations into its many pipeline families. The last deferral is evidence-based rather than cosmetic: the cull-mode mapping itself required a pixel test to correct an initially plausible but wrong winding interpretation, so copying a stencil front/back mapping without an equivalent differential stencil test would risk making an invisible wrongness look “implemented.”
23.25 Clear ordering and usage are consistent across target types
Each public clear enters the same ordered stream as all eleven deferred draw families, carrying independent colour, depth, and stencil flags and values. Replay partitions one bind cycle into native pass segments: leading/consecutive clears can fold into the next segment’s load actions, while a clear after a draw closes that segment so the following pass observes it at the exact public position. A discard-content target may clear its first segment as its usage permits; later explicit clears remain ordered rather than becoming one global final value. The shared clear-options and ordered-clear fixtures cover backbuffer, 2D-target, cube-face, and MRT-rejection boundaries without an intervening flush.
The shared FNA predicate maps only DiscardContents to discard; Preserve and Platform both preserve. WebGPU receives that Boolean for 2D and cube targets, and the first native segment selects clear versus load for colour, depth, and stencil together. Later segments load prior contents unless an explicit ordered clear selects an aspect. A cube face owns its colour view while all faces share the target’s depth/stencil attachment, matching the reference contract. Cube-usage and depth/stencil-usage regressions distinguish all three enums and the six faces rather than inferring behavior from a combined clear.
23.26 What remains open
Several gaps are shared with every other CNA renderer rather than unique to this one: no renderer anywhere in the project does real block-compressed texture upload (the paragraph above covers exactly why, and why it is a shared architectural gap rather than a per-renderer one), and the cross-renderer design change that would make it reachable from a real game remains larger than any one renderer task. Specific to this renderer: multiple simultaneous render targets, actual stencil-operation mapping (not merely the already-stored state and dynamic reference value), WireFrame rendering, custom SpriteBatch effects, and custom WGSL effects authored outside the stock-effect set. BlendState is no longer on that list for the one attachment WebGPU can bind: all source/destination factors and operations, dynamic BlendFactor, slot-zero ColorWriteChannels, and MultiSampleMask reach keyed SpriteBatch and 3D pipelines with dedicated differential tests. Slots 1–3 remain inapplicable until MRT exists. The two RenderTargetCube scope cuts also remain honest: mipmapped cube targets throw at construction, and their per-target MSAA request is accepted but reports zero rather than claiming an unimplemented multisampled array-texture resolve. Browser/Emscripten WebGPU is entirely out of scope so far — this renderer is native wgpu-native only.
23.27 Verification methodology, and how it differs from D3D9’s
This renderer’s own verification runs on a native automated CTest suite (gracefully skipping where no Wayland/X11 display is available) plus a battery of dedicated pixel-assertion tests per feature area, and the one independent real-game integration test described above. It is explicitly not covered by the oracle-versus-real-XNA methodology Chapter 28 describes for D3D9 / D3D11 / D3D12 — that comparison against a genuine, running XNA 4.0 reference is reserved for the three Direct3D renderers specifically, not applied here.
23.28 Legacy SDL GPU Chapter Material
SDL_GPU is a CNA graphics renderer, and one this book itself was slow to give a proper account of — Chapter 79 names that delay honestly as a gap in this book, not in CNA’s own code. This chapter closes it: a real, Linux/Vulkan-driver-verified renderer implementing the full IGraphicsRenderer contract (Chapter 19), built on top of SDL3’s own SDL_gpu API rather than a native vendor API of its own.
23.29 Why this renderer costs nothing new
SDL_gpu dispatches internally to Vulkan, D3D12, or Metal depending on platform and driver availability, and CNA’s own plan_sdlgpu.md states plainly why this renderer was worth adding on top of the hardware-backed renderers the project already had: SDL3 is already a vendored dependency of this project (third_party/SDL), and SDL_gpu.c is already compiled into every prebuilt SDL3 package this repository produces — unlike Vulkan (the Vulkan SDK/loader) or WebGPU (wgpu-native, a separate library), standing this renderer up required zero new third-party dependency, only new CNA-side code against an API already sitting in the build. The main class is CNA::Internal::Renderers::SdlGpu::SdlGpuRenderer . Its source lives under modules/renderers/sdl-gpu/src/. Build the target cna_renderer_sdl_gpu. Select this renderer with -DCNA_GRAPHICS_RENDERER=SDL_GPU. The project’s own naming table is explicit that this class name is deliberately distinct from the pre-existing SdlRenderer in SdlRenderer/ — the older SDL_Renderer 2D API and the newer SDL_gpu API are unrelated, and this project was careful not to let the similar SDL naming collide.
23.30 Precompiled bytecode only, in a fixed resource-binding order
SDL_gpu does not accept GLSL or HLSL source text at runtime the way ShaderEffect’s own public contract promises a game — SDL_CreateGPUShader takes only SDL_GPUShaderFormat-tagged precompiled bytecode: SPIR-V for the Vulkan driver, DXBC/DXIL for the D3D12 driver, MSL/metallib for the Metal driver. A further constraint worth knowing before assuming any existing shader is reusable as-is: every stage’s samplers, storage textures, storage buffers, and uniform buffers must be declared in a fixed, HLSL-style binding order (t[n] / s[n] in one address space, u[n] in another, b[n] in a third) — a different convention from the raw layout(set=, binding=) numbering the existing VulkanRenderer’s own GLSL shaders use. This means Vulkan’s compiled SPIR-V is a useful algorithmic reference for this renderer’s own shader authoring (the same lighting math, alpha-test logic, and dual-texture blend) but not directly reusable output — every shader’s binding layout had to be re-authored specifically for SDL_gpu’s own convention, confirmed directly against the real .glsl sources under this renderer’s own modules/renderers/sdl-gpu/src/shaders/ directory (23 files today, one pair per stock effect — colored3d, alpha_test3d, dual_texture3d, env_map3d, lit_textured3d, pbr3d / pbr_skinned3d, skinned3d / skinned_colored3d, and more).
23.30.1 The screenshot that exposed a Y-flip
This renderer’s own first real milestone — a textured, tinted, rotated, multi-flip sprite scene surviving a resize — is a concrete instance of a theme this book returns to repeatedly (Chapter 70): a “did it throw” test alone cannot catch a wrong-but-plausible image. SDL_gpu’s Vulkan driver flips clip-space Y internally, for cross-renderer NDC consistency reasons of its own — the vertex shader’s first, naively-ported NDC computation (gl_Position = vec4(ndc, 0, 1), the same shape every other renderer’s own sprite vertex shader already uses) compiled cleanly, ran without error, and rendered every single sprite upside down, a texture’s top row appearing at the bottom of the screen. Only a real captured screenshot caught it; the fix negates Y explicitly (vec4(ndc.x, -ndc.y, 0, 1)), confirmed against an isolated single-sprite diagnostic tool kept permanently in the tree (modules/renderers/sdl-gpu/examples/sdlgpu_diag_single_sprite.cpp, the same “keep the diagnostic, don’t throw it away once the bug is fixed” precedent Chapter 28 already covers for cna_diag_d3d12_swapchain).
23.31 SkinnedEffect’s storage-buffer workaround and push-uniform limit
Wiring SkinnedEffect through this renderer surfaced a genuine hardware/driver constraint rather than a CNA design choice: SDL_PushGPUVertexUniformData, the mechanism every other stock effect above uses to deliver its per-draw uniforms, has a real, undocumented cap of roughly 4096 bytes per slot on this renderer’s own Vulkan driver — found by a real empirical binary-search spike, not read out of any SDL3 header or release note, since the limit is not documented anywhere in SDL_gpu.h itself. A 72-bone skin palette ( bytes bytes) exceeds that cap outright, so SkinnedEffect’s bone matrices are uploaded through a real GPU storage buffer (SDL_BindGPUVertexStorageBuffers) instead of a uniform push — every other stock effect’s smaller per-draw payloads (world/view/projection, DiffuseColor, alpha-test parameters, light parameters) stay on the ordinary uniform-push path, since none of them come close to the cap. This is exactly the kind of real, hardware-shaped constraint this book asks every renderer chapter to name precisely rather than paper over with “uniforms are uploaded per draw” as if the mechanism were uniform across every payload size.
23.32 Custom ShaderEffect runtime compilation without a development package
Custom ShaderEffect support (Chapter 17) is real on this renderer too, and its own implementation is worth knowing about for a reason beyond the feature itself. Since SDL_gpu accepts only precompiled bytecode, SdlGpuEffectRenderer bridges ShaderEffect’s public GLSL-source contract to that requirement with a genuine runtime libshaderc GLSL-to-SPIR-V compile, linked directly into the renderer binary rather than merely invoked at build time. Reading SdlGpuRenderer.cpp directly shows a small, telling detail: this dev environment has no libshaderc-dev package installed, so there is no real shaderc.h to include — the renderer’s own source hand-declares the exact handful of shaderc_* C entry points it needs. These include shaderc_compiler_initialize, shaderc_compile_into_spv, and shaderc_result_get_bytes. Their extern "C" function signatures, shader-kind values, and optimization-level values are hand-authored as named constants rather than pulled from a missing header. This is a real, working engineering accommodation to a genuinely constrained dev environment, not a shortcut around the feature itself — the compiled shader still goes through the real linked libshaderc.so, only the header describing its C ABI is hand-authored instead of vendored. Building this custom-shader path also surfaced a genuine finding one layer over: the existing VulkanRenderer’s own ShaderEffect support does not actually runtime-compile GLSL text at all, despite ShaderEffect’s own documented contract implying every renderer does — a real, previously-unflagged discrepancy between one renderer’s implementation and the class’s own stated cross-renderer promise, left for Chapter 23 to account for on its own terms rather than resolved here.
23.32.1 The .cnj fixture exposes the remaining shader-compatibility boundary
The runtime compiler does not make every GLSL text accepted elsewhere in CNA portable to this renderer. The real CnjEffectTest.LoadsRealCnjFixture content test writes an ordinary .cnj effect manifest plus a GLSL ES 3.00 vertex/fragment pair: #version 300 es, unqualified stage outputs/inputs, and loose uniform mat4 projection / uniform sampler2D texture1 declarations. Under SDL_GPU, that fixture fails before a pixel is drawn. Its source is not in the SPIR-V-compatible dialect required by the runtime compiler and by the resource counts CNA supplies to SDL_CreateGPUShader.
The passing SdlGpu_ShaderEffect regression makes the required contrast concrete. Its two sources use desktop #version 450, explicit layout(location=...) qualifiers for every vertex attribute and stage I/O, and the exact fixed binding spaces this renderer declares: the fragment sampler is layout(set=2, binding=0), while the per-draw values live in a named uniform block at set=1 for the vertex stage and set=3 for the fragment stage. It compiles both stages at runtime, draws a white texture into a RenderTarget2D, and reads back the effect’s own blue-tinted value; repeating the same draw without the effect must read white. Thus the supported dialect and binding contract are proven by pixels, not merely by IsEffectValid().
This is deliberately an open compatibility gap, not evidence that ShaderEffect is a no-op on SDL_GPU. Closing it needs a separately designed decision: either migrate the shared .cnj fixture/source convention to the stricter portable dialect, or teach this renderer to translate its existing loose GLSL into the blocks, locations, and bindings SDL_gpu requires. Neither is a safe one-line relaxation; the first changes content expectations across renderers, and the second is a real shader-translation layer. The chapter therefore records the narrower, honest current contract: custom effects work when authored for SDL_GPU’s binding dialect, while the generic .cnj fixture is not yet cross-renderer portable to it.
23.33 Pipeline state is snapshotted at draw time, except where it is deliberately not
SDL_gpu graphics pipelines are immutable enough that CNA cannot simply bind an XNA state object at the end of a deferred frame and hope it represents every earlier draw. Each queued sprite or 3D draw therefore captures a RenderStateSnapshot: blend factors/functions, cull and fill mode, stencil operations/masks/reference, and the ordinary depth-test fields travel with the command that was issued under them. Pipeline lookup folds the pipeline-relevant portion into a size_t hash together with primitive topology, target color format, and sample count. This replaced an earlier hand-packed integer key, whose fixed bit budget would have become silently collision-prone as state dimensions were added.
The detail that keeps the cache both correct and finite is conditional hashing. Disabled blend does not hash its six blend factors/functions; disabled stencil does not hash operations or masks; and disabled two-sided stencil does not hash the counter-clockwise fields. Two commands that produce the same pipeline consequently share it even if their irrelevant XNA state-object fields differ, while a meaningful state change cannot reuse a pipeline with stale SDL_gpu descriptors.
The target format included in that key is not the public backbuffer-format field. Each frame queries SDL for the swapchain texture’s actual format and builds the matching pipeline. Independently, construction chooses D24S8 or D32S8 by a real device-support query and later allocates that combined attachment whenever a backbuffer frame needs it. A public DepthFormat::None does not suppress it; absence means the device supported neither combined format. Fullscreen remains an SDL-window operation outside this renderer hook, and swapchain acquisition follows the resulting window without selecting formats from the public enums.
Stencil reference itself is intentionally not pipeline state: the captured value is sent per draw through SDL_SetGPUStencilReference(), the missing call that an adversarial pixel test once exposed. A left-half stencil write followed by an Equal-reference reveal now proves that this dynamic value reaches the GPU rather than merely being stored in CNA.
Viewport, scissor, and blend constants follow the same per-command rule. Every queued draw reference snapshots the active viewport rectangle/depth range, scissor rectangle and enable bit, and blend factor; replay applies them through SDL_SetGPUViewport, SDL_SetGPUScissor, and SDL_SetGPUBlendConstants immediately before that draw. Enabled scissors are clamped to the target; disabled or degenerate ones expand to its full extent. The shared deferred-viewport/scissor suites queue several changes without a flush and cover backbuffer, 2D-target, and cube-face routes, so a final live state cannot satisfy them by accident.
Depth bias is pipeline-static in SDL_gpu 3.5 rather than dynamic. CNA normalizes both RasterizerState.DepthBias and SlopeScaleDepthBias by primitive topology, includes their bits in every affected pipeline key, and fills SDL’s constant and slope factors when creating the pipeline. The dedicated test combines structural key checks, differential depth pixels, and validation-fatal execution. Samplers likewise carry the full filter ordinal, U/V address modes, and clamped MaxAnisotropy through both cache key and native descriptor; anisotropy is no longer merely a stored capability bit.
23.34 Deferred rendering still preserves the game’s draw order
Deferring all SDL_gpu work until Present() once introduced a correctness bug hidden by the renderer’s family-specific command vectors: it rendered every 3D family first and every sprite last, regardless of the order the game issued them. A background sprite, 3D model, and HUD sprite therefore became background/model/HUD only by accident; even the opposite sprite–then–3D order was replayed as 3D–sprite. This is visible whenever later opaque or blended content should cover earlier content, not merely a performance detail.
Every Queue*Draw() and QueueSprite() call now appends a compact kind/index reference beside its family-specific command. At render time, RenderQueuedDraws() makes one chronological pass over those references and dispatches the existing per-family issue routine only when its command belongs to the current target. No sort is needed: append order is already the public draw-call order. Pipeline rebind avoidance is then tracked across all kinds rather than only sprites, so fixing correctness does not require forgetting the ordinary bind cache.
SdlGpu_DrawOrder proves both directions with no alpha arithmetic ambiguity. It renders an opaque red full-target sprite and an opaque green full-target 3D quad into separate targets with depth disabled. Sprite then 3D must read back green; 3D then sprite must read back red. The former fixed-family implementation produced red in both cases because sprites always ran last, so the pair is a discriminating regression rather than a no-crash smoke check.
23.35 Present timing belongs to the device manager, not a test-only renderer poke
The SDL_GPU tests establish a small but important rule about CNA’s initialization lifecycle. GraphicsDeviceManager defaults SynchronizeWithVerticalRetrace to true, which is the right XNA-compatible default for an ordinary game. On this project’s virtual test display, however, there is no useful vertical-retrace signal; leaving it enabled made each frame wait roughly one second. A 60- or 120-frame GPU regression test therefore consumed its CTest budget without doing more useful rendering. The correct setup, used by all 22 current SDL_GPU examples and diagnostics, is public configuration made before Game::DoInitialize() creates or resets the device:
This order matters. Earlier tests called SdlGpuRenderer::SetSwapInterval(0) directly as a local workaround. During initialization, though, the device manager converts its still-true public property to PresentInterval::One; GraphicsDevice::Reset then forwards that presentation interval to every renderer. The correct global forwarding was itself a real repair: before it landed, the reset path did not call IGraphicsRenderer::SetSwapInterval() at all. Once repaired, it rightly overwrote the private pre-initialization poke, revealing the test timeout instead of preserving an accidental ordering dependency.
For this renderer, interval zero asks SDL_gpu for SDL_GPU_PRESENTMODE_IMMEDIATE, falling back to MAILBOX if Immediate is unavailable; any positive interval selects VSync. SDL_gpu has no half-rate present mode, so XNA’s PresentInterval::Two has the same VSync outcome as One. That makes the example’s explicit false a test-environment choice, not a claim that games should globally disable synchronization: it uses the same public property a game would use, at the phase of the lifecycle where CNA will not subsequently replace it.
23.36 Two violations exposed by validation mode
This renderer now passes SDL_gpu’s debug_mode flag from CNA’s ordinary build mode: debug builds request validation and release builds do not. That deliberately modest toggle paid for itself immediately. With validation previously hardcoded off, two API-contract violations had appeared to work; with it on, each could hang the Vulkan driver rather than merely print a warning. The lesson is more useful than a generic “enable validation” recommendation: a renderer’s release-mode pixels can look plausible while resource descriptions are still invalid.
First, the MSAA implementation for RenderTargetCube used a six-layer 2D-array texture. SDL_gpu forbids multisampling on an array texture. CNA now uses one single-layer, multisampled 2D attachment for whichever cube face is currently rendering and resolves it into the actual cube texture; cycling is permitted only for that temporary attachment, never for the cube texture that must retain all six faces. Second, the initial automatic-mipmap implementation for plain Texture3D and TextureCube requested only SAMPLER usage, although SDL_gpu’s generator requires COLOR_TARGET too. Widening the usage exposed a third, deeper failure: SDL’s Vulkan path created invalid 2D views and blits for depth planes that no longer exist in smaller 3D mip levels.
That third finding is no longer open. REMED-GFX-099 removed the invented automatic generation behavior from plain Texture3D: XNA/FNA’s contract allocates an authored chain and lets each SetData(level,...) call populate the named level. The resource is therefore SAMPLER-only again, with no target views or generator blits to violate the Vulkan contract. A validation-fatal regression now covers exact level-zero and nonzero-mip round trips, partial volumes, distinguishable depth planes, forward/reverse write order, NPOT dimensions, and two alternating resources. Plain cube textures retain their separately tested per-face behavior. This sequence is why validation must be treated as evidence with scope: it first exposed tolerated violations, then forced the design back to the actual public resource contract rather than merely suppressing their diagnostics.
23.37 A pre-release render-target use-after-free
RenderTarget2D and RenderTargetCube support (with real multisampling and mip regeneration) surfaced a genuine lifetime hazard while its own test was still being written, not after: this renderer defers its actual render pass to Present() time, so a render target destroyed before that deferred pass executes was a real use-after-free the moment anything still referenced its underlying GPU state. The fix, SdlGpuRenderTarget2DState / SdlGpuRenderTargetCubeState, is a pair of shared_ptr-owned structs holding the actual GPU state independently of the SdlGpuRenderTargetRenderer / SdlGpuRenderTargetCubeRenderer wrapper’s own C++ object lifetime — a short-lived, local render target destroyed mid-Draw() now keeps its pending content alive long enough to render correctly and remain safely sampleable elsewhere the same frame. The bug was confirmed via a real segfault in sdlgpu_mrt_test.cpp before the fix landed, not merely reasoned about in the abstract.
23.37.1 A regression that makes destruction precede submission
The permanent regression test is deliberately stricter than “a short-lived target does not crash.” In modules/renderers/sdl-gpu/examples/sdlgpu_rendertarget_lifetime_test.cpp, one RenderTarget2D is created as a local variable inside a single Draw() call, cleared red, then sampled by a queued SpriteBatch draw into a still-live destination target that was first cleared blue. The local variable reaches the end of its scope before Present() invokes EnsureFrameRendered(), so the sampled draw is still only a command waiting to be submitted:
On the first frame the test reads the centre pixel of rtDest_ back and requires exact red (within its small channel tolerance), proving that the queued sampling operation really ran; a no-crash-only assertion could otherwise pass after silently omitting the draw. It then performs the same create–clear–sample–destroy pattern for 120 frames, which checks that the deferred-release queue is drained after each submission rather than merely retaining every texture until shutdown. The implementation has two necessary lifetime stages: queued targets and draw targets retain a shared_ptr to their internal state until frame rendering has finished, then that state’s destructor places its raw color, depth, and MSAA handles in pendingTextureReleases_. After a successful command-buffer submission, the renderer releases each queued handle through SDL_ReleaseGPUTexture(). SDL may still fence the physical memory internally; CNA’s narrower rule is that a recording cannot release a handle which an unsubmitted command still names.
23.38 MRT as a shader capability
GraphicsDevice::SetRenderTargets() makes the first RenderTarget2D the primary target and records every later target as one simultaneous SDL_gpu color attachment. At Present() the primary’s render pass contains SDL_GPUColorTargetInfo entries; the custom effect pipeline cache includes that attachment count, so a pipeline created for one color output cannot accidentally be rebound for two. All attachments receive the same CNA blend state, just as one GraphicsDevice::BlendState governs the whole draw rather than one state per target.
That does not mean every draw writes every attachment. CNA’s stock sprite and 3D fragment shaders declare one output, so their real, deliberately narrow MRT contract is: target zero receives the draw while targets one onward can still be bound and cleared independently. The second half of sdlgpu_mrt_test.cpp proves the broader custom-effect path by binding two fresh targets and issuing exactly one SpriteBatch draw with a #version 450 fragment shader containing layout(location=0) out vec4 outColorA and layout(location=1) out vec4 outColorB. Sampling a white texture with tint must read from A and its shader-computed channel swap from B. Those distinct readbacks rule out both an extra clear and a copied single-output result: one fragment invocation really wrote two simultaneous attachments.
MRT lifetime follows the same ownership repair as ordinary deferred targets. Each immutable PassSegment retains its primary and every secondary attachment as shared_ptr<SdlGpuRenderTarget2DState>; explicit clears operate on the segment rather than a parallel wrapper-pointer list. Destroying a public secondary wrapper therefore cannot invalidate an already-recorded bind cycle, and the render-target-lifetime regression covers the create–bind–draw/clear–destroy–present sequence.
23.39 Usage selects per-resource first-use load/store behavior
Each 2D target and each cube face begins with independent first-use colour/depth/stencil flags. The shared Discard bind records the FNA-style black/max-depth/zero-stencil clear into its new bind-cycle segment; Preserve records none. Once a pass has written the resource, later cycles load its stored aspects unless an explicit ordered clear says otherwise. MRT applies the primary cycle’s explicit colour clear to all simultaneous colour attachments while retaining each attachment’s owning state. The depth/stencil usage fixture renders distinct occlusion and stencil-gate outcomes across unbind/rebind cycles, so the result is not inferred from colour.
Cube usage now reaches construction too. A single-sample face naturally loads its cube layer; a multisampled face owns a persistent per-face attachment and uses RESOLVE_AND_STORE for Preserve so the next cycle can load its samples. Discard may use plain RESOLVE when no later segment in the frame needs them. The six-face MSAA/mip and depth/stencil usage suites cover these paths. PlatformContents follows the shared FNA predicate usage != DiscardContents and therefore preserves like Preserve; cube and depth/stencil usage fixtures assert that policy explicitly. See §19.4.1.
23.39.1 Texture3D’s cycle=true orphaned-write defect
Texture3D support on this renderer — sub-volume upload/readback plus mipmap generation — surfaced a real data-corruption bug of exactly the kind this book keeps returning to: code that compiles cleanly, throws nothing, and produces a wrong answer silently. The relevant upload operation is SDL_UploadToGPUTexture. Its cycle parameter offers a real performance trade-off SDL3’s own documentation states plainly: cycle=true swaps the texture to a fresh, separate underlying GPU resource on each call, avoiding a stall if the GPU is still reading the previous resource’s content. That is correct and desirable for Texture2D’s single-full-texture-replace SetData() path. CNA’s SDL_GPU texture renderer calls that path UpdatePixels; it deliberately retains cycle=true. The first Texture3D implementation copied that choice without accounting for a 3D texture’s multiple writes. The assumption breaks for Texture3D specifically, because its content is built up through multiple independent sub-volume and per-level SetData() calls that all need to land on the same underlying resource — with cycle=true, an earlier partial write (say, a sub-volume at ) silently orphaned itself onto an abandoned GPU resource the moment a second, later write (a sub-volume at ) followed it, reading back as zero/uninitialized rather than the real content actually uploaded.
The real regression test that caught this (modules/renderers/sdl-gpu/examples/sdlgpu_texture3d_test.cpp) is deliberately shaped to make the bug unmissable rather than merely possible to catch:
Two design choices make this test genuinely discriminating rather than accidentally passing either way: the sub-volume is off-center (a bug that only corrupted, say, the texture’s origin corner would still show up), and each Z slice gets its own distinct solid color rather than reusing one color for the whole volume — a uniform-color test cannot tell correctly -downsampled or correctly-retained content apart from an out-of-range read that happens to land on more of that same color, the identical discriminating-test principle Section 23.41’s own generated-mipmap gap needs applied to close it. The fix, confirmed against the real SdlGpuTexture3DRenderer::SetData implementation directly, is a one-word change: pass cycle=false for every Texture3D upload, leaving Texture2D’s own cycle=true single-replace path untouched and correct as-is. The identical fix (and the identical reasoning, cited by name in that renderer class’s own source comment) was applied proactively to TextureCube’s per-face uploads too, since a cube map is built up through six separate per-face SetData() calls that must all land on the same underlying resource — caught and fixed before shipping rather than found by reproducing the same bug a second time.
23.40 Forced swapchain failure preserves the queued frame
SDL_gpu distinguishes two acquisition outcomes that a renderer must not conflate. A successful SDL_WaitAndAcquireGPUSwapchainTexture() call with a null texture is a documented, non-error case such as a minimized window: CNA submits the command buffer and simply skips that frame. A false return is a hard acquisition failure. SDL forbids cancelling the command buffer after this call, so CNA submits it first and then throws with the captured SDL error. Crucially, it leaves framePending_ true: a caller which recovers the window must be able to submit the same queued clear and draws rather than silently lose them.
sdlgpu_swapchain_recovery_test.cpp proves that latter path without pretending a real device loss is easy to reproduce. On frame 10 it calls SDL’s own SDL_ReleaseWindowFromGPUDevice(), makes Present() fail, then uses SDL’s matching claim call to reclaim the identical window before calling Present() again. The test requires all five stages: nine ordinary frames, the induced throw, successful reclaim, successful presentation of the preserved frame, and 30 further frames without exception. This is a bounded, controlled proof of recovery after a hard acquisition failure; it does not claim to solve the separate lazy-proxy backbuffer readback path described below.
23.41 What remains open
Three limits are worth stating precisely rather than glossing over. First, occlusion queries are a permanent limitation of the underlying API, not a task gap: the vendored SDL_gpu.h has no query-pool or occlusion-query type anywhere in it (only SDL_GPUFence for CPU/GPU synchronization), so CreateOcclusionQuery() correctly returns nullptr on this renderer, the identical posture HEADLESS and SOFTWARE already take for their own, unrelated reasons — worth re-opening only if a future SDL3 release adds real query support, not before. Second, the generic .cnj custom-effect fixture remains a cross-renderer portability gap: its loose GLSL ES 3.00 declarations are not accepted by this renderer’s explicit-location, uniform-block binding contract, even though the dedicated #version 450 effect regression is pixel-verified. That choice is deliberately left open pending either a shared content-dialect migration or a real shader-translation layer. Third, hardware instancing is absent at a different layer: this otherwise 3D-capable renderer does not override DrawInstancedPrimitivesEx(), so every valid public DrawInstancedPrimitives() call reaches the common std::runtime_error default. SDL_gpu itself exposes per-instance vertex input; the gap is CNA-side pipeline/binding work, not a permanent underlying-API limitation. The public GraphicsCapability::Instancing entry now exists, but this renderer does not override the permissive base policy and therefore incorrectly reports it as supported. This is a capability overclaim: callers cannot safely use the bit as a guard until SDL_GPU either implements the draw route or returns false explicitly (Section 19.3.2).
Five former entries no longer belong in this list. Depth bias and slope-scale bias are normalized per polygon topology, hashed into every relevant pipeline key, and applied through SDL’s pipeline-static rasterizer fields; a dedicated pixel/validation test covers them. MaxAnisotropy is part of the complete sampler key and native descriptor. Secondary MRT attachments are retained as shared_ptrs in the immutable pass segment, closing the raw pointer lifetime hazard. Finally, ordinary Texture2D allocates its declared native mip count and overrides UpdatePixelsLevel; the native-allocation/upload regression proves that a nonzero level reaches the GPU resource. None of these closures adds automatic downsampling to plain Texture2D: its upper levels remain explicitly authored, as in the XNA/FNA contract.
Backbuffer readback is also closed without charging games that never use it. SDL’s swapchain texture remains permanently write-only, but the first GetBackBufferData() lazily enables a self-owned, swapchain-sized proxy. The pending frame renders into that proxy, one 1:1 blit presents it, and ReadBackbuffer() downloads the requested logical rectangle through a transfer buffer and fence, converting BGRA to public RGBA when needed. Before the first read the proxy and blit do not exist. First-read, whole-logical-dimension, and fully-written-range tests cover the observable contract.
Beyond these three remaining limits, Windows (D3D12 driver) and macOS/iOS (Metal driver) remain code paths only, not validation claims, until run on real (or Wine/DXVK-class) hardware — the same phased-claim discipline this book already applies to D3D11 / D3D12 (Chapter 28) and WebGPU (Chapter 23.18).
23.42 Clear state is per segment and issue-ordered
This renderer carries independent colour, depth, and stencil requests on each backbuffer, 2D-target/MRT, or cube-face segment. Consecutive clears before any draw coalesce safely into the segment’s load action, aspect by aspect. Once a draw has made ordering observable, the next clear opens another segment over the same destination; the following native pass loads unselected aspects, clears selected ones, and then replays later draws. MSAA segments use RESOLVE_AND_STORE whenever a later segment must load their samples.
The renderer-neutral clear-options and ordered-clear fixtures distinguish isolated aspects, draw–clear versus clear–draw, multiple clears, and backbuffer/2D/cube/MRT destinations without an intermediate flush. Pass-boundary and backbuffer-order suites separately prove that target rebinding and target–backbuffer interleaving remain in public order. Deferred submission is therefore an implementation strategy, not a remaining clear-order exception; the comparative matrix is §19.4.2.
23.43 Verification methodology
The executable claims above have dedicated tests rather than resting on code reads alone. Counting the actual module-local registrations directly (modules/renderers/sdl-gpu/examples/CMakeLists.txt) rather than trusting an old plan snapshot shows 83 SdlGpu_* CTest registrations today, spanning the 2D and 3D smoke tests, every stock effect (including EnvironmentMapEffect, SkinnedEffect, and the CNAEXT PbrEffect this book’s WebGPU chapter already introduced), custom ShaderEffect, every render-target variant (2D, cube, multiple render targets, MSAA), both extra texture types, sampler/render state, draw order, and swapchain recovery, plus the render-target-lifetime regression above — run, like every other renderer’s own CTest suite, against this project’s own cna_register_renderer_test() convention, gracefully skipping with a clear reason rather than hard-failing when no real DISPLAY / WAYLAND_DISPLAY is present, the identical convention D3D11 / D3D12 / WebGPU already follow. All verification to date is against this project’s own real dev-machine GPU via SDL_gpu’s Vulkan driver, the only driver this project’s own prebuilt SDL3 packages compile in on Linux — the same single-driver-verified, other-drivers-unverified posture this chapter’s own “What remains open” section already names honestly rather than implying broader coverage than actually exists.