SDL_GPU uploads, render-target lifetime and swapchain recovery

CNA snapshot 009d40f5  ·  Deep Dives › Renderers  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. No SDL_GPU test was executed for this page. CNA's routine SDL_GPU evidence is SDL_gpu's Vulkan driver on Linux; Direct3D 12 evidence is a Wine and vkd3d-proton probe, and no Metal run is recorded.

The SDL_GPU renderer records every draw on the CPU and only acquires a command buffer and a swapchain texture when the frame is flushed. This page explains what that means for resources: which contract violations SDL_gpu's validation mode exposed and how they were resolved, when an upload may swap a texture's backing memory, how a render target can be destroyed before the frame that samples it is submitted, what multiple render targets really write, how usage chooses load and store actions, and how a failed swapchain acquisition keeps the queued frame. The companion page SDL_GPU shader intake, pipeline keys and draw order covers shaders, pipeline state and presentation timing.

What validation mode exposed

The renderer passes SDL_gpu's debug_mode flag from CNA's ordinary build mode: a build without NDEBUG asks for validation (on the Vulkan driver, the Khronos layer) and a release build does not. The toggle mirrors the Direct3D 11 renderer's debug-layer rule: validation is a debug-build convenience, never a hard requirement. While validation was hard-coded off, two contract violations had appeared to work; with it on, each could hang the Vulkan driver rather than merely print a warning. Release-mode pixels can look plausible while resource descriptions are still invalid.

  • Multisampled cube faces. The first MSAA implementation for RenderTargetCube used a six-layer multisampled texture. SDL_gpu's validation forbids a sample count above one on any array texture, and a cube type has no multisampled variant. The next design used one single-layer multisampled 2D texture for whichever face was rendering; it could not preserve content, because its previous samples belonged to another face, so it had to be cycled on every pass, and cycling is illegal together with SDL_GPU_LOADOP_LOAD. At this snapshot (REMED-GFX-141) each face owns its own single-layer multisampled 2D texture, which resolves into that face's layer of the single-sample cube texture through resolve_texture and resolve_layer when the pass ends; nothing is cycled, and a preserving face stores its samples with SDL_GPU_STOREOP_RESOLVE_AND_STORE for the next cycle.
  • Automatic mipmaps on plain textures. An early implementation gave plain Texture3D and TextureCube an automatic mip generator but created them with SAMPLER usage only, although SDL_gpu's generator also requires COLOR_TARGET. Widening the usage exposed a deeper failure: SDL 3.5's Vulkan path created 2D render-target views for depth planes that no longer exist in the smaller mip levels of a 3D texture. REMED-GFX-099 removed the invented behaviour instead of suppressing the diagnostics. XNA and FNA allocate an authored chain and let each SetData(level, …) fill the named level, so plain Texture3D is SAMPLER-only again, and plain TextureCube is likewise an authored-level resource that never calls SDL's whole-cube generator. Rendered targets still regenerate their chains, per segment, after the pass (see Render targets are ordered pass segments).

SdlGpu_Texture3D_Validity only constructs an 8 × 8 × 2, four-level texture, because a construction-time validation defect is invisible to pixel checks; CMake makes every related image-view and blit VUID, and every validation error or warning, fatal for it. SdlGpu_Texture3D covers the data contract: a one-level 4 × 4 × 4 volume with four distinguishable depth planes, a partial box whose surrounding voxels must stay unchanged, a canonical three-level chain including nonzero levels, repeated uploads and forward and reverse level order, two resources alternated A, B, A, a non-power-of-two 7 × 5 × 3 chain and destruction after a nonzero-level transfer. Validation is evidence with a scope: here it first exposed tolerated violations and then forced the design back to the public resource contract.

Upload cycling: when a texture may swap its backing

SDL_UploadToGPUTexture takes a cycle flag. With cycle = true, if the GPU may still be reading the texture, SDL gives the texture fresh backing memory for the new data instead of stalling. That is correct when an upload replaces the whole resource, and wrong when the resource is built by several writes, because every write after the first lands on a new backing and the earlier ones are orphaned: they read back as zero or uninitialised, with no error.

