Rendering in Retrospect · #7 of 12

Draw Calls, State Changes, and Other Invisible Costs

Retrospective — written in 2026, looking back.

Open a profiler on a slow frame and the eye goes straight to the biggest draws: the million-triangle mesh, the fullscreen pass with the heavy shader. Those are the visible costs, and sometimes they really are the problem. But in my own renderer experiments, the frames that confused me most were the ones where no single draw looked expensive and the frame was slow anyway. The cost was distributed across hundreds of small decisions — draws, state changes, bindings — each cheap enough to ignore and expensive in aggregate. This post is about learning to see that class of cost.

A Draw Call Is Not a Triangle Count

It helps to be precise about what a draw call actually is. It is not "rendering a mesh." It is an instruction, issued by the CPU and consumed by the GPU, that says: with this pipeline state, these bound resources, and this vertex and index data, execute. The triangles are the payload; the instruction itself has a fixed toll — driver validation, state checking, command encoding on the CPU side, and state setup on the GPU side.

That toll structure explains an experiment result that surprises almost everyone the first time: a scene made of many tiny meshes can be slower than the same scene with far more triangles in a few large meshes. Consider an illustrative comparison — same total triangle count, two organizations:

Organization B does identical shading work in theory. In practice it pays the per-draw toll thousands of times, spends CPU time that A never spends, and gives the GPU a stream of tiny jobs with setup cost between each. The numbers vary by platform, which is why I am describing the shape rather than quoting figures — but the shape is reliable: per-draw cost is real, roughly constant, and completely indifferent to how few triangles follow it.

The lesson is not "minimize draws at all costs." It is that draw count is a budget line of its own, independent of triangle count, and a scene can blow it while looking trivially simple.

State Changes: The Toll Between the Tolls

Between draws, things change — and changes have their own prices, which differ wildly by what changed. In my experiments I found it useful to think of state in rough tiers, from cheapest to change to most disruptive. The exact ordering varies across graphics APIs and hardware, but the tiered thinking transfers:

This tiering explains why sorting matters, which is the next idea.

Sorting Is Paying the Expensive Tolls Less Often

A renderer rarely gets to choose the order the scene logically exists in, but it absolutely chooses submission order for opaque objects — depth order does not matter for correctness there, since depth testing resolves visibility. That freedom is worth money.

The standard approach in my demos is to build a sort key: a packed integer where the most significant bits encode the most expensive state to change, and the least significant bits encode the cheapest:

// Illustrative sort key layout — most expensive state in high bits,
// so a single integer sort groups expensive changes together.
uint64_t key = 0;
key |= uint64_t(pipelineId)   << 48;  // shader + fixed-function state
key |= uint64_t(materialId)   << 32;  // texture/descriptor set
key |= uint64_t(meshId)       << 16;  // vertex/index buffers
key |= depthBits                           ;  // tie-breaker, e.g. front-to-back
// sort draw items by key, then submit in that order

Sorting by this key means all draws sharing a pipeline are submitted consecutively (the expensive change happens once per group, not once per draw), within that, all draws sharing a material, and so on. The low bits can even do double duty: a front-to-back depth ordering within a group helps early depth rejection discard hidden fragments, so the same sort buys a second, unrelated saving.

Transparent objects are the exception that teaches the rule: they must render back-to-front for correct blending, so their sort is dominated by depth, and they pay whatever state costs that order imposes. That tension — one object set wanting state order, another wanting depth order — is not a flaw in the scheme. It is a clear view of what sorting is: choosing which cost to pay, with correctness as a constraint.

Batching and Instancing: Two Different Answers

Once draws are sorted, the remaining per-draw toll can be attacked two ways, and confusing them is common.

Batching merges geometry. Objects that share a material have their vertices combined into one buffer and submitted as one draw. It removes draws entirely, at the price of flexibility: the merged objects move as a unit, individual culling is lost, and dynamic objects force buffer rebuilds. In my experiments batching suited the static scenery — the hundreds of small props that never move — beautifully, and suited nothing else at all.

Instancing keeps objects separate as data but submits them together: one draw, one mesh, and a per-instance buffer carrying each copy's transform and variation:

// Vertex shader sketch: same geometry, per-instance data.
layout(location = 3) in mat4 instanceTransform;
layout(location = 7) in vec4 instanceTint;

void main() {
    vec4 worldPos = instanceTransform * vec4(position, 1.0);
    tintOut = instanceTint;
    gl_Position = viewProj * worldPos;
}

Instancing is the right answer when the same mesh repeats — foliage, debris, particles, crowd elements. It preserves per-instance culling at group granularity and lets dynamic instances update one small buffer instead of rebuilt geometry. Its limit is just as clear: it does nothing for a scene of unique meshes, which is precisely the scene batching addresses. They are complementary tools aimed at different scene shapes, and reaching for one where the scene wants the other is how invisible costs survive an optimization pass.

The Costs Nobody Draws

A few more invisible costs my experiments surfaced, each worth naming because none of them appear in a triangle count:

Seeing the Aggregate

The unifying principle I took from these experiments is that rendering cost is a sum of two very different kinds of terms: work that scales with pixels and triangles, and work that scales with decisions — draws issued, states changed, targets switched, sets rebound. The first kind is visible in any screenshot of your scene statistics. The second kind is visible only if you instrument for it: count your draws, count your pipeline changes, count your target switches, and print those numbers next to the triangle count where you will see them every day.

A scene that respects both budgets has a recognizable feel: sorted submission, static geometry batched, repeated geometry instanced, materials organized so bindings change rarely, and passes arranged so targets change for a reason. None of that is glamorous, and none of it shows in a screenshot. It shows in the frame graph — which is, in the end, the only review that counts.