Skip to content
Lesson 7.11Camera Geometry and Rendering APIs

Direct3D Draw Calls and Bound State

Read Direct3D draw calls in the context of their bound state, compare what the same arguments mean in Direct3D 9 and 11, and see how shader variants, instancing, and many small draws change a frame's cost.

Lesson 7.11
Read or listenPrint chapter or book
About 32 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 7.11Key ideas, reference and questions

A one-page summary of “Direct3D Draw Calls and Bound State” you can keep beside your notes. Plain Markdown.

Lesson 7.11 — Direct3D Draw Calls and Bound State: cheatsheet

  • Remember: Draw properties are category clues that need testing, rather than proof that a particular game object was drawn.
  • Also useful: Direct3D 11 state objects are saved configuration bundles: the context binds a bundle, and a draw uses whichever bundle is currently attached.

Key terms

  • base_vertex_location: Note that base_vertex_location is signed.
  • signed: Note that base_vertex_location is signed.
  • primitive_type: primitive_type is an argument in Direct3D 9; in Direct3D 11 the topology is bound state set earlier by IASetPrimitiveTopology, so the draw call does not mention it at all.
  • proxy: Every one of these is a proxy: an observable clue that may correlate with the category we care about, without being the category itself.

Formulas and patterns

  • index_count / 3 = triangles submitted, for a triangle list; index_count / 3 ≠ triangles you can see; index_count ≠ unique vertex count

Review

  • Q: A filter written for Direct3D 11 keys on index_count == 900. Ported to Direct3D 9's DrawIndexedPrimitive, it matches nothing. What is the most likely cause? A: Direct3D 9 passes a primitive count, so a 900-index triangle list arrives as 300
  • Q: A character in a scene is drawn with its body, weapon, and shadow. What does that show about draw calls? A: One object can take several draws, and one draw can cover many objects
Download cheatsheet (.md)

A draw call is still a request, not an object

Section titled “A draw call is still a request, not an object”

Lesson 7.3 made this point for OpenGL and it holds here: a draw call asks the GPU to consume indices using whatever is currently bound. It does not say “draw the player.” One character can take several draws — body, weapon, shadow — and one draw can cover many unrelated objects batched together.

What changes between the two APIs is not that idea. It is how much of the surrounding state you can see from inside the call.

The Direct3D 11 indexed draw looks compact because buffers, shaders, and topology are already bound to the context. Its function type has the hidden COM object pointer first, followed by a count and two offsets. Those three numbers choose geometry within the bound buffers; they do not identify a game object by themselves:

type DrawIndexedFn = unsafe extern "system" fn(
context: *mut c_void, // the hidden `this`
index_count: u32,
start_index_location: u32,
base_vertex_location: i32,
);
Argument Meaning
index_count how many indices to read from the bound index buffer
start_index_location where in that buffer to begin
base_vertex_location a value added to every index after it is read

Predict first

The bound index buffer holds six indices: 0, 1, 2, 2, 1, 3. The game calls DrawIndexed(3, 3, 4), so index_count is 3, start_index_location is 3 and base_vertex_location is 4.

Which vertices does the GPU fetch?

Answer: Vertices 6, 5 and 7. Start at position 3 and read 3 indices: 2, 1, 3. Adding the base vertex 4 to each gives 2 + 4 = 6, 1 + 4 = 5 and 3 + 4 = 7.

Which vertices does DrawIndexed fetch?The bound index buffer holds six indices: two triangles that share an edge. Change the three arguments and follow each index to the vertex the GPU reads.

Indices read, base added, vertices fetched

Bound index buffer

Six indices
position 0position 5
0
1
2
2
1
3

DrawIndexed(3, 3, 4)

Vertices fetched: index + base
62 + 4
51 + 4
73 + 4
Triangles submitted
3 ÷ 3 = 1 triangle

Read 3 indices from position 3: 2, 1, 3. Adding 4 to each fetches vertices 6, 5, 7.

Imagine two DrawIndexed(6, 0, 0) calls in one frame. Before the first, the context has a character texture and depth testing bound. Before the second, the program binds a UI texture and a state that ignores world depth. The same three draw arguments can now produce different images. If the character’s triangles are hidden behind a wall, the first call may produce no visible pixels at all. The draw arguments tell you which indices were submitted; the bound buffers, topology, shaders, textures, and depth state explain how those indices are used. This is the state-bound idea from OpenGL again, with Direct3D’s own way of storing and querying the state.

Identical draw arguments, different bound state. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

arguments = DrawIndexed(6, 0, 0)
bind the selected pass state
incoming_depth = 0.8; stored_depth = 0.3
test according to the bound comparison
write the pass color only if accepted
report what the arguments did not identify

Result: the earlier pixel remains. Identical index arguments did not establish identical pixels or an object category.