The first Texture3D implementation copied the cycle = true choice from the Texture2D upload path. A 3D texture is filled by several independent sub-volume and per-level SetData calls, so a first slice written and then followed by a second write read back as zero. The rule at this snapshot, stated in the upload code of SdlGpuRenderer.cpp:

ResourcecycleReason
Texture3D SetDatafalseSub-volumes and levels must all land on the same backing.
TextureCube per-face SetDatafalseSix faces, and possibly several levels, are assembled on one resource; this was applied proactively for the same reason.
Texture2D with one leveltrueThe whole resource is replaced anyway, so a fresh backing avoids a stall for free.
Texture2D with a mip chainfalseCycling discards every level the upload does not write, so level 0 followed by level 1 would orphan level 0.

A discriminating test for this defect class writes different colours to different parts of one resource in separate calls and reads each part back. With XNA's box-coordinate signature, SetData(level, left, top, right, bottom, front, back, data, startIndex, elementCount), two slices of an off-centre 2 × 2 region look like this (illustrative; the registered test uses a richer pattern):

Texture3D tex(device, 4, 4, 2, false, SurfaceFormat::Color);
std::vector<Color> red(4, Color::Red), green(4, Color::Green);
tex.SetData(0, 1, 1, 3, 3, 0, 1, red.data(), 0, 4);     // z = 0 slice of the box x 1..2, y 1..2
tex.SetData(0, 1, 1, 3, 3, 1, 2, green.data(), 0, 4);   // z = 1 slice, a second, separate write
std::vector<Color> gotRed(4), gotGreen(4);
tex.GetData(0, 1, 1, 3, 3, 0, 1, gotRed.data(), 0, 4);  // with cycle = true this read back zero
tex.GetData(0, 1, 1, 3, 3, 1, 2, gotGreen.data(), 0, 4);

Two choices make such a test discriminating rather than accidentally passing: the region is off-centre, so corruption of the origin corner alone is still caught, and each slice has its own colour, so an out-of-range read that happens to land on the same colour cannot pass. The same principle applies to any test of retained or downsampled content.

Destroying a render target before the frame is submitted

Because the render pass runs at flush time, a render target destroyed as a local variable inside one Draw() could still be named by a queued, unsubmitted command, for example a SpriteBatch draw that samples it. When the wrapper's destructor released the GPU texture immediately, that was a use-after-free; it was found as a real segmentation fault while the MRT test was first being written, before any release. The fix separates GPU state from the public wrapper: SdlGpuRenderTarget2DState and SdlGpuRenderTargetCubeState hold the native handles, and queued commands and pass segments keep them alive by shared_ptr. Lifetime then has two stages. Until the frame has rendered, every command and segment that names the state holds a reference; when the last one goes, the state's destructor does not release anything but queues its colour, depth and MSAA handles into pendingTextureReleases_. After the next successful command-buffer submission the renderer releases each queued handle with SDL_ReleaseGPUTexture(). SDL may still fence the physical memory internally; CNA's own, narrower rule is that a recording never releases a handle an unsubmitted command still names. The destruction walk and the failure path are in Destruction order is part of the renderer contract.

The permanent regression, SdlGpu_RenderTargetLifetime (sdlgpu_rendertarget_lifetime_test.cpp), is stricter than "a short-lived target does not crash":

// Shape of the regression's per-frame work (abridged from the test).
void DrawThroughShortLivedRenderTarget(GraphicsDevice& dev) {
    RenderTarget2D localRt(dev, 8, 8, false, SurfaceFormat::Color,
                           DepthFormat::None, 0, RenderTargetUsage::DiscardContents);
    dev.SetRenderTarget(&localRt);
    dev.Clear(Color::Red);
    dev.SetRenderTarget(nullptr);

    dev.SetRenderTarget(rtDest_.get());   // a 16x16 target that outlives the frame
    dev.Clear(Color::Blue);
    sb_->Begin();
    sb_->Draw(localRt, Rectangle(0, 0, 16, 16), Rectangle(0, 0, 8, 8), Color::White);
    sb_->End();
    dev.SetRenderTarget(nullptr);
}   // localRt is destroyed here, before Present() renders the frame

