GPU Computing · Graphics Pipeline
Raster State Validation and Why Draw Order Is the Only Ordering Primitive
How per-draw state forms an opaque pipeline object, why changing render targets mid-command-buffer forces a flush of binned work, and why the API exposes almost no way to sequence fragments directly.
A graphics API hands the driver a stream of draws, each with a large block of state: rasterizer settings such as cull mode and fill rule, depth and stencil compare functions and masks, blend factors and equations, the render target set and its formats, the viewport and scissor rectangles, and the shader programs. None of these is a rendering feature in itself. They exist because the hardware’s fragment stage has a fixed set of comparators, blend units and write masks, and the API has to describe which configuration of that fixed hardware a given draw expects.
The design question is how that configuration is expressed. Early APIs exposed it as dozens of independent setter calls, each of which the driver might validate individually. The modern direction is the opposite: the whole configuration is an immutable object created once, validated at creation, and bound with a single command. Understanding why that matters requires understanding what the driver does with the configuration, and in a tiler-based GPU, that is not nothing.
Validation is a per-draw cost, so make it per-object
Consider an API in which each draw validates its own state: checking that the depth format matches the attachment, that the blend equation is supported for the colour format, that the shader’s outputs match the attachment count, that the stencil masks are consistent, that the viewport is inside the render area. Each check is cheap, but they are numerous, and the checks repeat identically for every draw in a batch that shares the state. A frame with ten thousand draws performs the same validation ten thousand times.
The pipeline state object removes the repetition by making the checks part of object creation. Creation is a slow path that happens once per distinct state combination, and the draw path only has to confirm that the bound object is compatible with the current render pass. Compatibility is a narrower relation than full validity: the object was valid when created, and the remaining question is whether its formats and sample count match the attachments currently bound. A modern API answers that with a compatibility check against the render pass’s format list, which is a comparison of a handful of enumerators.
The cost of the approach is the combinatorial explosion of objects. Blend mode, depth mode, stencil mode, rasterizer mode, vertex layout and shader program each multiply, and a renderer that creates a distinct object per combination ends up with thousands, each holding a driver-side compiled command stream. The mitigations are the same in every API: the state that changes frequently is separated from the state that does not, so that a small object can be swapped without rebuilding the large one; and the objects are cached on a hash of their description, created on first use and reused.
/* A compact representation of the state that affects rasterization and
fragment output. The driver validates this once and compiles it into
hardware register writes; draw-time cost is a hash lookup. */
struct raster_state {
enum { CULL_NONE, CULL_FRONT, CULL_BACK } cull;
bool front_ccw;
bool depth_test, depth_write;
enum { CMP_LESS, CMP_LEQUAL, CMP_ALWAYS } depth_cmp;
bool blend_enable;
enum { BLEND_ADD, BLEND_MIN, BLEND_MAX } blend_op;
struct { int src, dst; } blend_rgb, blend_alpha;
uint8_t color_write_mask; /* one bit per target */
bool conservative;
};
static uint64_t state_hash(const struct raster_state *s)
{
uint64_t h = 14695981039346656037ull; /* FNV-1a offset basis */
const unsigned char *p = (const unsigned char *)s;
for (size_t i = 0; i < sizeof *s; i++) {
h ^= p[i];
h *= 1099511628211ull; /* FNV-1a prime */
}
return h;
}
Hashing the raw bytes works only if the struct has no padding holes with indeterminate values; a renderer that does this fills the struct with a designated initialiser first, or compares field by field. The alternative used by the object-based APIs is to keep the description around and compare it, which is more code but cannot be defeated by padding bytes.
The render target change is the expensive one
Of all the state changes, the one with the largest hidden cost is the change of render target set. On a tiler, the render target’s contents are not in external memory in a form the pipeline can read cheaply; the tile buffer holds them while the tile is being processed, and the target’s external copy lags behind. Switching targets requires that whatever is in the tile buffer be written out — a flush — and that the new target’s contents be loaded in if the draw needs them.
An API that changes targets per draw therefore forces a flush per draw. A render target the size of the screen flushed hundreds of times per frame is a bandwidth disaster, and it is a disaster that is invisible when developing on a hardware path whose target is a plain framebuffer in memory.
The modern API answer is to make the sequence of target changes explicit and coarse. A render pass object declares the full set of attachments, their formats, their load and store behaviour at the beginning and end, and the subpass structure that determines which attachments are read and written at which point. The driver sees the whole sequence before the first draw and can plan the flushes; it can keep the tile buffer contents across several subpasses and write them out once, at the end. The contract is that a subpass reads only the attachments it declared, so the driver knows when a flush is genuinely required.
Determinism follows from the same structure. The specification defines the visibility of a write to a subsequent read by the barrier between them, not by the completion of the draw; a draw’s writes are not visible to a later draw that reads them unless a barrier sits between. That is the mechanism early depth testing and fragment ordering relies on when it says the per-fragment order is fixed only within the operations that the pipeline defines, because the driver is free to overlap two draws’ fragment work if their state objects prove no conflict.
Draw order as the only sequencing primitive
The pipeline exposes no way to say “draw this triangle after that one”, no fragment-level ordering beyond the submission order, and no per-pixel sequencing beyond the fixed sequence of tests. The tools that compress state are therefore the only lever a renderer has over sequencing, and the design of a frame is a design of a state sequence.
Three consequences follow, and they are what a renderer architect argues about.
The first is that occlusion is expressed by submission order, not by geometry. A renderer that submits opaque geometry front to back gets the benefit of early depth rejection and hierarchical culling at tile granularity, and one that submits back to front gets none, because each draw passes the depth test until the last. The measure is not the depth complexity itself but the depth complexity at the moment each draw executes. This is the reason a renderer sorts its draw list, and the sort key is the state object, not the distance, because state changes have their own cost.
The second is that the pipeline cannot be asked to reorder draws to improve locality. Some drivers do a form of this internally, and the specification’s ordering rules are phrased so that a reordering which preserves the per-pixel result is allowed. But it cannot be relied on, because the optimiser must prove that no draw’s output depends on another’s ordering, and the general case cannot be proven. A renderer that depends on the driver’s reordering for performance will see the benefit on one vendor and not another.
The third is that multi-pass techniques are sequences by construction. Deferred shading, shadow mapping, a depth prepass and a post-process chain are all ordered sequences of passes, and the pass boundaries are the synchronisation points. Each boundary costs a target load and store, and each costs a barrier whose scope the renderer must declare. The deferred shading and tiling comparison turns on exactly this: the tiler’s advantage is that the pass boundaries within a tile disappear, and the deferred renderer’s advantage is that the shading pass boundary is where it counts each visible surface once, so the two techniques choose different boundaries to pay for.
State that is not state
A few raster behaviours are exposed not as state objects but as shader declarations, and the split is informative. Depth output, discard behaviour and interpolation qualifiers are shader code, because they change the compiled program and the pipeline’s ability to hoist the depth test. Conservative rasterization is state, because it changes the rasterizer’s coverage test and the shader is unaware. Multisample coverage and alpha-to-coverage are state. Provoking vertex convention is state, but it is also visible to the shader’s flat interpolation, so it crosses the boundary.
The split follows the hardware: whatever the fixed-function units must be told, and whatever the shader compiler must know at compile time, is separated accordingly. Getting the split wrong has visible cost. A renderer that needs the depth test hoisted but writes depth for an effect must either split the effect into a fast pass and a slow pass, or accept the loss of early testing for the whole draw. A renderer that needs a per-fragment coverage decision must put the decision in the shader and accept the same loss, because coverage decided after shading cannot be hoisted. These are not bugs to be worked around; they are the shape of the interface, and the state object’s job is to make the shape explicit before the draw begins rather than after.
The validation model also determines what a failure looks like. A state object that is invalid fails at creation, with an error the renderer can attribute to a specific object and check once. A state that is set per draw fails at the draw, and the error is reported against the draw call that happened to be the first to combine two incompatible settings — which is usually not the draw that set either of them. The change from per-draw setters to per-object validation is therefore as much a diagnostic improvement as a performance one.
References
- Vulkan Specification — Khronos Group
- The OpenGL Graphics System: A Specification, Version 4.6 (Core Profile) — Khronos Group
- Direct3D 12 Programming Guide — Microsoft Learn
- A Trip Through the Graphics Pipeline — Fabian Giesen