SDL_GPU shader intake, pipeline keys and draw order

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; the pushed-uniform limit is CNA's measurement on that driver, and Direct3D 12 or Metal behaviour is not established by it.

The SDL_GPU renderer drives SDL3's own GPU API and lets SDL choose the native driver. This page explains the parts of its design that follow from that API: why its shaders are precompiled SPIR-V in a fixed resource-set convention, why the skinning palette is a texture, which GLSL a custom ShaderEffect may contain, how immutable pipelines are keyed, what state is dynamic, how deferred replay keeps the game's draw order, and why vertical synchronisation is a device-manager setting rather than a renderer poke. It complements the maintainer tour in SDL_gpu renderer internals and the build-and-query walkthrough in Tutorial 131.

An SDL3 API, not a separate library

SDL_gpu dispatches internally to Vulkan, Direct3D 12 or Metal, depending on the platform and the drivers available. Its implementation is part of the SDL3 library CNA already vendors, so on Linux, where SDL's Vulkan driver consumes CNA's committed SPIR-V directly, this renderer adds no third-party dependency at all; that is the argument CNA's own planning gave for adding it next to renderers that need the Vulkan SDK or a downloaded wgpu-native. The argument is narrower at this snapshot: on Windows and Apple targets CNA_SDL_GPU_SHADERCROSS defaults to ON and fetches a pinned SDL_shadercross with SPIRV-Cross to translate the same SPIR-V for Direct3D 12 or Metal, and a custom GLSL ShaderEffect needs a target-native libshaderc. The build switches are listed in the internals page.

The implementation class is CNA::Internal::Renderers::SdlGpu::SdlGpuRenderer, sources are under modules/renderers/sdl-gpu/src, the target is cna_renderer_sdl_gpu and the identity is selected with -DCNA_GRAPHICS_RENDERER=SDL_GPU. The name is deliberately distinct from SdlRenderer, the 2D-only SDL_RENDERER identity built on SDL's older SDL_Renderer API: the two SDL APIs are unrelated, and CNA keeps their similar names from colliding.

Precompiled bytecode in SDL_gpu's set convention

SDL_CreateGPUShader accepts only bytecode tagged with an SDL_GPUShaderFormat: SPIR-V for the Vulkan driver, DXBC or DXIL for Direct3D 12, MSL or a metallib for Metal. It never takes GLSL or HLSL source. It also fixes where each resource lives. The stock shaders under src/shaders (23 GLSL sources, compiled offline by compile_shaders.py into the checked-in spirv_shaders.hpp) follow SDL_gpu's graphics convention, which the script's docstring states explicitly:

StageSampled textures, storage textures and buffersUniform buffers
Vertexset 0set 1
Fragmentset 2set 3

The VULKAN renderer's own GLSL uses its own descriptor layout, so its compiled SPIR-V is a useful algorithmic reference (the same lighting, alpha-test and dual-texture arithmetic) but not reusable output: every stock shader had to be re-authored for these sets. Uniform values that other renderers would push as constants are delivered with SDL_PushGPUVertexUniformData and its fragment twin into the set-1 and set-3 blocks. One piece of GLSL is now genuinely shared: the shadow-sampling include is inlined by the SDL_GPU script with CNA_SHADOW_* defines that move its resources into these sets. Portable shader packages compiled for the Vulkan layout are accepted at run time through a SPIR-V remapper; see Shader routes.

The screenshot that exposed a Y-flip

The renderer's first real milestone was a textured, tinted, rotated, multi-flip sprite scene that survives a resize. The first vertex shader converted pixel positions to NDC with gl_Position = vec4(ndc, 0, 1), the same shape the other renderers' sprite shaders use. It compiled, ran without error and drew every sprite upside down, because SDL_gpu's Vulkan driver flips clip-space Y internally to give shaders a consistent cross-driver convention. Only a captured screenshot revealed it; a "did it throw" test could not. The fix negates Y explicitly in sprite2d.vert.glsl:

// sprite2d.vert.glsl at 009d40f5: pixel space -> NDC, (0,0) top-left, Y down like XNA
vec2 ndc = (inPos.xy / ubo.viewportSize) * 2.0 - 1.0;
gl_Position = vec4(ndc.x, -ndc.y, inPos.z, 1.0);

The isolating tool was kept in the tree: sdlgpu_diag_single_sprite.cpp draws exactly one non-rotated, non-flipped, full-texture sprite with native back-buffer presentation, so texture sampling and UV mapping can be checked independently of viewport scaling, rotation, flips and address modes. It is a manual diagnostic, not a CTest.

The 72-bone palette and the pushed-uniform limit