On the first frame the test reads the centre of the surviving target and requires red within a small tolerance, which proves the queued sampling draw really ran; a no-crash check could pass after silently dropping it. It then repeats create, clear, sample and destroy for 120 frames, which shows the release queue is drained after each submission instead of retaining every texture until shutdown. Multiple render targets follow the same ownership: each immutable PassSegment retains its primary and secondary attachments as shared_ptr<SdlGpuRenderTarget2DState>, so destroying a public secondary target cannot invalidate a recorded bind cycle.

Multiple render targets are a shader capability

GraphicsDevice::SetRenderTargets() makes the first RenderTarget2D the primary target and records the others as extra attachments of the same pass segment, so the pass contains one SDL_GPUColorTargetInfo per bound target. The pipeline key includes the attachment count and every slot's format, so a pipeline created for one colour output cannot be rebound for two. All attachments share the draw's blend factors and functions, as one GraphicsDevice.BlendState governs the whole draw, while each slot takes its own ColorWriteChannels mask. The segment's explicit clear colour applies to every colour attachment; each attachment keeps its own first-use state, and depth comes from the primary target.

That does not mean every draw writes every target. CNA's stock sprite and 3D fragment shaders declare one output, so their contract is narrow on purpose: target 0 receives the draw, and targets 1 onward can be bound and cleared independently. A custom effect can write several. SdlGpu_MRT (sdlgpu_mrt_test.cpp) draws one SpriteBatch sprite with a #version 450 effect whose fragment stage declares layout(location = 0) out vec4 outColorA and layout(location = 1) out vec4 outColorB, where B is A with its channels rotated. Sampling a white texture with tint (0.2, 0.4, 0.8, 1) must read (51, 102, 204, 255) from the first target and (102, 204, 51, 255) from the second. Those distinct values rule out both an extra clear and a copied single-output result: one fragment invocation wrote two attachments. At this snapshot the test also varies the target count up to four, changes only slot 1 from Color to HdrBlendable, and drives a compiled Effect Framework program that writes oC0 and oC1 (SDLGPU-75), which keeps a classic-XNA MRT oracle runnable even in a build without the optional ShaderEffect compiler.

Usage and first-use load actions

Each 2D target and each cube face starts with independent first-use colour, depth and stencil flags. The shared DiscardContents bind clear issued by GraphicsDevice (opaque black, depth 1, stencil 0 on the attachments that exist) records its clear into the new bind-cycle segment; a preserving bind records none. Once a pass has written a resource, later cycles load its stored aspects unless an explicit ordered clear selects them. Only DiscardContents discards: PlatformContents follows the shared FNA predicate usage != DiscardContents and preserves like PreserveContents, and the cube and depth-stencil usage fixtures assert that explicitly.