Final values: pass = world; indices = 6; start_index = 0; base_vertex = 0; pixel = earlier background; texture = character; comparison = LESS; incoming_depth = 0.8; stored_depth = 0.3; accepted = false.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    pass = world.

  2. Step 2, line 1. Both submissions use six indices and the same offsets.

    pass = world; indices = 6; start_index = 0; base_vertex = 0; pixel = earlier background.

  3. Step 3, line 2. The context supplies information outside the draw arguments.

    pass = world; indices = 6; start_index = 0; base_vertex = 0; pixel = earlier background; texture = character; comparison = LESS.

  4. Step 4, line 3. The fragment’s depth is0.8 and the earlier depth is0.3.

    pass = world; indices = 6; start_index = 0; base_vertex = 0; pixel = earlier background; texture = character; comparison = LESS; incoming_depth = 0.8; stored_depth = 0.3.

  5. Step 5, line 4. 0.8 is not less than 0.3, so this fragment fails.

    pass = world; indices = 6; start_index = 0; base_vertex = 0; pixel = earlier background; texture = character; comparison = LESS; incoming_depth = 0.8; stored_depth = 0.3; accepted = false.

  6. Step 6, line 5. Only an accepted fragment updates this model’s pixel.

    pass = world; indices = 6; start_index = 0; base_vertex = 0; pixel = earlier background; texture = character; comparison = LESS; incoming_depth = 0.8; stored_depth = 0.3; accepted = false.

  7. Step 7, line 6. Result: the earlier pixel remains. Identical index arguments did not establish identical pixels or an object category.

    pass = world; indices = 6; start_index = 0; base_vertex = 0; pixel = earlier background; texture = character; comparison = LESS; incoming_depth = 0.8; stored_depth = 0.3; accepted = false.

Note that base_vertex_location is signed. That is not a quirk; it exists so several meshes can share one vertex buffer, with each mesh addressed by a positive or negative shift from a common origin. If you log it as u32 a perfectly ordinary negative offset appears as a number near four billion, and you will be tempted to treat a normal draw as corrupt.

FF
FF
FF
FF
as i32: −1, an ordinary negative vertex shift
misread as u32: 4,294,967,295
For example, base_vertex_location = −1. The bits never change; only the reading does.

The same arithmetic from Lesson 7.3 applies to the counts:

index_count / 3 = triangles submitted, for a triangle list
index_count / 3 ≠ triangles you can see
index_count ≠ unique vertex count

Submitted triangles can still be degenerate, back-face culled, clipped, or covered by something nearer. Depth testing happens long after this call returns.

The Direct3D 9 equivalent carries more:

DrawIndexedPrimitive(
primitive_type, // triangle list, strip, fan, line list...
base_vertex_index,
min_vertex_index,
num_vertices,
start_index,
primitive_count // primitives, not indices
)

Two differences matter when you port a note from one to the other. primitive_count counts primitives, where Direct3D 11’s index_count counts indices — for a triangle list they differ by a factor of three, and mixing them up is the most common reason a ported filter matches nothing. primitive_type is an argument in Direct3D 9; in Direct3D 11 the topology is bound state set earlier by IASetPrimitiveTopology, so the draw call does not mention it at all.

Where the state lives is the real difference

Section titled “Where the state lives is the real difference”
Direct3D 9
Device holds render state
Query it during the draw
Direct3D 11
State objects bound to a context
Ask the context what is bound

Direct3D 9 keeps render state on the device as a large set of individually settable values. Inside a DrawIndexedPrimitive hook you can ask the device what is currently set — GetRenderState, GetTexture, GetTransform — and get an answer immediately.

Direct3D 11 replaces most of that with immutable state objects created up front and bound as a unit: a blend state, a depth-stencil state, a rasterizer state. From a DrawIndexed hook you retrieve what is bound with the context’s Get calls — OMGetBlendState, OMGetDepthStencilState, PSGetShader, IAGetVertexBuffers — and then read the description back off the object.

Two practical consequences follow, and they catch people out:

Those Get calls hand you a reference. COM Get* methods call AddRef on what they return, increasing the object’s reference count by one. Each successful query therefore creates one matching Release obligation. On a path that runs thousands of times per second, forgetting even one release causes a steady leak. Put the returned pointer in a Rust wrapper whose Drop calls Release; then an early return cannot skip cleanup. Lesson 6.4 later applies the same ownership idea to Windows handles.

yes
no
Get call, e.g. OMGetBlendState
COM AddRef runs
inside the call
Release called on
every path, even early returns?
reference count
returns to normal
steady reference leak

Querying costs more than logging. Retrieving several state objects per draw, on a thread with a frame deadline, is real work. Sample rather than record everything — one draw in a hundred, or only draws whose index count falls in a range you care about.

Label geometry by the state bound around it

Section titled “Label geometry by the state bound around it”

You cannot ask a draw call what it is drawing. You can group draws by properties that tend to correlate with what they are, then test whether the grouping holds:

