Vulkan renderer internals
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. Modern compute paths, every shader family and the exact CI host matrix were not audited test by test; no Vulkan test was run for this page.
The VULKAN identity maps to one physical family, modules/renderers/vulkan/, built as cna_renderer_vulkan. It implements CNA's neutral renderer contract, but its draw methods usually stage records: actual Vulkan commands are recorded and submitted at Present or at a deliberate flush or readback boundary. That separation in time decides state snapshots, resource retirement and shutdown order, and it is what a maintainer has to keep in mind before touching any of them.
Build and registration
RendererSelection.cmake maps VULKAN to modules/renderers/vulkan and cna_renderer_vulkan, defines CNA_RENDERER_VULKAN, runs find_package(Vulkan REQUIRED) and declares CNA_VULKAN_COMPILED_EFFECTS (default OFF; configures MojoShader and defines the macro). vulkan/CMakeLists.txt links Vulkan::Vulkan, adds the shared cna_renderer_mojoshader_effect target only with compiled effects (Vulkan has no MojoShader adapter; its nine-function effect backend is CNA's own), and on GNU/Clang compiles the family with -Werror=switch. VulkanRenderer::SupportsCapability has no default: arm, so appending a GraphicsCapability member stops the build until this renderer answers it. That is a compile-time coverage invariant, not a claim of feature parity.
VulkanRendererDescriptor.cpp: GetDescriptor() declares identity Vulkan, RendererWindowKind::Vulkan (the platform refuses to build a Vulkan surface from a window created without that intent), needsWindow, needsVideoSubsystem and needsVulkanSurface. Its availability hook is AlwaysAvailable: the descriptor does not pre-probe the host, so a usable instance and device are not guaranteed and creation can still throw. Its only adapter query is clampMultiSampleCount = VulkanRenderer::ClampAdapterMultiSampleCountEXT (VULKAN-187), so GraphicsAdapter can answer MSAA questions before any device exists. RendererRegistry.cmake generates the registry row that references GetDescriptor, and GraphicsDevice passes a window surface snapshot and the narrow IPlatformVulkanSurface service, not the whole platform object. Review renderer selection internals before changing registration, and what each renderer needs from the platform for which platforms provide a Vulkan surface.
Constructor in actual dependency order
GraphicsDevice::createRenderer -> descriptor.create(args) -> VulkanRenderer(args): RequirePlatformVulkanSurface(args.vulkanSurface) + window id -> CreateInstance -> [debug messenger if validation is active] -> CreateSurface -> PickPhysicalDevice -> CreateLogicalDevice -> publish adapter sample counts -> PickSampleCount (device framebuffer limits) -> CreateSwapchain -> image views -> depth resources -> render pass -> [MSAA colour + MSAA render pass] -> framebuffers -> command pool / buffers -> per-frame sync objects -> default sampler -> descriptor set layout / pool -> sprite buffers -> initialized_ = true -> RegisterForWindow(window id)
VulkanRenderer.cpp stores the window surface snapshot (id, drawable size, display scale, defaulting a non-positive scale to 1) and a borrowed surface service, and throws for a missing window id. Physical-device selection precedes the sample-count choice, which uses the selected device's colour and depth framebuffer sample limits; the intersection is also stored in a process-wide atomic for adapter queries that may run before another device exists. Validation is requested in non-NDEBUG builds and dropped at run time, with a log line, when VK_LAYER_KHRONOS_validation is not installed, so confirm that it is really active rather than assuming it from a Debug build. Render-target, MRT and swapchain render passes and the 2D sprite pipelines are created lazily and cached by compatibility key (GetOrCreateRTRenderPass, GetOrCreateMRTRenderPass, GetOrCreateSwapchainRenderPass), not all eagerly here. Registration is the last step. If startup fails part-way, the caller's GraphicsDevice construction or fallback path releases the window and video reference; native partial-creation cleanup inside this class has not been audited here, so do not claim every exception path leak-free.
A draw is a record, not necessarily a submit
The public indexed draw reaches VulkanRenderer::DrawIndexedPrimitivesEx with neutral GpuDrawParams. A custom effect goes to QueueCustomEffect3DDrawEXT before any declaration guard. Otherwise the method packs multiple vertex streams if present, selects a stock family (PBR, alpha test, dual texture, environment map, skinned, lit or unlit, decided from effect parameters and from whether the declaration really supplies a normal, not from stride alone), verifies that the declaration supplies what the shader reads, notes sampled render-target sources, and builds a Pending3DDraw: copied vertex and index bytes, the draw count, transforms including the XNA pixel-centre correction, depth and blend state with per-channel blend factors, the current render target (null means backbuffer), the vertex layout taken from the caller's declaration at draw time, and the effect's pipeline and descriptor set. With compiled effects built, PrepareCompiledEffectDrawEXT replaces the stock selection and refuses a vertex buffer without a declaration; without them, a compiled-effect draw throws NotSupportedException instead of silently using a stock shader.
PushPending3DDraw then tags the active occlusion query and snapshots scissor (REMED-GFX-013), viewport (REMED-GFX-062) and blend factor (REMED-GFX-070) into the record before queuing it. A developer adding state must decide whether it is draw-time or replay-time state; draw-time state has to be copied into this record, because reading the renderer's mutable state during replay would retroactively change earlier draws in the frame. The full neutral-to-Vulkan comparison is in the indexed draw trace.
Vulkan also queues ordered clears, sprite batches, modern commands and render-target cycles. A mid-frame readback can flush deferred render-target work (FlushDeferredRenderTarget pulls in only the off-screen bind cycles that produce the read target; backbuffer cycles are excluded so the one-acquire, one-submit, one-present frame is kept). "One Present per frame" is therefore not a complete model of submissions: inspect FlushDeferredRenderTarget, ReadBackbuffer and the modern-command flushes when changing synchronisation.
SubmitFrame and synchronization
Present() calls SubmitFrame(false). With MaxFramesInFlight = 2 frame slots, SubmitFrame:
- waits for the current slot's in-flight fence and advances the completed generation;
- runs
ProcessRetiredResources(false), freeing retired native handles whose consuming frame is now provably complete; - acquires a swapchain image with the slot's image-available semaphore; on
VK_ERROR_OUT_OF_DATE_KHRit recreates the swapchain and returns before resetting the fence, because resetting it there would leave an unsignalled fence with no submission and hang the next frame; - resets the fence and command buffer, and
RecordCommandBuffertranslates the pending records; - submits to
graphicsQueue_, waiting on image availability at colour-attachment output and signalling render completion, then advances the retirement generation; - presents with the render-finished semaphore; an out-of-date or suboptimal present recreates the swapchain; the slot advances modulo
MaxFramesInFlight.
ReadBackbuffer can use the deferred-swap variant: SubmitFrame(true) submits, waits for render and readback copy, and holds the image while the CPU reads the staging memory; FinishDeferredPresent presents afterwards. That keeps presentation-engine timing from corrupting the captured pixels, and a readback or cache change must account for this route as well as the ordinary one. CheckDeviceLostEXT checks results at the fence wait, acquire, submit and present (VULKAN-334); a failure at one of those boundaries is not necessarily where the offending draw was queued. Test-only injection of out-of-date acquires and present results exists for exactly these branches.
Swapchain policy, resize and reset
CreateSwapchain queries surface capabilities, formats and present modes. It prefers linear B8G8R8A8_UNORM with the sRGB-nonlinear colour space for XNA's default Color back buffer rather than silently sRGB-encoding it (the gamma-encoded variant is a separate extension format). Swap interval 0 prefers IMMEDIATE, then MAILBOX, then FIFO; interval 2 prefers FIFO_RELAXED, then FIFO; the default interval uses FIFO. The applied mode is recorded separately from the request. The native surface may dictate the current extent; otherwise requested drawable pixels are clamped to supported bounds. Separate graphics and present queue families use concurrent sharing, a single family exclusive sharing.
RecreateSwapchain ignores a zero-sized drawable, waits for device idle, destroys framebuffers, MSAA colour resources and swapchain image views, rebuilds swapchain, views, depth, MSAA and framebuffers, resets the observed image-index record and invalidates the cached backbuffer readback (VULKAN-404: it described the destroyed swapchain). Per-frame fences and semaphores are not recreated: they are indexed by frame slot, not by the changing image count. Base render passes are deliberately kept for the renderer's lifetime. The cleanup also clears the renderer's custom-viewport flag: GraphicsDevice's UpdateViewportFromWindow must reconcile the viewport after a resize, so do not assume an old custom viewport stays meaningful. SetSwapInterval records the request first and uses this same rebuild when the device is initialised; an unchanged interval is a no-op.
Render targets and retirement
A single RenderTarget2D or cube face is translated to its Vulkan target renderer. MRT builds a VulkanMRTProxy from existing attachment views and checks the real attachment count, independent blend, dimensions, sample counts and duplicate subresources. The proxy's framebuffer can be named by the current binding and by pending draw, clear and batch records; on unbind it moves to a frame-generation retirement list. Ordinary native destruction is deferred the same way until a fence proves the consuming frame complete, and ProcessRetiredResources(false) runs after that wait at the next submission (REMED-GFX-075). Never free an attachment, descriptor or proxy because the public target was unbound: submitted or queued work may still refer to it. The neutral transaction is described in textures and render targets; the user-side MRT rules are in Render targets.
Destructor: explicit owner disconnection
The destructor unregisters its window mapping first. Without a logical device it only destroys the instance. Otherwise it waits for device idle and then works through an ordered walk:
- Release every current and retired MRT proxy's framebuffer and disconnect its owner (VULKAN-405: a proxy share left in a pending record by a process that exits without presenting would otherwise be destroyed after
vkDestroyDevice, reading a finished renderer). - GPU timers, compute shaders and storage buffers (compute pipelines own descriptor sets naming storage buffers), each
ReleaseVulkanResources()thenDisconnectOwner(). - Externally owned render targets, vertex and index buffers; then the renderer's own per-frame sprite, 3D and instance buffers.
- Externally owned textures, 2D texture arrays, storage textures, and the 3D textures, texture cubes and render-target cubes that VULKAN-407 added after they were found in no list at all.
- Force-free every retirement bucket (
ProcessRetiredResources(true)) after idle and before the descriptor pools the retired sets came from are destroyed; then MSAA and depth resources. - Descriptor pools including chained overflow pools, the layout, the sampler cache and default sampler, pipelines and per-frame uniform buffers, render passes, per-frame semaphores and fences, the command pool, then the swapchain.
vkDestroyDevice, then the debug messenger, the platform surface and finally the instance.
The live lists are non-owning registries. A resource C++ object that outlives the renderer must have its owner pointer disconnected so that its later destructor neither frees through a dead device nor reads a dead VulkanRenderer. That is a concrete invariant for any new Vulkan resource class: add it to the right live list, give it ReleaseVulkanResources and DisconnectOwner, and put it in the destructor's ordered walk; a conventional destructor alone is insufficient if public objects can outlive the device. See debugging shutdown and lifetime and architectural invariants.
Shader and pipeline boundaries
Stock GLSL sources live in src/shaders; spirv_shaders.hpp is the embedded SPIR-V the renderer consumes and compile_shaders.py regenerates it. Do not edit the generated header apart from its GLSL. One source file now has two consumers: shadow_sampling.glsl, included into the per-pixel BasicEffect, SkinnedEffect and PBR fragment stages, is also inlined by the SDL_GPU renderer's shader script (SMG-0032). Its resource sets and bindings are now the CNA_SHADOW_* macros, defaulting to this renderer's own set 1 layout (so Vulkan's SPIR-V is unchanged) and redefined by SDL_GPU for SDL_gpu's fixed sets. A change to its sampling, cascade, PCF or punctual formulas therefore needs both generated headers regenerated and both renderers tested; see SDL_gpu shader routes.
The ordinary draw route selects shader families from effect parameters and declared vertex inputs. A custom ShaderEffect takes SPIR-V words, not GLSL text (renderer availability); its scalar uniforms live in a fixed 128-byte push-constant block, and its texture unit N samples through SamplerStates[N] (VULKAN-166). Compiled XNA effects are conditional on CNA_VULKAN_COMPILED_EFFECTS and have their own path in VulkanCompiledEffect.cpp, using MojoShader's SPIR-V profile with fixed sets 0 to 3 and vertex-stage sampling refused. Pipeline and render-pass compatibility is keyed and cached; a change to vertex layout, formats, MSAA, depth or effect descriptors needs a cache-key audit, not just a shader edit.
Testing and a maintainer workflow
The family has one GoogleTest source, VulkanCompiledEffectTests.cpp, and a very large example-based suite in examples/CMakeLists.txt. That suite is guarded by CNA_BUILD_EXAMPLES, CNA_BUILD_TESTS, not Emscripten, not Windows, and CNA_GRAPHICS_RENDERER STREQUAL "VULKAN". Because modules/renderers/CMakeLists.txt re-points CNA_GRAPHICS_RENDERER to each family's identity while entering it, the guard is also true when VULKAN is a non-default member of CNA_GRAPHICS_RENDERERS; the registrations set no renderer selection, so in such a build confirm which renderer each executable actually creates before citing a result (the EasyGL and HEADLESS suites compare against the build default for this reason). The suite no longer needs SDL: without an SDL3 target the examples build with CNA_EXAMPLES_NO_SDL and run on the native X11 or Wayland platforms.
It registers smoke, orientation, shader and effect, draw, render-target, swapchain (Vulkan_SwapchainOutOfDate, Vulkan_SwapchainChurn, Vulkan_Swapchain_Sync), lifetime (Vulkan_ResourceLeak, Vulkan_MoveSemantics, Vulkan_DisposedResource, Vulkan_BoundResourceDispose, Vulkan_DeviceDisposeOrder, Vulkan_DeferredResourceLifetime, Vulkan_ModernResourceLifetime), adapter (Vulkan_AdapterQueryContract) and modern tests (Vulkan_ComputeStorageBuffer, Vulkan_IndirectDraw, Vulkan_ShadowCasterWinding), among many others. Some diagnostic corpus executables write pixel dumps for cna_diag_compare and are intentionally not CTest tests. Every example links a narrow terminate handler that turns "no usable Vulkan device" into a CTest skip; a skipped oracle is not a pass. In any build containing VULKAN, TestHelpers.cmake adds the [Vulkan Validation] output gate to every cna_register_renderer_test registration (VULKAN-393/408), because leak reports arrive during vkDestroyDevice, after the last statement a test could check; a small exemption list exists and cna_apply_vulkan_validation_gate re-applies the gate where a registration overwrote its fail pattern. Linux CI installs Vulkan development packages and Mesa drivers in platform and glTF jobs (lavapipe in the glTF job), but the exact host coverage of every test is not mapped here.
For a backend patch:
- Reproduce with the smallest registered Vulkan test.
- Decide whether the fault is before queuing, during command recording, at submit or present, or at retirement.
- Confirm the Khronos layer is actually active; for ownership issues set
CNA_VULKAN_LIFETIME_TRACE=1(one line per ownership transition, from enqueue to native free).CNA_VULKAN_SAMPLER_TRACEandCNA_VULKAN_TARGET_READBACK_TRACEare narrower probes for sampler/descriptor and render-target readback paths. - Add a test covering the relevant second frame, resize or disposal path.
- Run the focused test and renderer conformance, then a neutral graphics test and an independent backend.
Do not infer that all Vulkan features are complete from descriptor registration or a large test count. User-side setup is in Tutorial 85; how CNA's evidence is organised is in test architecture. No Vulkan test was executed for this page.
Curated reading order
vulkan/CMakeLists.txtandVulkanRendererDescriptor.cpp: compile gate, public identity and pre-window requirements.VulkanRenderer.hpp: pending-record queues, live registries, swapchain and frame-slot fields and caches, before the very large implementation.VulkanRenderer.cpp: constructor and destructor,CreateSwapchain/RecreateSwapchain,DrawIndexedPrimitivesEx/PushPending3DDraw,RecordCommandBuffer/SubmitFrame,SetRenderTargets/ProcessRetiredResources, in that order.VulkanCompiledEffect.cppandthe shaders directory: the stock versus compiled effect paths, and the shadow include shared with SDL_GPU.examples/CMakeLists.txtandTestHelpers.cmake: the test whose preconditions and failure signal match the change, and the output gate it runs under.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Compiled XNA effects: admission, reflection, passes and renderer runtimes — What happens to Direct3D 9 Effect Framework bytecode in CNA: admission order and preflight bounds, the reflected object graph, parameter upload, pass-state publication, cloning, the XNB EffectReader and per-renderer translation.
- Evidence tiers of the native modern GPU renderers — What VULKAN, SDL_GPU, WEBGPU and METAL implement at this snapshot, what evidence backs each, why a capability bit is not evidence, and the defect shapes these renderers exposed.
- The XNA stock effects: exact semantics, worked uses and verification history — BasicEffect, AlphaTestEffect, DualTextureEffect, EnvironmentMapEffect and SkinnedEffect in CNA: defaults, formulas, ordering traps, per-vertex versus per-pixel lighting, worked uses and the defect patterns behind the current code.
- Vulkan draw-time state, ordered clears, occlusion queries and descriptor pools — How CNA's deferred VULKAN renderer carries blend, stencil, viewport and scissor state, orders clears, counts occlusion queries and grows descriptor pools, with the defects behind each rule.
- Vulkan presentation, frame pacing and back-buffer readback — How CNA's VULKAN renderer picks its swapchain format and present mode, synchronises two frames in flight, and reads the back buffer without racing the presentation engine.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-096: VulkanRenderer's constructor leaks the Vulkan handles it created when a later construction step throws — VulkanRenderer::VulkanRenderer creates the instance, debug messenger, device, swapchain and other raw handles in sequence with no cleanup path, so a throw from a later step such as PickPhysicalDevice leaves them undestro
- CNA-BUG-103: VULKAN clears every colour attachment of a multiple-render-target set, so PreserveContents targets lose their contents when bound as part of an MRT set — GetOrCreateMRTRenderPass begins each colour attachment with LOAD_OP_CLEAR from an undefined layout (except split-pass continuations), and its cache key has no usage flag, unlike the single-target pass that honours Discar
- CNA-BUG-112: On VULKAN SpriteBatch draws are never tagged with the active OcclusionQuery: a sprite-only Begin/End span reports PixelCount 0, and sprites in a mixed span are counted only by record order — Only 3D draws are tagged with the active query (PushPending3DDraw is the only tagging site), so a query whose span holds only SpriteBatch draws reports 0: taggedDraws_ stays 0 and IsComplete answers complete with zero pi
- CNA-BUG-264: Vulkan passes XNA's normalised RasterizerState.DepthBias to vkCmdSetDepthBias unscaled, so a realistic bias has no effect — VulkanRenderer hands DepthBias to vkCmdSetDepthBias raw, but that constant factor counts minimum depth steps; EasyGL, OpenGL4, SDL_GPU and Direct3D 11/12 scale XNA's normalised value by the depth format, so -0.0001 offse
- CNA-BUG-271: known_bugs.md still lists the FNA3D dangling-device use-after-free as OPEN and a lost repeated SpriteBatch Begin/End as a live symptom, although the source has fixed both — CNA's own known-bug list keeps two entries whose fixes are in the source and pinned by registered tests: FNA3D resource renderers now hold a shared device state that the renderer clears (Fna3d_Device_Lifetime), and repea
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- Architecture
- Graphics architecture
- Internals
- Indexed draw trace · Textures and render targets · SDL_gpu (shares the shadow GLSL) · Renderer selection internals
- Maintainer workflow
- Fix a renderer bug · Debug shutdown and lifetime · Architectural invariants
- Tests and validation
- Test architecture · What to test after changing X
- Reference
- Test target index · CMake option index