SDL_gpu renderer internals

CNA snapshot 009d40f5  ·  Development › Graphics internals  ·  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. Driver-specific live validation remains open: no workflow selects SDL_GPU, Direct3D 12 evidence is a Wine/vkd3d-proton route, and Metal has none recorded here.

modules/renderers/sdl-gpu/ implements the one public SDL_GPU renderer identity. It is neither an OpenGL wrapper nor CNA's SDL platform backend: it owns an SDL_GPUDevice, records every CNA draw in CPU-side command queues, and only acquires a GPU command buffer and a swapchain texture when the frame is flushed. That delay is the key to its resource lifetime, render-target ordering and debugging rules. At this snapshot the family also carries a SPIR-V shader intake, a 3D custom-effect path, compute, storage buffers and images, indirect draws, instanced stock draws, shadow reception and image-based lighting; this page covers the whole of it for a maintainer who has to change or debug it.

Where platform, renderer and native driver meet

CMake SDL_GPU identity -> cna_renderer_sdl_gpu (REQUIRES_PLATFORM SDL3, links SDL3::SDL3)
  -> SdlGpuRendererDescriptor: RendererWindowKind::Plain, window + video subsystem
  -> GraphicsDevice -> SdlGpu::CreateGraphicsRenderer(args)
       resolves the SDL_Window from args.surface.windowId (the family's one SDL3 edge)
  -> SDL_CreateGPUDevice(SPIR-V [+ SDL_shadercross formats], debug mode, driver auto-selection)
  -> SDL_ClaimWindowForGPUDevice(device, SDL_Window*)      (skipped in headless mode)
  -> SDL_gpu native driver: Vulkan / Direct3D 12 / Metal, whichever SDL chose

sdl-gpu/CMakeLists.txt creates the target with cna_add_renderer() and sets REQUIRES_PLATFORM SDL3. There is no separate SDL_gpu library: SDL_gpu.h is part of the vendored SDL3 every SDL-backed renderer links, so an X11, Wayland or Win32 platform selection is not sufficient merely because it can supply a window, and the SDL2 platform and CNA_ENABLE_SDL=OFF refuse the identity by name (see Translation and adapter layers). The family links SDL3::SDL3 after every static archive that calls it (SDLGPU-98: single-pass GNU/MinGW archive resolution otherwise left SDL_shadercross's SDL references unresolved).

SdlGpuRendererDescriptor.cpp declares RendererWindowKind::Plain, needsWindow and needsVideoSubsystem true and AlwaysAvailable: the descriptor does not probe the host, so a registered SDL_GPU identity is no promise that a device will be created. It needs a plain window, not an OpenGL context. The factory at the end of SdlGpuRenderer.cpp resolves the SDL_Window* through CNA::Platform::Detail::ResolveSdl3RendererWindow(args.surface.windowId) and forwards virtual size, presentation mode, swap interval, multisample count and depth format; everything else comes from SDL. Review renderer selection internals before changing registration.

Build switchDefaultWhat it controls
CNA_SDL_GPU_SHADERCROSSON on Windows and Apple, OFF elsewhereDeclared in RendererSelection.cmake; includes ThirdPartySDLShaderCross.cmake (pinned SDL_shadercross plus SPIRV-Cross), links cna_sdl_shadercross statically and defines CNA_SDL_GPU_SHADERCROSS. A configure with the option on and no configured target is a FATAL_ERROR.
CNA_SDL_GPU_COMPILED_EFFECTSOFFCompiled XNA effects through MojoShader's own SDL_GPU adapter; links cna_renderer_mojoshader_effect. See Compiled effects.
GLSL ShaderEffect routedetected, no optionThe family CMake looks for a target-native shaderc_shared/shaderc library, but never on WIN32, APPLE or EMSCRIPTEN targets, and never by globbing host library directories when cross-compiling. Found: it links the library, defines CNA_SDL_GPU_SHADER_EFFECTS and sets CNA_SDL_GPU_SHADER_EFFECTS_AVAILABLE; the configure log says which.
CNA_SDLGPU_TEST_VIDEO_DRIVERx11 on Linux; windows, cocoa, android, uikit elsewhereCache variable in examples/CMakeLists.txt: the SDL video driver the integration tests run under (see validation).

Device and presentation setup

The public constructor delegates to a test-hook constructor with an empty SdlGpuTestHooksEXT. Every native acquisition happens inside a local ConstructionResources object, which owns the partially created device, window claim, construction shaders, ShaderCross session and window-registry entry until the last fallible step has succeeded; only CommitTo() moves raw handles into renderer members. The order in the constructor body is:

  1. Test-headless guard: CNA_SDLGPU_TEST_FORCE_HEADLESS is honoured only when it is exactly 1, and with a real window it is refused (std::runtime_error) unless SDL_GetCurrentVideoDriver() is dummy or offscreen. Headless mode is a null window (the public PresentationParameters::HeadlessEXT route) or that test switch.
  2. Debug mode: on whenever NDEBUG is not defined, which on Vulkan asks SDL for its validation layer.
  3. Shader formats: SPIR-V, plus SDL_ShaderCross_GetSPIRVShaderFormats() after a reference-counted ShaderCross session is acquired when the option is built. Without ShaderCross the constructor first asks SDL_GPUSupportsShaderFormats and throws "no installed SDL_gpu driver accepts SPIR-V; rebuild with CNA_SDL_GPU_SHADERCROSS=ON" when nothing does.
  4. SDL_CreateGPUDevice, then SDL_ClaimWindowForGPUDevice unless headless.
  5. Swapchain: ConfigureSwapchain and SDL_GetGPUSwapchainTextureFormat; in headless mode an RGBA8 colour-target format, falling back to BGRA8, or a refusal.
  6. Depth/stencil format for the requested DepthFormat, and the sample count clamped by ClampSampleCount against the chosen formats.
  7. CreateConstructionShaders: the stock sprite, colored, textured, lit, alpha-test, dual-texture, environment-map, skinned and PBR vertex/fragment modules (instanced variants are created later, on first use).
  8. Drawable pixels from SDL_GetWindowSizeInPixels (virtual size in headless mode), then IGraphicsRenderer::RegisterForWindow.
  9. CommitTo(*this), BeginBackbufferSegment() (the backbuffer owns a real first bind cycle), and an SDL_Log line naming the backend SDL actually chose (SMG-0031).

If any step throws, ~ConstructionResources unwinds in reverse: window registry entry, construction shaders (reverse order), window claim, device, ShaderCross session. SdlGpuFailurePointEXT names every stage a test can fail on purpose (DeviceCreation, WindowClaim, SwapchainSetup, DepthStencilFormatQuery, each construction shader, WindowMetricsInitialization, RendererRegistration, AfterRendererRegistration, and later FrameCommandBufferAcquisition, GraphicsPipelineCreation, SamplerCreation and the default-texture creations); successful stages still acquire real SDL objects, and SdlGpuTestHooksEXT::resourceEvent reports every acquisition and release (device, window claim, shader, frame command buffer, pipeline, sampler, default texture, ShaderCross session, storage buffer) so a test can prove that rollback releases exactly what was acquired. forceNoDepthStencilFormat and forceShaderCrossCompilation force the two branches a Vulkan host would otherwise never take.

Swap interval: SDL_gpu has no half-rate present mode, so PresentInterval.Two becomes VSYNC. Interval 0 prefers IMMEDIATE, then MAILBOX (recorded as applied interval 1, because mailbox is still vblank-synchronised); a refused SDL_SetGPUSwapchainParameters is logged and leaves the previous applied value. In headless mode the interval is only recorded. GetDriverNameEXT() returns SDL_GetGPUDeviceDriver: record it in every bug report, because the public identity says nothing about which native API ran.

⚠

Headless here is a renderer mode that renders into an offscreen proxy instead of a swapchain. It is not evidence that every SDL_gpu driver works without a display, and a headless pass says nothing about window claim, swapchain acquisition or presentation.

Shader routes: stock SPIR-V, SPIR-V intake, GLSL and compiled effects

Five different code paths create shaders on this renderer. A shader bug starts with deciding which one is involved.

RouteInputWhereAvailability
Stock effects and SpriteBatchCommitted SPIR-V in spirv_shaders.hppCreateSdlGpuShaderEXT, route chosen by SelectSdlGpuShaderCreationRouteEXTEvery build with a device that accepts SPIR-V, or whose formats SDL_shadercross can produce
Instanced stock vertex modulesThe same sources compiled again with CNA_INSTANCEDStockVertexShaderEXT, created on first useAs the stock route
ShaderEffect from SPIR-VCaller or shader-package SPIR-V words (LooksLikeSpirvEXT)SdlGpuEffectRenderer::CompileSpirvProgramEXT after RemapSpirvForSdlGpuEXTEvery build (no runtime compiler needed)
ShaderEffect from GLSLGLSL text compiled to SPIR-V by libshaderc at run timeSdlGpuEffectRenderer::CompileProgramOnly with CNA_SDL_GPU_SHADER_EFFECTS; otherwise an invalid effect with a compile error naming the missing compiler
ComputeSPIR-V onlySdlGpuComputeShaderRenderer::CompileProgramAny device (see modern resources)
Compiled XNA effectsD3D9 Effect Framework bytecode via MojoShaderSdlGpuCompiledEffect.cppOnly with CNA_SDL_GPU_COMPILED_EFFECTS

Stock shaders. The GLSL sources under src/shaders are compiled offline by compile_shaders.py (libshaderc through ctypes; not run by CMake) into the checked-in header; never edit the header apart from its sources. The script now does two textual things before compiling: it inlines #include "shadow_sampling.glsl" from the Vulkan renderer's shadow_sampling.glsl (SMG-0032: one piece of shadow GLSL, with CNA_SHADOW_* defines moving its resources into SDL_gpu's sets), and it inserts an instance prologue into every vertex shader that is the identity unless CNA_INSTANCED is defined, in which case four per-instance matrix columns arrive at locations 12 to 15 (STREETS-0001). The old position-only instanced3d module is gone. SelectSdlGpuShaderCreationRouteEXT picks DirectSpirv when the device accepts SPIR-V, ShaderCross when the device's formats intersect what SDL_shadercross can produce (or when the test hook forces it), and Unsupported otherwise; the ShaderCross route reflects the SPIR-V and refuses a module whose reflected sampler, storage and uniform counts differ from the create info. The shader sources follow SDL_gpu's set convention, not the Vulkan renderer's, as the script's docstring warns.

SPIR-V intake (SMG-0006/0007). CNA's portable shader packages are compiled from sources written for the Vulkan renderer's descriptor layout. SdlGpuSpirvBindings.hpp is the one translation layer: RemapSpirvForSdlGpuEXT rewrites a module word by word into the layout SDL_gpu mandates, reports each resource's original and assigned (set, binding) and its SDL slot, and turns a push_constant block into a uniform buffer (SDL_gpu has no push constants). A block that is not already std140-legal is refused by name, and a failed remap must be surfaced, never replaced by the untransformed module, because a module left in the Vulkan layout binds the wrong resources silently.

Stageset 0set 1set 2set 3
vertexsampled textures, storage textures, storage buffersuniform buffers——
fragment——sampled textures, storage textures, storage buffersuniform buffers
computesampled textures, read-only storage textures and buffersread-write storage textures and buffersuniform buffers—

The portable conventions the runtime binds against are constants in SdlGpuRenderer.hpp: an effect's own textures in set 1 at bindings 0 to 3 (2D), 4 to 7 (cube) and 8 to 11 (volume), up to kMaxCustomEffectSamplersEXT = 4 units per kind; the FloatArray, Vec2Array, Vec3Array and Mat4Array uniform blocks at bindings 12 to 15 (72 std140 elements each, held in a shared SdlGpuUniformArrayBlockEXT); and six engine-owned matrices at binding 19 (kEngineMatricesBindingEXT). The texture unit is read from the declared binding, not from the compacted SDL slot, because a sampler the compiler dead-strips shifts every later slot.

ℹ

The SPIR-V intake and the compute pipeline hand SPIR-V straight to SDL_CreateGPUShader/SDL_CreateGPUComputePipeline; neither goes through SDL_shadercross, whereas SupportsShaderLanguageEXT(SpirV, …) and SupportsComputeShadersEXT() answer true for any device. On a Direct3D 12 or Metal driver the create call is therefore the point of refusal. This is a reading of the source at this snapshot; no D3D12 or Metal run was made for this page.

Why one public draw is not yet one GPU draw

DrawPrimitivesEx and DrawIndexedPrimitivesEx first send a compiled-effect draw to QueueCompiledEffectDraw (when built), then call DispatchStockDrawEXT. That function now starts with the custom-effect case: a valid SdlGpuEffectRenderer goes to QueueCustomEffect3DDrawEXT (SMG-0019; before that, a 3D ShaderEffect draw fell through and was shaded by whatever stock family its declaration matched). Otherwise CollectStockVertexStreamsEXT and SelectStockVertexShapeEXT choose a shape and one typed queue: colored, textured, colored-textured, lit, alpha-test, dual-texture or environment-map, with StrideDerived reserved for SkinnedEffect (selected by effect state) and the PBR strides; anything else throws NotSupportedException. SdlGpuVertexBufferRenderer and SdlGpuIndexBufferRenderer keep CPU shadow copies because user draws may pass temporary sources that are gone before Present.

Each queued command snapshots what the draw needs by value: matrices (with the XNA pixel-centre correction), topology, vertex and index bytes, effect uniforms, RenderStateSnapshot (blend equation, per-slot colour write masks, cull, wireframe, both depth-bias values, stencil state and reference), sampler slots, and textures as SdlGpuSampledTextureEXT values whose keepAlive keeps the owning GPU state alive until the submit that consumes it. PushDrawOrder, the one choke point, appends a QueuedDrawRef carrying the queue kind, index, pass segment, viewport, scissor and blend factor. Replaying "all sprites, then all 3D", or reading live state at Present, would change public draw order and state semantics. Newer snapshots follow the same rule:

  • Custom 3D effects. The attribute location of a declaration element is its index in the VertexDeclaration (the convention EasyGL and Vulkan use); reflection drops elements the shader does not read and a consumed location the declaration does not supply throws NotSupportedException. The command captures the 128-byte per-draw block (overlaid by Apply3DUniformsEXT), uniform arrays, reflected uniform/sampler/storage maps, bound textures, the SamplerStates[0..3] that XNA applies per texture unit (STREETS-0004), storage buffers published through BindStorageBufferForDrawEXT with keep-alives (SMG-0020), and an instance count. Its pipeline key adds stride and every attribute's location, format and offset.
  • Instanced stock draws (STREETS-0001). DrawInstancedPrimitivesEx routes a compiled effect and a custom effect with their instance count; a custom effect that is not a valid SDL_GPU effect throws NotSupportedException. Without a per-instance stream it is an ordinary indexed draw. Otherwise it range-checks every per-instance and per-vertex binding (ArgumentOutOfRangeException) and re-enters DispatchStockDrawEXT with pendingInstanceCountEXT_ set, so the effect family is kept and only the vertex module changes. Skinned and PBR draws, whose vertex record is fixed, carry the matrix columns in a second per-instance buffer at slot 1 (InstanceTransformStreamEXT).
  • Indirect draws (SMG-0023/0025). DrawPrimitivesIndirectEXT and DrawIndexedPrimitivesIndirectEXT accept only this renderer's own storage buffer (NotSupportedException otherwise), park the buffer, offset and a shared_from_this() keep-alive in pendingIndirect* members for the duration of the dispatch, and clear them on every exit. At replay the arguments stay on the GPU: CNA's IndirectDrawIndexedArguments is field-for-field SDL_GPUIndexedIndirectDrawCommand.
  • Shadow reception (SMG-0032). CaptureShadowReceptionEXT fills the 132-float CnaShadowParams block float-for-float as Vulkan and WebGPU do, resolves the directional or cascade map, the point-light cube and the spot map with keep-alives, and copies SamplerStates[7..9]. A shadow map attached after the draw is not that draw's. Receivers always bind all three samplers, so neutral default textures (including a white cube) are created on the queueing side, where a failure still has a caller.

At flush, EnsureFrameRendered acquires one SDL_GPUCommandBuffer (owned by a FrameCommandBufferOwner), inserts the frame's queued SetStringMarkerEXT labels (SMG-0027), and acquires one swapchain texture with SDL_WaitAndAcquireGPUSwapchainTexture, or uses the proxy in headless mode. A null texture (for example a minimised window) is a non-error skip, but a command buffer that attempted acquisition is still submitted, on every exit. Sprite and scene vertex/index data is uploaded in a copy pass before any render pass, because SDL_gpu forbids a copy pass inside a render pass. The walk over passSegments_ then opens one native pass per segment in bind order and calls RenderQueuedDraws in the captured order, the readable backbuffer proxy is blitted to the swapchain if active, and the buffer is submitted; SDL presents the acquired swapchain texture on submission, so Present() only calls EnsureFrameRendered. The same flush also runs from ReadBackbuffer, render-target readback and FlushPendingGpuWorkEXT (compute dispatch, storage-buffer readback and copy). The neutral trace leading here is in the indexed draw trace.

Modern resources: compute, storage buffers and images

SdlGpuModern.hpp and SdlGpuModern.cpp hold the CNAEXT engine layer's resources (SMG-0012): SdlGpuStorageBufferRenderer, SdlGpuStorageTexture2DRenderer and SdlGpuComputeShaderRenderer. The ordering rule this deferred renderer needs is stated there: a dispatch first flushes the pending frame (FlushPendingGpuWorkEXT) and then records its own command buffer, so SDL's submission order yields render, compute, render and compute-write-before-graphics-read without hand-written barriers.

  • Storage buffers. One SDL_GPUBuffer plus a CPU shadow, zero-filled on creation. SDL_gpu has no uniform-buffer usage at all, so a Constant-usage buffer is pushed from its shadow with SDL_PushGPU*UniformData at bind time. GetDataRangeEXT, CopyToEXT and RefreshShadowFromGpuEXT flush the pending frame first, so a readback observes work queued before it. Every buffer registers with the renderer (RegisterStorageBufferEXT); see destruction order for why.
  • Storage textures. SDL_gpu has no separate storage-image object: a texture is one when created with the compute storage usage bits. TranslateStorageImageFormatEXT is the single format table, shared with GetSurfaceFormatUsageSupportEXT, which asks SDL_GPUTextureSupportsFormat separately for storage read, storage write, sampled and render-target usage.
  • Compute shaders. CompileProgram accepts SPIR-V only, remaps it, refuses a non-compute entry point and refuses integer samplers (CNA textures are float-sampled), then creates the pipeline with the reflected counts and LocalSize. DispatchEXT binds storage buffers and textures by reflected slot, leaving a gap for an unbound resource instead of shifting neighbours (SMG-0013), gives every declared sampler a real image (default white texture and a nearest/clamp compute sampler), and pushes the uniform block or a bound constant buffer. Named scalars need OpMemberName, which an optimised package lacks.
Reported limitValue while a device exists
Work-group count per axis65535
Work-group size X / Y / Z; invocations128 / 128 / 64; 128
Storage / uniform buffer bytes134217728 / 16384
Compute storage-buffer bindings; sampled textures per stage8; 16
Storage / uniform offset alignment256 / 256

These are Vulkan 1.0 minimum guarantees hard-coded in SdlGpuRenderer.cpp, because SDL_gpu exposes no device limits; they are not measured values. Capability answers come from several places: the renderer's own SupportsCapability switch answers ComputeShaders, IndirectDraw, the two float render-target members and OcclusionQuery false, while GraphicsDevice derives the public answers from SupportsComputeShadersEXT(), SupportsIndirectDrawEXT() and the format probes (true whenever a device exists), so the device-level answer is the one to trust (capability matrix). SupportsBaseInstanceDrawingEXT, SupportsShadowSamplingEXT and SupportsImageBasedLightingEXT (the PBR shader's split-sum term, STREETS-0002) are true; HalfFloatTextureLinearFiltering is true when HalfVector4 is a supported render-target format (STREETS-0003); CreateOcclusionQuery throws NotSupportedException because the vendored SDL_gpu exposes no query commands. Use-side coverage is in modern features by renderer.

Render targets are ordered pass segments

A PassSegment is one public bind cycle: exactly one native SDL_BeginGPURenderPass/SDL_EndGPURenderPass pair. Every bind opens a new segment, including a rebind of the same target, face, mip or MRT set, and segments are appended in the order they were opened, so replay is public order by construction (REMED-GFX-145). A target drawn into, unbound, sampled by a later draw and rebound in one frame is three passes, not two; merging by target identity would lose order and load actions. The backbuffer has real segments too (REMED-GFX-143): kSwapchainSegment (0) survives only as the before-first-cycle sentinel, empty earlier backbuffer segments issue no pass, and the last backbuffer segment always gets one so the acquired swapchain image is left presentable.

  • A Clear() belongs to the segment it was issued in. If a draw of that segment already precedes it, SegmentForOrderedClear closes the segment and opens another over the same destination (openedByClear, REMED-GFX-156); leading clears fold into the pass load action.
  • Load and store actions belong to the segment: an explicit clear wins, then the resource's first-use clear flag, otherwise LOAD. An MSAA target resolves into its sampled texture; when a later segment loads the same resource (SegmentColorLoadedLater) or contents are preserved, the store op becomes RESOLVE_AND_STORE. Backbuffer depth is always stored so a later backbuffer cycle still depth-tests against it.
  • MRT extras (rts[1..]) are attachments of the segment their primary bind opened, with every attachment's own format in the pipeline description; a cube face in a multi-target set throws. A single cube face binds through its own route.
  • Mip chains of a rendered target are regenerated per segment by GenerateRenderTargetMipChain, after the pass ends (REMED-GFX-187).

The renderer does not treat a public target wrapper as the owner of GPU state. SdlGpuRenderTarget2DState, SdlGpuRenderTargetCubeState and SdlGpuSampledTextureState hold the native handles, are kept alive by shared_ptr from segments and commands, and in their destructors queue textures through QueueTextureRelease instead of releasing them. After a successful submit the renderer drains pending texture and pipeline releases, and SDL_gpu's own fencing takes it from there. On a flush exception the command buffer is finished for failure, transient draw buffers are released with ReleaseSceneDrawBuffers(false), and the queued logical frame is kept, so a retry can re-record it; a resource fix must examine both paths. The neutral side of target lifetime is in textures and render targets.

Resize, readback and limitations

OnSurfaceChanged throws std::invalid_argument for a renderer without a window and for a different window identity; otherwise it records the drawable extent and a sanitised display scale (non-finite or non-positive becomes 1). Backbuffer depth and MSAA textures are sized when the next frame acquires its actual swapchain extent, so a minimised zero-size frame does not render. ComputeLogicalViewport applies the presentation modes (native, fixed-height dynamic-width, stretch, letterbox, overscan). Once ReadBackbuffer is called, backbufferReadbackEnabled_ makes a self-owned proxy with the swapchain's format the draw destination (the swapchain texture is write-only) and the proxy is blitted to the swapchain before submit; nothing is paid until the first read. The read flushes, clamps to the proxy, downloads through a transfer buffer and a fence, swaps channels for a BGRA swapchain, and zero-fills anything outside the proxy or nothing at all if no proxy exists (a minimised window). If a mismatch appears only after resize or a screenshot, inspect proxy activation, the pass target format and sample count and the copy order before changing GraphicsDevice.

Explicit limits at this snapshot:

  • OcclusionQuery is refused (see above); GetAdditionalLimitationsTextEXT() says so.
  • That same text still states that CNAEXT ShaderEffect instancing is not implemented, although DrawInstancedPrimitivesEx now queues a custom-effect draw with its instance count (SMG-0022, the particle route that reads per-instance data from a storage buffer by gl_InstanceIndex). What the custom-effect command does not do is bind a per-instance vertex stream: it binds vertex buffer slot 0 only. Treat the text as stale and the stream limit as real.
  • The GLSL ShaderEffect route exists only where the build found libshaderc, and CustomEffects follows that route alone (see renderer availability).
  • One identity sits over several native APIs: a Vulkan result does not establish Direct3D 12 or Metal behaviour. Headless stock-draw validation (ValidateStockDrawForDriverEXT) is not native presentation.

Destruction order is part of the renderer contract

~SdlGpuRenderer runs these steps in this order, and each one depends on the next object still being alive:

  1. Release the cached compute sampler while the device exists.
  2. Unregister the window mapping.
  3. Drop queued scene, sprite, segment and order data (ReleaseSceneDrawBuffers, spriteCommands_, passSegments_, drawOrder_). Releasing a command's last texture reference may enqueue a native handle; the drain below frees it.
  4. Drop the storage-buffer draw bindings and the pending indirect-argument keep-alive (SMG-0040: as members they would otherwise die after the device, when a buffer can no longer release its VkBuffer).
  5. Destroy stock resources family by family: PBR, skinned, instanced, environment map, dual texture, alpha test, lit, textured, colored, sprite.
  6. With compiled effects: release compiled-effect pipelines, then every native compiled effect through ReleaseCompiledEffectsForRendererTeardownEXT (a public effect may outlive its device and becomes native-state-free), then the program leases, then the MojoShader context.
  7. Drain pendingTextureReleases_ and pendingGraphicsPipelineReleases_; release depth, backbuffer MSAA and readback proxy textures.
  8. ReleaseStorageBuffersForRendererTeardownEXT: every storage buffer still alive, whoever holds it, releases its native buffer and is detached, so its later destructor touches neither SDL nor the renderer.
  9. Unclaim the window, destroy the device, and finally release the ShaderCross session.

Reversing command-drop and device destruction would leave delayed releases addressing a dead device; reversing compiled-effect pipelines and the MojoShader context risks callbacks into a destroyed context. A new native resource class needs the same treatment: a place in this walk, and detachment if its public object can outlive the renderer. See debugging shutdown and lifetime and architectural invariants.

Validation that matches the risk

examples/CMakeLists.txt registers the integration tests inside if(CNA_BUILD_TESTS AND CNA_GRAPHICS_RENDERER STREQUAL "SDL_GPU"). modules/renderers/CMakeLists.txt re-points CNA_GRAPHICS_RENDERER to each family's identity while it enters that family, so this block is also entered when SDL_GPU is a non-default member of CNA_GRAPHICS_RENDERERS; cna_register_renderer_test sets no renderer selection, so in such a build confirm which renderer an executable actually creates (the EasyGL and HEADLESS blocks compare against _cna_default_renderer_identity for exactly this reason). The environment comes from CNA_SDLGPU_TEST_VIDEO_DRIVER: only X11 receives CNA_TEST_DISPLAY, dummy/offscreen clear both display variables, and every test in the directory fails on Validation (Error|Warning), VUID- or D3D12 (ERROR|WARNING): output (SDLGPU-115).

  • The shared parity corpus from ParityFixtures.cmake as SdlGpu_Parity_*, plus SdlGpu_Parity_backbuffer_msaa_Reset (the A 4x, B 0x, A 4x reset mode).
  • Construction and lifecycle: SdlGpu_ConstructorExceptionSafety, SdlGpu_Smoke (now also checks the capability answers), SdlGpu_SamplerCacheLifetime, SdlGpu_SwapchainRecovery, SdlGpu_MinimizedRetry, SdlGpu_PresentationSurface, SdlGpu_PresentLifecycle.
  • Targets and order: SdlGpu_RenderTarget2D, SdlGpu_MRT, SdlGpu_RenderTarget2DMSAA, SdlGpu_DrawOrder, SdlGpu_GraphicsDevice_OrderedClear, SdlGpu_Backbuffer_PassOrder, SdlGpu_RenderTarget_PassBoundary, SdlGpu_BoundTargetLifetime, SdlGpu_DeferredSourceLifetime.
  • No-swapchain routes: SdlGpu_HeadlessGraphicsDevice (public HeadlessEXT device; SDL_GPU_DRIVER=vulkan, or direct3d12 on Windows) and SdlGpu_2D_HeadlessDriver (the unchanged 2D scene with CNA_SDLGPU_TEST_FORCE_HEADLESS=1).
  • Added for the modern work: SdlGpu_StorageBufferLifetime (no SDL_GPUBuffer survives device destruction in any order), SdlGpu_TextureDefragment (forces SDL_gpu's Vulkan defragmenter over textures), SdlGpu_InstancedPbr3D, SdlGpu_InstancedStockFamilies, SdlGpu_BasicEffect_DiffuseColorClamp, SdlGpu_EnvironmentMapEffect_AmountClamp, SdlGpu_ShaderEffect_PerUnitSampler (Vulkan's witness compiled again without its validation leg) and, only with CNA_CNAEXT, SdlGpu_HalfFloatFiltering.
  • Built but deliberately not registered: cna_test_sdlgpu_modern_stress, a 3000-cycle soak over storage buffers and textures, texture arrays, resized offscreen targets, indirect draws, a custom-effect sprite and shadow reception, checking resident memory, file descriptors and threads (exit 77 without a modern route); and the manual diagnostic cna_diag_sdlgpu_single_sprite.
  • GoogleTest sources under tests/: compiled-effect, sampler-cache and ShaderCross tests; the compiled-effect one is also built standalone as SdlGpu_CompiledEffectRuntime when CNA_SDL_GPU_COMPILED_EFFECTS is on.
⚠

Cross-compiled Windows executables run through run-wine-vkd3d-headless.sh (fresh Xvfb, SDL dummy, direct3d12, forced headless, a vkd3d-proton engagement gate); that is Wine evidence, not native Windows presentation, and no workflow selects SDL_GPU. On a non-Windows host the headless registrations reuse the ordinary test environment, which selects x11 by default, while the constructor accepts the forced-headless switch only under dummy or offscreen; check CNA_SDLGPU_TEST_VIDEO_DRIVER before reading a SdlGpu_2D_HeadlessDriver result. None of these tests was executed for this page.

A workable order: ctest --test-dir <build> -N -R SdlGpu to see what is registered, then the targeted subset, then -L SdlGpu or the parity label; run_gpu_tests_private.sh is CNA's private-compositor runner for GPU tests (see Tutorial 131). For a change, decide first whether it touches enqueue-time capture, replay-time state, pass segmentation, SDL resource ownership or a shader route, and add a test whose sequence separates old from new behaviour: target A, backbuffer, A again; a texture or storage buffer destroyed before Present; a resize between acquisition and rendering; a dispatch followed by a readback. Run it on the native driver named in the report and on a second driver where possible. If an interface signature changes, check GraphicsDevice internals and the other renderers too.

Read in this order

  1. sdl-gpu/CMakeLists.txt, the SDL_GPU block of RendererSelection.cmake and SdlGpuRendererDescriptor.cpp: the SDL3 dependency, the three shader switches and the pre-construction contract.
  2. SdlGpuRenderer.hpp: PassSegment, QueuedDrawRef, the typed draw commands, the native state wrappers, the binding constants and the failure points, before the implementation.
  3. SdlGpuRenderer.cpp: ConstructionResources and the constructor, then DispatchStockDrawEXT and one Queue*Draw, EnsureFrameRendered, RenderToTarget, and the destructor.
  4. SdlGpuSpirvBindings.hpp with SdlGpuSpirvBindings.cpp, then SdlGpuModern.hpp with SdlGpuModern.cpp: the SPIR-V intake and the modern resources.
  5. compile_shaders.py and examples/CMakeLists.txt: how shader artifacts are produced and what a named test actually runs.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.