Vulkan draw-time state, ordered clears, occlusion queries and descriptor pools
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 Vulkan test was executed for this page. Driver observations quoted from CNA's source comments (lavapipe, AMD RADV) are evidence for those drivers only; the SpriteBatch occlusion-query and zero-area scissor statements are readings of the source that no registered test decides.
The VULKAN renderer does not execute a public draw when it is called: it stores a record and replays it into a command buffer when the frame is submitted. That delay decides how state objects, clears, occlusion queries and descriptor sets have to work. This page explains the exact semantics at this snapshot, with the defects that taught each rule: blend and stencil state that used to be accepted and then discarded, clears that could not express their position, a query that spans only one contiguous run, and descriptor pools that grow instead of capping. It is for maintainers changing VulkanRenderer and for game developers who need to know what a Vulkan build really does with their state.
A recurring defect shape: state accepted, then discarded
An interface that takes a complete state packet while the implementation consumes only part of it produces no error at all; it produces plausible pictures. Two historical Vulkan defects had exactly this shape, and their repair defines the current code. Both are fixed at this snapshot; they are described here because the same shape keeps reappearing in new renderers.
Blend state
VulkanRenderer::ApplyBlendState used to keep only one Boolean, whether blending was on, and every pipeline hard-coded the BlendState.NonPremultiplied equation whenever it was. CNA's records describe several independent blend pixel tests failing before the repair (tracking item Task 868). Today the entry point stores all six values (colour and alpha source factor, destination factor and function), the four per-slot ColorWriteChannels masks and the multisample mask, and blending counts as enabled unless both colour and alpha are the Opaque preset (One/Zero). One shared helper, FillBlendAttachmentState in VulkanRenderer.cpp, turns them into a VkPipelineColorBlendAttachmentState for every pipeline-creation function, because the fix had to reach every place a graphics pipeline is built rather than one central function. XNA's Blend.BlendFactor maps to VK_BLEND_FACTOR_CONSTANT_COLOR, and the XNA write-mask bits (R=1, G=2, B=4, A=8) are identical to VK_COLOR_COMPONENT_*. The blend state is part of the pipeline key, packed into a fixed bit budget; the SDL_GPU renderer later chose a hashed key for the same job (SDL_GPU pipeline keys).
Depth-stencil state
ApplyDepthStencilState takes sixteen parameters. It once stored only the depth-enable and depth-write flags; every stencil value was dropped and no pipeline ever set stencilTestEnable. CNA's records describe separate checks of the enable flag, the masks, the front-face operations, two-sided mode and the reference value each failing. The same investigation found that the device-wide depth format tried a stencil-less format first (see the back-buffer depth format). The current design:
FillDepthStencilStatewritesdepthCompareOp,stencilTestEnableand full front and backVkStencilOpStateblocks into every 3D pipeline; withTwoSidedStencilModefalse the back face simply copies the front, which is FNA's behaviour (the counter-clockwise fields are ignored, not reset).- Compare mask, write mask and reference are true Vulkan dynamic state, set per draw with
vkCmdSetStencilCompareMask,vkCmdSetStencilWriteMaskandvkCmdSetStencilReference. GraphicsDevice.ReferenceStencilis an independent device property in XNA and FNA, so the renderer overridesSetReferenceStencil()to change the value without a full state re-application. The baseIGraphicsRendererdefault is a no-op; the EasyGL renderer overrides it as well.
The first stencil repair also swapped the counter-clockwise operations onto the front face, blaming a software-rasteriser quirk it could not isolate. A later measurement on an AMD Radeon 780M with the RADV driver (VKPAR-0005, recorded in the source comment above FillDepthStencilState) showed stencil gating normally and the swap inverting all three operations. The convention now written down beside the code: clockwise-as-displayed is the front face, the ordinary StencilFunction/StencilPass/StencilFail/StencilDepthBufferFail apply to it, and the counter-clockwise fields go to back. The stock vertex shaders' pos.y = -pos.y only converts Direct3D-style clip space to Vulkan's, so it mirrors nothing. The lesson is the one that the evidence page generalises: a plausible explanation attributed to one driver is not a measurement.
Draw-time snapshots: viewport, scissor and blend factor
Because replay happens at submit time, any state read from the renderer's mutable members during replay would give every queued draw the frame's last value. PushPending3DDraw and the SpriteBatch end-of-batch path therefore copy the scissor rectangle and enable bit (REMED-GFX-013), the viewport (REMED-GFX-062) and the blend factor (REMED-GFX-070) into each record; SetScissorRect, SetViewport and SetBlendFactor are store-only. At replay the two helpers treat the rectangles differently on purpose:
| State | Replay rule | Why |
|---|---|---|
Scissor (computeScissor) | An enabled rectangle is clamped to the current target; a disabled rectangle, or one with zero width or height, becomes the full target before vkCmdSetScissor | A VkRect2D must lie inside the framebuffer |
Viewport (computeViewport) | Not clamped; an unset or zero-sized viewport becomes the full target; depth range clamped to [0, 1]; height stays positive | A viewport is the NDC-to-framebuffer transform, so clamping would move geometry; Direct3D and XNA pass an overhanging viewport through; the Y flip is done in the vertex shader |
| Blend factor | vkCmdSetBlendConstants per draw | Render-target passes would otherwise get no constant at all |
A pass opens with full-target defaults and each draw replaces them with its own snapshot, so a later state change cannot retroactively unclip or move a draw queued earlier in the same frame. The shared Vulkan_Deferred_Viewport and Vulkan_Deferred_Scissor suites queue several changes inside one unflushed bind cycle, on the back buffer and on a RenderTarget2D, so a final live value cannot satisfy them by accident. The scissor rule has one consequence worth knowing: an enabled scissor rectangle of zero area draws unclipped on this renderer, as it does on SDL_GPU, whereas WEBGPU keeps the zero-area rectangle and so clips the draw entirely, and METAL suppresses the draw (see WebGPU dynamic state and Tutorial 109). That cross-renderer difference is recorded for review rather than resolved here.
Clear is an ordered, aspect-selective command
Every public Clear becomes a PendingClear record (REMED-GFX-129) carrying a shared reference to its destination, its bind-cycle segment, its position in the frame's public command stream (order), the aspects it names (wantColor, wantDepth, wantStencil) and their values. Command recording discovers passes from this stream as well as from draws, so a target that is only cleared still gets a render pass. RecordCommandBuffer then replays each segment's draws in slices between its clears:
- A clear that precedes every draw of its cycle may ride the pass's load action, but only when that pass really clears its colour attachment, that is on a discarding target. The shared layer issues a clear on every
DiscardContentsbind, so folding saves one full-target clear per bind. - Every other clear, and every clear at all on a preserving target (whose pass uses
VK_ATTACHMENT_LOAD_OP_LOAD), is recorded asvkCmdClearAttachmentsafter the preceding slice of draws.vkCmdClearColorImageis avoided because it is a transfer command that cannot be recorded inside a render pass. - The clear rectangle is always the whole render area. Viewport and scissor do not restrict a clear, a contract CNA established across renderers (REMED-GFX-018) and that checks V1/V2 of the shared ordered-clear fixture assert.
- The aspect mask follows the attachment: a stencil request on a depth-only format drops the stencil bit, because clearing a stencil aspect that does not exist is invalid usage.
ClearDepth,ClearStenciland the combined variants record ordered clears of just their aspects. Before REMED-GFX-129,Clear(ClearOptions::DepthBuffer, …)only updated a fallback value and did nothing on Vulkan.- The first back-buffer cycle of a frame always clears, because an acquired swapchain image has undefined content; later back-buffer cycles load what earlier cycles stored unless they issue their own clear, per aspect.
An occlusion query left open by one slice stays open across a clear in the middle of its segment, since ending and re-beginning the same query index in one pass is invalid usage.
Two renderer-neutral suites make these rules observable. Vulkan_GraphicsDevice_ClearOptions first establishes distinct colour, depth and stencil contents and then encodes whichever aspects survive a clear into colour probes, so it can tell the three aspects apart instead of asserting one pixel. Vulkan_GraphicsDevice_OrderedClear runs the shared graphicsdevice_ordered_clear_test.cpp, which queues each whole public sequence (for example draw, clear, draw on a preserving target) and only then reads anything back: no readback, present, flush or extra frame sits between two commands of a sequence, so a renderer cannot pass by being forced to settle in between. Depth and stencil are proven by rendering geometry that the stored values must reject or gate, never by reading them back, and a silent validation layer is not counted as evidence.
The black-frame defects this replaced
Two render-target defects produced black frames with no error. First, Clear() once changed only global clear values while command recording discovered passes solely from draws, so a target that was cleared and unbound without a draw never entered recording (Task 875):
device.SetRenderTarget(&probe, CubeMapFace::PositiveZ);
device.Clear(Color::CornflowerBlue);
device.SetRenderTarget(nullptr); // no intervening draw: the former failure trigger
An intermediate repair added a list of cleared targets; the PendingClear stream has since superseded it. Vulkan_RenderTarget2D_ClearOnlyRoundtrip still fills two targets without a draw and samples both. Second, SpriteBatch content rendered into all six faces of a RenderTargetCube, unbound and then sampled through EnvironmentMapEffect came out black; Vulkan_RenderTargetCube_SampleAfterUnbind now expects the blue centre. CNA's records do not identify the commit that fixed the second defect. CNA's own docs/rendertarget-support.md still lists these and several other Vulkan render-target features as open at this snapshot; the source and the registered regressions are the better evidence.
RenderTargetUsage: one predicate, per-aspect consequences
All renderers derive "preserve" from one shared predicate, RenderTargetUsagePreservesContentsEXT in RenderTargetUsage.hpp: only DiscardContents discards; PreserveContents and PlatformContents both preserve, as in FNA. RenderTarget2D and RenderTargetCube pass that Boolean to the renderer. On a DiscardContents bind, GraphicsDevice itself clears colour to opaque black and, where the attachments really exist, depth to 1 and stencil to 0 (the neutral sequence is in Textures and render targets: bind). An explicit ordered clear supersedes either policy.
On Vulkan the Boolean selects render-pass variants: the single-sample and MSAA render-pass caches each provide a clear and a load variant, and a preserving target's depth-stencil attachment gets the layouts and store operations it needs to survive a full unbind and rebind. For a RenderTargetCube, colour is per face (each face renders through its own framebuffer over a single-layer view), while one depth-stencil image is deliberately shared by all six faces, matching FNA. The cube's MSAA route is a separate multisampled 2D image with six array layers and one view per face (REMED-GFX-141), resolved into the corresponding layer of the single-sample, cube-compatible colour image. Six layers, rather than one image shared by the face being rendered, is what lets a PreserveContents face be reloaded for a partial update; the MSAA image is created TRANSIENT only for a discarding target. Vulkan_RenderTargetUsage, Vulkan_RenderTargetCube_Usage, Vulkan_RenderTargetCube_MsaaFace and Vulkan_RenderTarget_DepthStencilUsage distinguish these paths, including PlatformContents.
Render-target mips, MSAA and the MRT limit
- Mip generation. A render target created with
mipMap = trueallocates its chain with transfer usage, andVulkanTargetPassEXT::MaybeGenerateMipsruns after the pass: a per-levelvkCmdBlitImagecascade with linear filtering from the resolved level 0, with a layout barrier around each level. Tests:Vulkan_RenderTarget2D_MipChain,Vulkan_RenderTargetCube_MipChain,Vulkan_MrtMipFinalization. - MSAA. Both
RenderTarget2DandRenderTargetCuberesolve multisampled content (Vulkan_RenderTarget2D_MsaaResolve,Vulkan_RenderTargetCube_MsaaResolve). - MRT. At most four simultaneous targets. The cap is CNA's shared
MAX_RENDERTARGET_BINDINGSinGraphicsDevice.cpp, which mirrors FNA; it does not come from the Vulkan device limit. TheMultipleRenderTargetscapability asks the different question of whether more than one colour attachment is expressible (maxColorAttachments > 1), and the public answer is additionally limited by the XNA profile. - Texture mips. A plain
Texture2Dupload above level 0 goes throughUpdatePixelsLevelinto the requested subresource (Vulkan_Texture2D_Mip_RoundTrip); upper levels are authored by the game, as in XNA.
Occlusion queries in a deferred recorder
Vulkan records an occlusion query inside a render pass, but CNA's OcclusionQuery.Begin() and End() return long before the frame's command buffer exists. The renderer therefore turns a query into a tag (CNA's tracking items Task 447 and 854):
Begin()marks the query active on the renderer and resets its tagged-draw count;End()clears the mark. Neither records a Vulkan command.PushPending3DDrawtags every 3D draw queued while the query is active and counts it.- At the start of
RecordCommandBuffer, before any render pass begins, every query tagged this frame getsvkCmdResetQueryPool; resetting inside a render pass is not allowed. A query reused every frame, the idiomatic XNA pattern, is therefore reset every time it is used, not only at construction. - During replay, a contiguous run of draws carrying the same tag is wrapped in one
vkCmdBeginQuery/vkCmdEndQuerypair, so one query may legitimately cover several draws. An open query is closed at the end of each render pass. getIsCompleteProperty()pollsvkGetQueryPoolResultswithout waiting and stays false while the result is not ready;getPixelCountProperty()returns the value once it is.
Four precise consequences follow from that design:
- Only the first contiguous run per frame is counted. A query already recorded once this frame is deliberately not reopened, because Vulkan requires a reset between two begins and re-resetting mid-frame would corrupt the first run's in-flight result. If the tagged draws of one
Begin()/End()span a render-pass boundary (a render-target switch inside the span), the draws after the boundary are not summed. This is a real capability gap against what the hardware could do, and it is stated in the source rather than hidden. Keep render-target changes outside a query's span on Vulkan. - Exact counts need a device feature. Without
occlusionQueryPreciseandVK_QUERY_CONTROL_PRECISE_BIT, Vulkan only promises "some samples passed". The renderer enables the feature where offered and passes the bit only then (VULKAN-370);OcclusionQuery::isPixelCountPreciseEXT()reports which one you got. - An empty span completes immediately with zero (VKPAR-0026), as XNA reports it, instead of waiting for a result nothing will produce. A query disposed with draws still queued is detached from them first; the draws survive, and the query pool is retired behind the frame fence (REMED-GFX-075).
- Only 3D draws are tagged. The tag is applied in
PushPending3DDraw; reading the source, SpriteBatch batches recorded betweenBegin()andEnd()do not carry it, so on this renderer they do not contribute to the count. This is a reading of the code at this snapshot, not an executed observation.
The ordinary visibility-culling pattern fits these rules as long as the proxy is drawn with the 3D API inside one target:
// Illustrative: DrawBoundingProxy and DrawFullDetailMesh stand for game code.
OcclusionQuery query(getGraphicsDeviceProperty()); // requires HiDef and the capability
query.Begin();
DrawBoundingProxy(candidate); // cheap 3D stand-in; no render-target change inside the span
query.End();
// One or more frames later, without blocking:
if (query.getIsCompleteProperty() && query.getPixelCountProperty() > 0)
DrawFullDetailMesh(candidate);
Evidence: Vulkan_OcclusionQuery_PixelCount draws a visible quad (positive count), a quad behind a nearer opaque occluder (zero or far lower) and two non-overlapping half-quads inside one span (both contributions summed); Vulkan_OcclusionQuery_Precision asserts an exactly known fragment count rather than "greater than zero"; Vulkan_OcclusionQuery_Cycle covers the reset-and-reuse cycle. The user-side pattern, including the multi-frame ring of queries, is in Tutorial 61: Occlusion Queries.
Descriptor pools grow; they do not cap
Every Texture2D and RenderTarget2D on this renderer takes one combined-image-sampler descriptor set at construction, and the sampled-descriptor cache keyed by (VkImageView, VkSampler) takes one more for every distinct pair a draw uses. The base pool is sized by MaxDescriptorSets, which is 512, and created with VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT so single sets can be returned. At this snapshot 512 is a per-pool capacity, not a ceiling on live textures:
AllocateTexSamplerDescSetEXTtries the newest overflow pool first (the older ones are full, which is why the newer ones exist), then the base pool, and when every existing pool is full it chains another pool of the same size (VULKAN-390). Textures and render targets allocate through the same function (VULKAN-181/391).- It throws a named
std::runtime_erroronly if the device refuses to create a further pool, or a fresh pool refuses its first allocation. It never substitutes a resource. - A disposed resource returns its set to the pool it came from, through the frame-fenced retirement queue, so a game that streams textures in and out does not accumulate towards anything.
- The stock-effect descriptor pools are created with
kEffectPoolMaxSets(512 * MaxFramesInFlight) sets, and they and the custom-effect path chain further pools the same way throughAllocateFromGrowingPoolEXT. A caller that still gets no set refuses by name; theShaderEffectpath, for example, throws rather than binding a null descriptor set. GetTexSamplerDescriptorPoolCountEXT()reports how many pools back the cache, so a test can prove that chaining happened instead of inferring it from the pixels.
The history explains the design. When the single pool was full, the sampled-descriptor cache used to return the renderer's default white descriptor set, so a game with more than 512 live texture and sampler combinations drew white sprites with no exception, log line or validation message. Chaining replaced that, but at first only that cache used the growing pool; Texture2D and RenderTarget2D still allocated straight from the base pool. On the lavapipe software driver the gap was invisible, because the driver kept allocating past maxSets, which the specification permits. On RADV, Vulkan_DescriptorCapacityContract, whose subject is 256 simultaneously live textures, failed every leg until both callers went through the growing path.
The regression for the overflow path, Vulkan_DescriptorPoolOverflow, shows how to test an arm that volume cannot reach. Its source records that on the tested drivers even 4,000 simultaneously live pairs, and a pool shrunk to 8 sets, never produced an allocation failure, so a volume-only test would pass while executing none of the code it claims to cover. The test therefore injects VK_ERROR_OUT_OF_POOL_MEMORY through the one allocation helper every call site uses, and checks that a single injected failure chains exactly one more pool, that 640 distinct live 1x1 textures (each colour encoding its index, so white is never a legitimate answer) each draw their own texture, that a failure the fresh pool cannot satisfy raises the named exception, and that the validation layer stays silent. Vulkan_DescriptorCapacityContract checks each draw against a self-identifying colour, so a recycled or grown pool that aliased one resource onto another's binding would name the wrong resource.
Optional device features are negotiated, not assumed
CreateLogicalDevice enables an optional VkPhysicalDeviceFeatures member only when the physical device reports it, and records the outcome for the capability answers: fillModeNonSolid (wireframe; FillMode::WireFrame is honoured only when it is enabled), samplerAnisotropy (with the device's maxSamplerAnisotropy), independentBlend, occlusionQueryPrecise, drawIndirectFirstInstance and textureCompressionBC; the 4444 packed formats additionally need the VK_EXT_4444_formats extension and its feature bit. A device without a feature gets a working renderer with the corresponding capability reported false, not a creation failure. This was once investigated as a possible gap and found already correct; it is recorded here because a renderer's evidence record should show negative findings as well as defects. VulkanRenderer::SupportsCapability has no default: arm and the family compiles with -Werror=switch, so a new GraphicsCapability member stops the build until this renderer answers it; an out-of-range value is refused rather than claimed.
Formats follow the same rule. ClassifySurfaceFormatEXT answers from the physical device's real VkFormatProperties for every format the renderer can store and defers to the framework's Color-only rule for a format it has no mapping for (VULKAN-170), so non-Color textures are not blocked wholesale on Vulkan. The per-renderer summary is in Rendering backends: texture formats.
The depth-bias case: a test that measured the wrong convention
For a long time one Vulkan test, Vulkan_DepthBias, failed only at its most extreme tested bias magnitude, and an equivalent observation had been recorded on DIRECTX9, which suggested a shared driver-environment cause. The explanation at this snapshot, recorded in vulkan_depth_bias_test.cpp (VULKAN-091), is that the test used OpenGL's depth convention. Its flat triangles sat at z = 0 under an identity projection and were described as "depth 0.5", which is true in OpenGL, where clip z in [-1, 1] maps to [0, 1]. XNA uses Direct3D 9's convention, clip z in [0, w], and so does Vulkan; there z = 0 is the near plane, nothing can be biased in front of it, and the Less test fails. A separate off-screen experiment on every device the loader offered showed vkCmdSetDepthBias's constant factor behaving as specified on both the lavapipe driver (with the same D24_UNORM_S8_UINT format the renderer picks) and RADV; two drivers is the threshold CNA's Vulkan plan sets before attributing a result to one. The flat scenarios now sit at z = 0.5, the tilted ones span 0.2 to 0.8, and a new guard leg draws a flat triangle at z = 0 with a bias of -1e6 and requires it to stay red, which is what XNA's depth range means. RasterizerState.DepthBias is handed to vkCmdSetDepthBias as its constant factor and SlopeScaleDepthBias as its slope factor, both unconverted. That is not what FNA3D does: it multiplies the normalised XNA DepthBias by a per-depth-format scale (65,535 for Depth16 and 16,777,215 for Depth24 and Depth24Stencil8; its SDL_gpu driver adds 8,388,607 for 32-bit float depth) before applying it, and CNA's EasyGL, OpenGL4, SDL_GPU, WebGPU and Direct3D 11 and 12 renderers convert it as well, whereas Vulkan's constant factor counts smallest resolvable depth steps. On this renderer a realistic XNA magnitude (around 1e-4) is therefore effectively no bias, and only very large values such as the test's -1e6 have an effect; the source comment that says the mapping matches FNA omits the scale.
Coverage and what remains unproven
The renderer-specific pixel suite is large. Pixel tests that were once missing on Vulkan now exist as registrations that reuse the shared EasyGL sources verbatim: Vulkan_SpriteBatch_LayerDepthOrder, Vulkan_SpriteBatch_Rotation, Vulkan_SpriteBatch_Scale, Vulkan_SpriteBatch_SourceRectangleCropping and Vulkan_SpriteEffects_Flip for SpriteBatch, the Vulkan_SpriteFont_* glyph-placement tests, and Vulkan_Model_TwoMeshesEffects and Vulkan_Model_HierarchyChildMesh for multi-mesh models. Registration is not a result: no test on this page was executed for it, and the registrations run only in a build that selects VULKAN, with a display and a Vulkan device. The renderer is not part of the 32-fixture cross-renderer parity corpus and is not compared with real XNA output. The occlusion-query SpriteBatch observation and the zero-area scissor difference above are readings of the source that no registered test decides. How this evidence compares with the other native renderers is summarised in Evidence tiers of the native modern GPU renderers.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Graphics architecture
- Maintainer workflow
- Fix a renderer bug · Debug shutdown and lifetime
- Tests and validation
- Test architecture: GPU tests
- Reference
- Test target index