SkinnedEffect supports up to SkinnedEffect::MaxBones = 72 bones, so its palette is 72 × 64 = 4,608 bytes. Every other stock effect's per-draw payload (matrices, DiffuseColor, alpha-test and light parameters) is pushed with SDL_PushGPUVertexUniformData or its fragment twin, but on this renderer's Vulkan driver a pushed uniform block is limited to about 4,096 bytes. CNA found that limit with an empirical binary search; SDL_gpu.h does not document it. The palette therefore cannot be pushed.

The first workaround bound the palette as a vertex storage buffer. At this snapshot that route is gone, because it made Direct3D 12 reject an otherwise valid graphics pipeline. skinned3d.vert.glsl now samples a 288 × 1 RGBA32F texture at vertex set 0, binding 0, holding the same column-major palette as four adjacent texels per matrix. The comment in skinned3d.vert.glsl gives the reasons: SDL_gpu's Direct3D 12 backend accepts it as well as Vulkan, it is expressible on Metal, and the 4,608-byte row is 256-byte aligned, which avoids Direct3D 12's slow unaligned texture-upload path. The general lesson is to name the mechanism per payload size precisely, rather than write "uniforms are uploaded per draw" as if one mechanism served every payload.

Custom ShaderEffect: which GLSL the runtime route accepts

ShaderEffect's public contract takes shader source, while SDL_gpu takes only bytecode. SdlGpuEffectRenderer::CompileProgram bridges the two in two ways. A payload that starts with the SPIR-V magic word goes to the SPIR-V intake, which needs no compiler and exists in every build. Otherwise, in a build where CMake found a target-native libshaderc (CNA_SDL_GPU_SHADER_EFFECTS), the GLSL text is compiled to SPIR-V at run time by the linked library. The development environment that built this route had no libshaderc development package, so there is no shaderc.h to include: SdlGpuRenderer.cpp hand-declares the exact extern "C" subset it calls (shaderc_compiler_initialize, shaderc_compile_into_spv, shaderc_result_get_bytes and the release and status functions), with the shader-kind and optimisation-level enumerators as named integer constants. The compile still goes through the real shared library; only the header describing its C ABI is hand-written, matching the ctypes bindings compile_shaders.py uses against the same library.

The GLSL route is narrow, and the narrowness is the contract. It compiles for Vulkan's GLSL rules and creates the vertex shader with one uniform buffer and the fragment shader with one sampler and one uniform buffer. A source that works is therefore written like the passing SdlGpu_ShaderEffect regression: desktop #version 450, an explicit layout(location = …) on every vertex attribute and stage input and output, the fragment sampler at layout(set = 2, binding = 0), and the per-draw values in a named uniform block at set = 1 in the vertex stage and set = 3 in the fragment stage. That test compiles both stages at run time, draws a white texture into a RenderTarget2D and reads back the effect's blue tint, then repeats the draw without the effect and requires white, so the binding is proven by pixels rather than by IsEffectValid(). The renderer declares SPIR-V as its dialect and its SupportsShaderLanguageEXT claims only SPIR-V, even where the GLSL route exists, because that route rejects the GLSL ES 3.00 profile other parts of CNA use.

The VULKAN renderer is stricter still: its ShaderEffect takes SPIR-V words and refuses GLSL text (see Vulkan internals: shader boundaries), which was noticed while this route was being built. CustomEffects means a renderer accepts an effect, not that it executes the source you wrote; ExecutesShaderEffectSourceEXT() and the declared dialect answer those questions.

The .cnj fixture boundary

The runtime compiler does not make every GLSL text accepted elsewhere portable to this renderer. The content test CnjEffectTest.LoadsRealCnjFixture (CnjEffectTests.cpp) writes an ordinary .cnj effect manifest with a GLSL ES 3.00 pair: #version 300 es, unqualified stage inputs and outputs, and loose uniform mat4 projection and uniform sampler2D texture1 declarations. Vulkan-rule GLSL requires locations and forbids non-opaque uniforms outside a block, so that source cannot compile on this route, and the effect is invalid before a pixel is drawn. This is an open compatibility gap, not evidence that ShaderEffect is a no-op here: custom effects work when authored in the dialect above. Closing it needs a designed decision, either migrating the shared .cnj source convention to the stricter portable dialect, which changes content expectations for every renderer, or giving this renderer a real translation layer from loose GLSL to the blocks, locations and sets SDL_gpu requires. Neither is a one-line relaxation. See Shader effects: renderer availability for the user-side matrix.

One hashed pipeline key with conditional dimensions