Observable What it often distinguishes
bound pixel shader material family: skin, foliage, UI, particles
bound texture a specific asset
index count large static geometry versus small dynamic meshes
depth-stencil state world geometry versus UI drawn without depth
blend state opaque geometry versus transparent effects
draw order within a frame rendering passes: depth, opaque, transparent, UI

Every one of these is a proxy: an observable clue that may correlate with the category we care about, without being the category itself. A shader shared by characters and crates does not distinguish them. Lesson 15.8 later returns to the risks of treating a proxy as proof. Test a grouping by changing something you control — move, equip a different weapon, walk into another room — and check that the group changes the way the label says it should. A label that matched in one frame can be an accident of what that frame happened to draw.

The bound pixel shader in the table above is one compiled program among many that a game can choose from, and the number of draws in a frame has a cost of its own.

Variants, instancing, and shaders that do not draw

Section titled “Variants, instancing, and shaders that do not draw”
  • Variants. One shader source can have switches, written as #ifdef lines, such as “has a normal map” or “has fog.” Each combination compiles to a separate program, so three switches give 2 × 2 × 2 = 8 variants. Engines compile the ones a scene needs, which is why a game can hitch the first time something new appears.
  • Instancing. To draw 1,000 copies of one tree mesh, the game makes one draw call with 1,000 instances. A buffer holds each copy’s transform, a 4 × 4 matrix of 16 four-byte floats, which is 64 bytes, so 1,000 copies take 64,000 bytes. Earlier we saw that one draw can contain many objects; instancing is the deliberate version of it.
  • Compute shaders. A compute shader is not part of drawing. It is a general GPU program that runs once for each item in a grid of work, reading and writing buffers. Engines use it for particles, culling, mesh deformation, and post-processing.
  • Post-processing shaders. A fragment shader run over a full-screen rectangle, reading the finished picture as a texture, produces effects such as bloom and colour grading (Lesson 7.2).
  • Extending the standard material. Engines let a game add its own shader code and extra vertex data, such as a per-vertex wind weight, to a standard material instead of rewriting it.

Each draw call costs the CPU something even if the GPU’s share is tiny, because the driver checks the bound state and builds the commands. Suppose, as an illustration, one draw costs 5 microseconds of CPU time. Then 10,000 draws cost 10,000 × 5 = 50,000 microseconds, which is 50 ms, three frames of the 16.67 ms budget from Lesson 4.1, with the GPU waiting. Instancing, merging meshes that share a material, texture atlases, and culling (Lesson 7.2) all exist to cut the number of draws.

A stress test finds the limit by multiplying one thing, such as cubes, sprites, lights, animated characters, or text, and watching the frame time. If 1,000 cubes take 4 ms and 4,000 take 16 ms, each costs 0.004 ms and the 16.67 ms budget runs out at about 16.67 ÷ 0.004 = 4,167 cubes. Halving the window’s pixel count tells you which processor is the limit: if the frame time does not change, the CPU, not the GPU, is the bottleneck.

DrawIndexed fires many times per frame; Present fires once. Recording draws without frame boundaries produces a stream you cannot reason about, because you cannot tell where one frame’s ordering ends.

Number the frames in the Present hook from Lesson 7.10, and tag each draw sample with the current frame number. The draw index within a frame is then meaningful, which is what makes “the UI is the last eleven draws” a statement you can check rather than an impression.

draw 0..38depth pre-pass, no pixel shader
draw 39..174opaque world geometry
draw 175..190transparent effects, blending enabled
draw 191..201UI, depth test disabled
Frame 412's draws, grouped by what the bound state implies. The boundaries come from watching blend and depth-stencil state change, not from asking the draw call what it drew.

Bound both the samples per frame and the frames retained, exactly as in Lesson 6.11. An unbounded recorder attached to a render loop will consume memory faster than you expect and will change the frame times you were trying to measure.

Direct3D 12 does not submit draws through a context. Commands are recorded into command lists ahead of time and handed to a queue with ExecuteCommandLists, often built on several threads at once.

Hooking the submission point therefore tells you that a batch of pre-recorded work was queued, not what is in it. Present still works as a frame boundary because the swap chain interface is unchanged, but per-draw observation means dealing with recorded command lists rather than intercepting individual calls. The vtable reasoning from Lesson 7.10 transfers directly; the assumption that one hook sees one draw does not.

Use your own Direct3D program or an offline single-player session on a pinned version, and keep the analysis window separate as in Lesson 7.9. The transferable skill — reading submitted GPU work and correlating it with bound state — is what graphics debuggers, frame profilers, and capture tools do, and it is the practical way to understand what a renderer is actually asking the GPU to do.

Lesson 8.8 uses these graphics observations alongside memory snapshots, feature toggles, and verified restoration and shutdown order to assemble one reliable local tool.

Knowledge check

Count the right thing

A filter written for Direct3D 11 keys on index_count == 900. Ported to Direct3D 9's DrawIndexedPrimitive, it matches nothing. What is the most likely cause?

Finished Lesson 7.11?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 7: 11 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.