Cube usage reaches construction too. A single-sample face simply loads its cube layer. A multisampled face owns its persistent per-face attachment and uses RESOLVE_AND_STORE when it preserves, or when a later segment in the same frame must load its samples; a discarding face with no later reader may use plain RESOLVE. The ordered-clear rules that combine with these load actions are described in the internals page; SdlGpu_RenderTargetCube_Usage, SdlGpu_RenderTargetCube_MsaaFace and SdlGpu_RenderTargetCube_MsaaMip cover the cube paths (the last of these is on CNA's recorded failing list, CNA-BUG-099).

Two kinds of acquisition failure

SDL_gpu distinguishes two outcomes of SDL_WaitAndAcquireGPUSwapchainTexture() that a renderer must not conflate:

OutcomeMeaningWhat the renderer does
Returns true with a null textureA documented non-error case, such as a minimised windowSubmits the command buffer (SDL requires a buffer that attempted acquisition to be submitted, not cancelled) and skips rendering for that flush.
Returns falseA hard acquisition failureCaptures SDL_GetError(), submits the command buffer first, then throws std::runtime_error. The queued frame is not discarded: framePending_ stays true, so a caller that recovers the window can present the same queued clear and draws.

A real device loss is hard to reproduce on demand, so SdlGpu_SwapchainRecovery (sdlgpu_swapchain_recovery_test.cpp) forces the same code path with SDL's own pair of calls. It renders nine ordinary frames; on frame 10 it calls SDL_ReleaseWindowFromGPUDevice(), so the next Present() must throw with a real SDL error; it reclaims the identical window with SDL_ClaimWindowForGPUDevice(); it requires the next Present() to succeed with the preserved frame; and it renders 30 further frames without an exception. That is a bounded, controlled design for exercising recovery after a hard acquisition failure, not a claim about every kind of device loss. CNA's last recorded run before this snapshot lists SdlGpu_SwapchainRecovery among the failing classic SDL_GPU tests (CNA-BUG-099), and it had passed 5 of 5 when it was added, so read it as intended coverage, not a current pass. SdlGpu_MinimizedRetry covers the null-texture case. Back-buffer readback is a separate route (a lazily enabled readable proxy), described in Resize, readback and limitations.

Limits, and limits that no longer apply

Occlusion queries are an API limitation, not a task gap. The vendored SDL_gpu 3.5.0 has command-completion fences but no occlusion-query or query-pool command, so nothing can count samples that pass depth and stencil. SdlGpuRenderer::CreateOcclusionQuery() throws NotSupportedException saying exactly that, and the renderer answers OcclusionQuery false (SDLGPU-80). Constructing an OcclusionQuery through the public API therefore fails deterministically instead of producing a query whose Begin and End silently do nothing, which is what an inherited null factory would have allowed. The public constructor refuses first, because the capability is false, with its generic message ("OcclusionQuery is not supported by the active graphics profile and renderer."); the SDL_gpu-specific text is reachable only by calling the renderer factory directly. SdlGpu_OcclusionQuery_Limitation is meant to pin this contract, but it demands the SDL_gpu message from the public constructor and is on CNA's recorded failing list (CNA-BUG-012), so it does not currently pin it. The limitation is worth reopening only if SDL_gpu gains query commands.

Former limits that are closed at this snapshot:

  • Hardware instancing is implemented. DrawInstancedPrimitivesEx routes stock families, compiled effects and custom effects with their instance count (a custom ShaderEffect draw binds only vertex-buffer slot 0, so it instances through a storage buffer read with gl_InstanceIndex, not through a per-instance vertex stream, and the renderer's own limitation text still says ShaderEffect instancing is not implemented: CNA-GAP-008), re-enters the stock dispatcher with a per-instance stream (STREETS-0001), and the Instancing capability is answered true by the renderer's own switch rather than by an inherited default. CNA's older records describe a revision that reported the capability while every valid instanced draw reached the common throwing default, a capability overclaim that no longer exists. SdlGpu_InstancedStockFamilies and SdlGpu_InstancedPbr3D cover it.
  • Depth bias and slope-scale bias are pipeline state, normalised per depth format and hashed (see dynamic and baked state); MaxAnisotropy is part of the full sampler key.
  • Secondary MRT attachments are retained by their pass segment, which closed the raw-pointer lifetime hazard described above.
  • An ordinary Texture2D allocates its declared native level count and overrides UpdatePixelsLevel, so a nonzero level reaches the GPU resource (SdlGpu_Texture2DMipStorage). None of this adds automatic downsampling: upper levels of a plain Texture2D stay explicitly authored, as in XNA and FNA, and SDL_GenerateMipmapsForGPUTexture is deliberately not called for plain textures.

Driver coverage. One identity sits over several native APIs. The Direct3D 12 and Metal routes are code paths whose evidence is limited to what the evidence page lists: a Direct3D 12 probe run under Wine with vkd3d-proton, and no Metal run recorded. The shared .cnj custom-effect fixture remains a cross-renderer portability gap (the .cnj boundary).

Evidence and its limits

Every test named on this page is registered in the SDL_GPU examples CMakeLists.txt at this snapshot; none was executed for this page. They run only in a build that selects SDL_GPU, need a display with a GPU (CNA's standard runner is a private headless compositor, because plain Xvfb cannot present Vulkan on a real GPU), and fail on any validation output. Registration and CNA's own notes are the evidence here, not a result obtained for this page. Those notes also list 26 classic SdlGpu_* tests as failing in CNA's last recorded run, among them SdlGpu_SwapchainRecovery, SdlGpu_RenderTargetCube_MsaaMip and SdlGpu_OcclusionQuery_Limitation named here (CNA-BUG-099, CNA-BUG-012), so a test named on this page is the intended check, not a recorded pass.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture: GPU tests