SDL_gpu graphics pipelines are immutable, so the renderer cannot bind an XNA state object at the end of a deferred frame and hope it describes every earlier draw. Each queued sprite or 3D command carries a RenderStateSnapshot taken when it was issued (blend factors and functions, per-slot colour write masks, cull and fill mode, both depth-bias values, stencil operations, masks and reference, depth test fields), and pipeline lookup folds the pipeline-relevant part into one std::size_t key. PipelineCacheKey combines the primitive topology, depth test, depth write and depth function, the number of colour targets and every slot's format, the sample count, the depth-stencil format (INVALID for a pass without one) and the snapshot, using the well-known boost::hash_combine mixing formula in HashCombine. It replaced a hand-packed integer key whose fixed bit budget would have become collision-prone as dimensions were added, the limit the Vulkan renderer's packed blend key documents.

Conditional hashing keeps the cache both correct and finite:

  • Disabled blending does not hash its six factors and functions; disabled stencil does not hash its operations or masks; disabled two-sided stencil does not hash the counter-clockwise fields. Two commands that need the same pipeline share it even when their irrelevant XNA fields differ.
  • Stencil masks are hashed after truncation to the Uint8 SDL applies, so two integers that truncate to the same byte do not fragment the cache.
  • Every MRT slot's format is part of the key (SDLGPU-72), because render-pass compatibility is slot-aligned: {Color, Color} and {Color, HalfVector4} need different pipelines even when the primary attachment and every public state match.
  • Depth-stencil format presence is part of the key even when depth testing is off (REMED-GFX-097): a depthless pass must never reuse a pipeline created for a depth-backed one.
  • The sample count is a required dimension. A pipeline created with the wrong sample_count for its pass's attachments was one of the findings of an adversarial review of this renderer.

The colour format in the key comes from the pass actually being recorded: each frame asks SDL for the swapchain texture's real format, which is independent of the public BackBufferFormat. GetAppliedBackBufferFormatEXT() reports Color because readback normalises BGRA to RGBA. The back buffer's depth attachment, by contrast, follows the public DepthFormat at this snapshot: DepthFormat::None means no depth attachment, Depth16 and Depth24 fall back to D32_FLOAT where the device lacks them, and a change through UpdatePresentationFormatEXT first renders any pending frame and then releases the old attachment. Fullscreen is not a renderer operation: SDL's window owns the transition, and UpdatePresentationFormatEXT ignores its isFullScreen and back-buffer-format arguments (SDL picks the swapchain colour format), replacing only the renderer-owned depth and MSAA attachments.

What is dynamic and what is baked

