Chapter 16 State Objects and Value Semantics
CNA’s four state-object classes all inherit GraphicsResource. Their static, ready-made presets exist before a GraphicsDevice; that is why the base accepts a null device (Chapter 12). An architectural choice worth stating up front is that CNA stores the three device-wide state objects by value. A device assignment copies the supplied state into its own CPU field, then calls the corresponding renderer application method. Thus mutating the original local value later cannot change the stored device value. CNA also declares the static presets const, whereas FNA’s static readonly fields protect only the C# reference — their public state properties remain mutable. The presets themselves are unbound, harmless GraphicsResource values in both implementations, not GPU allocations that must be disposed.
There is a second, more important rule than that value/reference difference: reading CNA’s mutable device-state getter gives access only to the CPU copy. Changing that reference does not call ApplyBlendState, ApplyDepthStencilState, or ApplyRasterizerState; those calls occur only in their three device setters. Reassign a changed copy to submit it. A former CNA test comment claimed that FNA’s reference alias would make a later BlendState mutation take effect on the next draw. Current FNA source does not support that conclusion: its blend/depth flush resubmits only when the selected state object identity changes, so a mutation of the already-current object is not dirty-tracked. FNA does reapply its rasterizer state every draw, but that implementation detail is not a portable update route. The safe rule in either API is to configure a state before assignment and assign it again after a change.
16.1 By-value state semantics
The value copy and submission boundary are easy to state abstractly and easy to get wrong in practice. GraphicsDevice::setBlendStateProperty(const BlendState&) takes its argument by const reference, stores an internal copy, and submits the supported fields. The mutable overload of getBlendStateProperty() returns that CPU copy; it is not a proxy that submits every field mutation:
The practical rule is therefore more conservative than the old “mutate through the getter” advice: construct or copy, change, and assign a fresh value every time the effective blend, depth-stencil, or rasterizer state changes. A C++ getter mutation can make later introspection misleading by showing a value the renderer was never asked to use.
16.2 The value surface is broader than the binding bridge
Every public property round-trips in the small C++ value objects, but not every property has the same submission route or renderer coverage. The three whole-state setters submit first and copy into the public device cache only after the renderer calls succeed; a native rejection therefore cannot leave the cache claiming the rejected state. Samplers are instead resubmitted for all sixteen pixel slots immediately before each draw. The current bridges are:
-
•
BlendState forwards its six source/destination/function values plus one BlendWriteState containing all four per-MRT color-write masks and the state’s MultiSampleMask. Its BlendFactor is then sent through a separate renderer hook. The similarly named device MultiSampleMask property remains only a C++ integer shadow and makes no renderer call; use the state-object property.
-
•
DepthStencilState forwards all of its depth/stencil fields on whole-state assignment. Its ReferenceStencil is also sent through the distinct device setter, whose renderer default is a no-op; Chapter 16’s EasyGL caveat therefore still applies to a standalone reference change.
-
•
RasterizerState forwards cull mode, fill mode, scissor enable, and both bias values, but not MultiSampleAntiAlias. The property is a retained CPU value rather than a generic rasterization command.
-
•
On every CNA draw, the device visits all sixteen pixel sampler slots. One hook carries filter, AddressU, AddressV, and maximum anisotropy; a second carries MaxMipLevel and MipMapLevelOfDetailBias. AddressW is still absent. The public vertex texture and vertex sampler collections are retained (and cleared when a texture is disposed), but no draw path forwards either collection to the renderer at all.
The presence of a common hook is not a promise that every implementation consumes it. All ApplyBlendState overrides had to make an explicit decision about BlendWriteState, but historical and 2D families may reject or ignore unsupported masks. The shared differential 3D oracle exercises color masks on SDL GPU, WebGPU, and LLGL; its optional 4x-MSAA leg is enabled only where a functional native sample mask exists. The newer sampler-mip hook has only four overrides: FNA3D stores both values, Glide accepts LOD bias but rejects a non-default maximum level, Metal rejects either non-default, and PortableGL deliberately ignores both because its textures have one level. All other families inherit the no-op default. Thus a pixel sampler entry is submitted at the next draw, but support for its mip controls remains a renderer claim to verify. No located draw test distinguishes AddressW or either vertex collection from its default behavior.
16.3 BlendState
Four presets — Additive, AlphaBlend, NonPremultiplied, Opaque — and a property surface covering the color and alpha blend function/source/destination triples independently, four independent ColorWriteChannels masks (one per potential MRT slot), a BlendFactor color, and a MultiSampleMask. Blend has thirteen values (One, Zero, the four source/destination color and alpha pairs, BlendFactor / InverseBlendFactor, and SourceAlphaSaturation); BlendFunction has five (Add, Subtract, ReverseSubtract, Max, Min). ColorWriteChannels is a flags enum with the same hand-implemented bitwise operator set already seen on DisplayOrientation (Chapter 6) and ClearOptions (Chapter 12).
The common bridge now carries the entire value surface: the six blend ordinals, the four write masks, the state-object sample mask, and the separately submitted blend factor. Native support is still narrower on some families; eleven renderer overrides explicitly ignore the appended write state, and some single-target APIs can honor only slot zero. The generic differential test is therefore stronger evidence than a round-trip property test: with blending disabled it proves that masked channels preserve the destination, enabled channels take the source, and an A–B–A sequence does not reuse stale pipeline state. Its optional MSAA case separately compares all-samples with mask zero.
Vulkan also has a historically serious bug in the six blend ordinals — for a long time its blend implementation was, in the project’s own words, “almost entirely fake”: one hardcoded blend equation applied regardless of what was actually requested, confirmed failing across five separate pixel tests before the fix replaced it with a genuine per-Blend/BlendFunction mapping across all nine of the renderer’s pipeline-creation sites. The later BlendWriteState work is a distinct contract and must not be inferred from that earlier factor/function campaign.
16.4 DepthStencilState
Three presets (Default — depth test and write on —, DepthRead — test on, write off —, and None — both off) sit atop a sixteen-property surface: DepthBufferEnable, DepthBufferWriteEnable, and DepthBufferFunction; plus a full stencil-test configuration (StencilEnable, StencilFunction, StencilMask/StencilWriteMask, ReferenceStencil, the three stencil-operation slots for pass/fail/depth-fail, TwoSidedStencilMode, and four separate counter-clockwise-face variants of the same operations). CompareFunction has eight values; StencilOperation has eight (Keep, Zero, Replace, Increment / Decrement and their saturating variants, Invert).
Depth testing itself is solid on the measured paths: DepthBufferWriteEnable and a five-case DepthBufferFunction selection test (Always, Never, Less, LessEqual, Greater) verify correctly on EasyGL and, as of a fix, Vulkan. That is not a sweep of all eight enum values. Stencil testing is where this state object’s real history lives. At one point, Vulkan’s apply-state entry point accepted fifteen parameters but genuinely stored only two — every stencil-related parameter passed through was simply discarded, and stencilTestEnable was never set on any pipeline at all. Five separate, independent checks (enable flag, masks, front-face operations, two-sided mode, reference-stencil propagation) each separately confirmed failing on Vulkan while passing on EasyGL, before the fix added real per-pipeline compare operations, full front/back VkStencilOpState configuration, and dynamic-state reference/mask values. A second Vulkan-specific bug was found in the same pass: depth-format selection checked a stencil-less format before it checked any stencil-capable one. EasyGL, meanwhile, had a subtler bug of its own: no window was ever created requesting an actual stencil buffer (SDL_GL_STENCIL_SIZE) at all — meaning every stencil test that appeared to pass was passing against a buffer with zero stencil bits actually allocated, a “correct code, no resource to act on” bug rather than a logic error. One gap named in an earlier pass of this chapter as open on both EasyGL and BGFX is now only open on one of them: IGraphicsRenderer::SetReferenceStencil does exist in the shared renderer interface, with a no-op default any renderer may override. BGFX now overrides it, mirroring Vulkan’s mechanism: every other stencil parameter is cached so a later standalone reference-value change can rebuild the renderer’s own front/back stencil state without a full state-object re-application. EasyGL has no override and inherits the interface no-op. Changing ReferenceStencil without reassigning the whole DepthStencilState is therefore broken on EasyGL, but not BGFX. A second gap named in the same earlier pass of this chapter as still open on all three hardware renderers is also closed: a stencil-only Clear call once ignored its flag on every renderer, but the current route dispatches it on EasyGL, Vulkan, and BGFX. The repair also exposed an independent Vulkan-specific bug along the way (every render-pass-creation site had its own stencil load-operation hardcoded to discard, regardless of what Clear() itself requested) — see Chapter 12 for the fix in full.
16.4.1 Masking a reflection with the stencil buffer
The classic real use of the stencil-test properties above, taken together, is a planar mirror: render the mirror surface into the stencil buffer only, then render the reflected scene a second time, but only where that stencil mark was left behind — so the reflection never spills past the mirror’s own on-screen footprint even though the reflected geometry itself may extend well beyond it. This needs two distinct DepthStencilState configurations, not one:
One of this chapter’s own already-stated facts directly constrains whether this exact technique is safe to ship across all three hardware renderers. ReferenceStencil above is set once, as part of constructing each DepthStencilState — this worked example does not rely on changing it independently afterward, which sidesteps the still-open gap flagged above (no real SetReferenceStencil override exists on EasyGL specifically, BGFX’s own fix now notwithstanding; a technique needing a per-draw-call reference value without rebuilding the whole state object would hit that remaining gap directly on EasyGL). The requested stencil value now reaches all three hardware renderers; later ordered-clear work also made stencil-only clears selective on Vulkan and BGFX. Vulkan records the exact attachment command at the issue position, and BGFX assigns an ordered view carrying only BGFX_CLEAR_STENCIL (§19.4.2). Portable code can still rely on what this technique’s own first pass already does — its unconditional CompareFunction::Always / StencilOperation::Replace configuration overwrites the stencil value outright every frame, never depending on it having been zeroed first, which is exactly why this specific technique remains correct without assuming selective clear semantics.
16.5 RasterizerState
Three presets (CullClockwise, CullCounterClockwise — the XNA default — CullNone) and six properties: CullMode, DepthBias, FillMode, MultiSampleAntiAlias, ScissorTestEnable, SlopeScaleDepthBias. CullMode has three values (None, CullClockwiseFace, CullCounterClockwiseFace); FillMode has two (Solid, WireFrame).
ScissorTestEnable is deliberately only an enable switch. Its rectangle lives separately in GraphicsDevice.ScissorRectangle; changing either property immediately routes only that half of the state to the renderer. The distinction is observable: EasyGL, Vulkan, BGFX, WebGPU, SDL GPU, D3D9, and D3D11 retain an independent Boolean and rectangle. The SDL renderer and ASCII instead apply the rectangle as an always-active clip and ignore the Boolean. D3D12, Software, Canvas, and FREEDIRECT never turn the pair into a custom raster clip at all; Headless only validates and traces it. The full scope and evidence matrix is §19.4.3. SpriteBatch no longer creates a second routing gap here: Begin() resolves a null argument to RasterizerState::CullCounterClockwise and otherwise submits the caller’s copied state through this same GraphicsDevice setter.
All three cull modes verify correctly on both EasyGL and Vulkan via a genuine opposite-winding-order pixel test, and a small architectural detail worth noting: Vulkan flips clip-space Y and compensates by setting VK_FRONT_FACE_CLOCKWISE instead of the API’s default counter-clockwise convention — confirmed, by the same test, to cull the same input winding identically to EasyGL despite the two renderers taking different routes to get there. FillMode::WireFrame is verified on both EasyGL (via a GL_LINES re-expansion, since OpenGL ES has no glPolygonMode to fall back on directly) and Vulkan (VK_POLYGON_MODE_LINE). DepthBias and SlopeScaleDepthBias have one known, pre-existing failure at an extreme magnitude (DepthBias = -1e6) on Vulkan specifically — the other three sub-cases pass, and this was not re-investigated as part of this state object’s own audit. BGFX’s CullMode / FillMode / DepthBias / ScissorTestEnable are, by design, not independently pixel-verified at all — BGFX has no GPU-readback API in this project, so its state coverage is smoke-test/no-regression only, a scope difference worth remembering whenever this book or the project’s own docs describe a BGFX row as “correct.”
16.5.1 Shadow-map depth bias and a wireframe debug toggle
DepthBias and SlopeScaleDepthBias exist for exactly one recurring real problem: shadow acne, the self-shadowing Moiré artifact a shadow-mapped surface shows when its own depth-buffer sample is compared against itself with no tolerance at all. A small, fixed bias plus a slope-scaled term (larger at grazing angles, where acne is worst) is the standard fix:
This chapter’s own audit notes one specific, narrow failure at an extreme magnitude (DepthBias = -1e6) on Vulkan — ordinary shadow-mapping bias values like 0.0015 above are nowhere near that magnitude and are unaffected; the caveat only matters if a game computes its bias value dynamically and that computation could, under some real input, blow up toward an extreme.
A second, unrelated everyday use for the same class — toggling a wireframe debug overlay — is a one-property change with no bias involved at all:
Remember this chapter’s own scope caveat before trusting a wireframe-toggle test on BGFX specifically: FillMode there has no GPU-readback pixel verification at all (BGFX has no readback API in this project), so FillMode::WireFrame on that one renderer is smoke-tested, not pixel-proven, unlike the same toggle on EasyGL or Vulkan.
16.6 SamplerState
Six presets — AnisotropicClamp / Wrap, LinearClamp / Wrap, PointClamp / Wrap — cover exactly the combinations real FNA ships, no more. Seven properties: AddressU / V / W (TextureAddressMode: Wrap, Clamp, Mirror), Filter (TextureFilter, nine values spanning point/linear/anisotropic and their per-mip-stage combinations), MaxAnisotropy, MaxMipLevel, MipMapLevelOfDetailBias.
The central, now-fixed finding here was not about the address-mode or filter mapping tables themselves — both were correct in isolation for as long as they were tested — but about whether the supported pixel GraphicsDevice.SamplerStates route was actually applied before a 3D draw. It was not, for three separate, renderer-specific reasons. EasyGL’s eighteen user-primitive overloads omitted the internal sampler-application step; only the ordinary buffer-bound DrawPrimitives and DrawIndexedPrimitives calls made it. On Vulkan, the dual-texture descriptor-set path hardcoded a default sampler into its slots regardless of what sampler state was actually current; on BGFX, a dual-texture draw bound its second texture slot using the first slot’s sampler flags. All three were fixed, and — once actually applied — the underlying address-mode and filter mappings themselves were confirmed correct on EasyGL and Vulkan for a real 3D stock-effect draw. That conclusion never extended to VertexSamplerStates: the public collection is still retained only, as the binding-boundary section above shows.
Two further, more severe gaps were found and fixed together. First, mip-aware filtering: EasyGL correctly honors any explicit Mip-qualified filter, but Point and Linear on their own always sample mip level 0 regardless of minification — a deliberate, documented deviation from FNA (which is mip-aware for these two filters too), because CNA does not set GL_TEXTURE_MAX_LEVEL per texture, and applying a mip-aware filter unconditionally to the common non-mipmapped case would render as GL-incomplete (solid black) instead. Second, and more severe: Texture2D::SetData at a mip level above 0 was a total, silent no-op on both Vulkan and BGFX — the same historical failure shape their Texture3D / TextureCube readback paths once had (Chapter 14 now records the corrected live matrix). Mip-level SetData is now a real GPU upload on all three hardware renderers, but that upload fix must not be mistaken for universal SamplerState mip control. AddressW still has no common argument. MaxMipLevel and LOD bias now reach the separate ApplySamplerMipState hook, but only FNA3D consumes both without rejecting a non-default; Glide consumes only bias, Metal explicitly refuses non-default values, PortableGL has a documented one-level no-op, and every other renderer inherits the interface no-op. The API surface is wired, but the implementation matrix is deliberately sparse. Anisotropic filtering itself: correct on Vulkan (queries the real device capability and clamps the requested level); EasyGL was silently falling back to plain trilinear filtering regardless of the requested anisotropy level until a fix wired real OpenGL anisotropic-filtering-extension support, clamped to the driver’s own capability; BGFX remains partially fixed — it enables the anisotropic sampling flag but never communicates the actual requested level, an on/off switch rather than a graduated one, still open. The newer renderers widen that matrix: WebGPU and D3D11/D3D12 create real anisotropic samplers (the Direct3D pair accept only feature level 11.0 or newer, whose maximum anisotropy is 16); D3D9 maps the filter and maximum but neither capability-checks nor clamps against D3DCAPS9::MaxAnisotropy; SDL GPU stores the requested maximum but never puts it in a queued command or sampler-cache key; and Software ignores every sampler value after the slot-range check. ASCII does not even forward ApplySamplerState to the SDL renderer it wraps. The last three therefore also make SupportsCapability(AnisotropicFiltering) definite false positives, since they inherit the interface’s default true; D3D9’s answer is conditionally unreliable on the current device. See Chapter 19’s complete capability table.
16.6.1 Crisp pixel art and a minified, mipmapped floor
Two everyday sampler needs pull Filter in opposite directions, and the chapter’s own mip-awareness deviation above means the obvious choice is not always the correct one for both. A 2D pixel-art sprite wants hard, un-blurred texel edges at every zoom level — the PointClamp preset, unmodified, is exactly right, and mip level 0 is the only level a sprite like this has anyway:
A distant, minified 3D floor texture with a real mipmap chain exposes an EasyGL-specific choice. On that renderer, plain TextureFilter::Point and Linear sample level 0 regardless of minification; selecting a mip-qualified value is therefore required for a blocky but distance-stable result:
This is EasyGL’s deliberate deviation from FNA (which is mip-aware for plain Point/ Linear too), not an edition-wide promise about every CNA renderer. EasyGL keeps the plain filters mip-unaware because unconditionally enabling mip selection on the common one-level texture would make it GL-incomplete and black. Other renderers map the same enum through their own native sampler contracts; carry this choice across only after a renderer-specific pixel test.
16.7 The recurring device-default copy defect
Every one of these four state classes independently had the identical small bug at some point in its history: its static presets did not set their own Name property. This sounds trivial, but it had a real, non-cosmetic consequence for DepthStencilState specifically — GraphicsDevice’s own constructor did not actually copy DepthStencilState::Default and RasterizerState::CullCounterClockwise into its own member fields at construction, and this had gone unnoticed because the values happened to coincide by luck, until Name existed as a distinguishing field that made the missing-copy bug observable at all. Fixing the missing-Name bug on all four classes, in other words, is what surfaced a real, separate device-construction bug — a small illustration of how a seemingly cosmetic gap in one place can be hiding a functional one somewhere else entirely.