StateWhere it livesDetail
Stencil referencePer draw, SDL_SetGPUStencilReference()Captured with the command, not baked into a pipeline. An adversarial pixel test once found the call missing: a left-half stencil write followed by a full-screen Equal-reference draw is meant to prove the dynamic value reaches the GPU (SdlGpu_RenderState, which CNA's last recorded run lists among the failing classic SDL_GPU tests: CNA-BUG-099).
Viewport, scissor, blend constantsPer draw, SDL_SetGPUViewport, SDL_SetGPUScissor, SDL_SetGPUBlendConstantsCaptured in the draw's QueuedDrawRef and applied immediately before it (REMED-GFX-064, -068, -069). Enabled scissors are clamped to the target; disabled or degenerate ones expand to its full extent, so an enabled zero-area rectangle draws unclipped here as on VULKAN, while WEBGPU clips such a draw entirely.
Depth biasPipeline-static in SDL 3.5See below.
Sampler stateCached SDL_GPUSampler objectsSee below.

Depth bias. SDL 3.5 exposes the constant factor, clamp, slope factor and an enable bit only in the pipeline's rasterizer state. Before REMED-GFX-051 the renderer captured both public values but left them out of the snapshot and the key, so every draw replayed through the same zero-bias pipeline. Now NormalizeDepthBias converts XNA's normalised RasterizerState.DepthBias into the attachment's native units the way FNA3D's SDL_gpu driver does: multiplied by 223−1 for a 32-bit float depth format, 224−1 for a 24-bit one, and 216−1 for 16-bit or a depthless pass. SlopeScaleDepthBias already has the same meaning in both APIs. Lines and points have no polygon slope and get no bias, so their values are normalised to "disabled" and neither change native state nor fragment the cache; signed zero is canonicalised; non-finite values are passed through for SDL and the driver to judge. Every pipeline also sets enable_depth_clip, because XNA clips primitives outside its 0 to 1 depth range while SDL_gpu's default is to clamp. SdlGpu_DepthBias combines deterministic depth-winner pixels, cache-cardinality checks (one pipeline per genuinely static state, no fragmentation from signed zero, target identity, viewport or line-only state) and validation-fatal execution. It is on CNA's recorded failing list as well (CNA-BUG-099), so this describes the intended check, not a recorded pass.

Samplers. The sampler key and the native descriptor carry the complete request (REMED-GFX-170): all nine TextureFilter values mapped separately for minification, magnification and mip mode with no default branch, the U, V and W address modes, MaxAnisotropy clamped to 1 to 16 and applied only for TextureFilter::Anisotropic (so a Point request never becomes a filtered fetch), MaxMipLevel as SDL's minimum level of detail, and MipMapLevelOfDetailBias. Anisotropy is therefore no longer merely a stored capability bit.

Deferred replay preserves public draw order

Deferring all work until Present() once introduced a correctness bug hidden by the family-specific command vectors: every 3D family was replayed first and every sprite last, whatever order the game used. A background sprite, a 3D model and a HUD sprite came out right only by accident, and a sprite followed by a 3D draw was replayed as 3D then sprite. This is visible whenever later content should cover earlier content, not merely a performance detail.

Every Queue*Draw() and QueueSprite() call now appends a compact kind-and-index reference through one choke point, PushDrawOrder, beside its family-specific command. RenderQueuedDraws() makes one chronological pass over those references and dispatches each family's issue routine only for commands that belong to the current target. No sort is needed, because append order is already the public call order, and pipeline-rebind avoidance is tracked across all kinds so correctness did not cost the bind cache. The regression is built to discriminate: sdlgpu_draworder_test.cpp draws an opaque red full-target sprite and an opaque green full-target 3D quad into one target with depth testing off. Sprite then 3D must read green; 3D then sprite must read red. The old fixed-family order would have read red both times, so the pair cannot pass by crashing less.

Present timing belongs to the device manager

The SDL_GPU tests established a small rule about CNA's initialisation order. GraphicsDeviceManager defaults SynchronizeWithVerticalRetrace to true, the right XNA-compatible default for a game. CNA's virtual test display offers no useful vertical-retrace signal, so with vsync on each frame could wait around a second, and a 60- or 120-frame GPU test spent its CTest budget waiting. The correct setup, used by most of the family's Game-based examples (36 of the 50 example sources set it), is the public property, set before Game::DoInitialize() creates or resets the device:

gdm_ = std::make_unique<GraphicsDeviceManager>(this);
gdm_->setPreferredBackBufferWidthProperty(64);
gdm_->setPreferredBackBufferHeightProperty(64);
gdm_->setSynchronizeWithVerticalRetraceProperty(false);

The order matters. Earlier tests called SdlGpuRenderer::SetSwapInterval(0) directly. During initialisation, however, the device manager converts its still-true property into PresentInterval::One, and GraphicsDevice::Reset forwards the interval to the renderer. That forwarding was itself a repair: before it, the reset path never called IGraphicsRenderer::SetSwapInterval() on any renderer. Once it landed it rightly overwrote the private early poke, and the tests' timeouts surfaced instead of an accidental ordering dependency being preserved. The explicit false is a test-environment choice made through the property a game would use, at the point in the lifecycle where CNA will not replace it; it is not a recommendation to disable synchronisation.

On this renderer interval 0 asks SDL_gpu for SDL_GPU_PRESENTMODE_IMMEDIATE, falling back to MAILBOX, which is still synchronised to vertical blank and so is recorded as applied interval 1; any positive interval selects VSYNC. SDL_gpu has no half-rate or relaxed mode, so PresentInterval::Two behaves exactly like One, unlike the Vulkan renderer's FIFO_RELAXED (Vulkan present modes). A refused SDL_SetGPUSwapchainParameters is logged and keeps the previous applied value; the swapchain composition requested is SDL_GPU_SWAPCHAINCOMPOSITION_SDR. In headless mode the interval is only recorded.

Evidence and its limits

The tests named here (SdlGpu_ShaderEffect, SdlGpu_RenderState, SdlGpu_DepthBias, SdlGpu_SamplerState, SdlGpu_DrawOrder and the content-module CnjEffectTest) are registered at this snapshot; none was executed for this page. They run in a build that selects SDL_GPU, under a directory-wide gate that fails on validation output, and CNA's own records place their routine execution on SDL_gpu's Vulkan driver on Linux. Those records also list 26 classic SdlGpu_* tests as failing, among them SdlGpu_RenderState and SdlGpu_DepthBias named here (CNA-BUG-099), so a test named on this page is the intended check, not a recorded pass. The ~4,096-byte pushed-uniform figure is CNA's measurement on that driver, not an SDL guarantee, and a Direct3D 12 or Metal run is not established by it. The renderer's resource lifetime, uploads, render targets and swapchain recovery continue in SDL_GPU uploads, render-target lifetime and swapchain recovery; the comparison with the other native renderers is in Evidence tiers of the native modern GPU renderers.

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

Maintainer workflow
Fix a renderer bug
Tests and validation
Test architecture: GPU tests