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.
Cheatsheet for Lesson 7.11
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 thatbase_vertex_locationis signed.- signed: Note that
base_vertex_locationis signed. primitive_type:primitive_typeis an argument in Direct3D 9; in Direct3D 11 the topology is bound state set earlier byIASetPrimitiveTopology, 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'sDrawIndexedPrimitive, 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
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.
Read the arguments precisely
Section titled “Read the arguments precisely”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.
Indices read, base added, vertices fetched
Bound index buffer
DrawIndexed(3, 3, 4)
add 4 to each
Vertices fetched: index + baseRead 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 identifyResult: 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
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
pass = world.
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.
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.
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.
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.
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.
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.
i32: −1, an ordinary negative vertex shiftu32: 4,294,967,295base_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 listindex_count / 3 ≠ triangles you can seeindex_count ≠ unique vertex countSubmitted 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 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.
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.
Shader programs and the cost of a draw
Section titled “Shader programs and the cost of a 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
#ifdeflines, 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.
Why many small draws cost frame time
Section titled “Why many small draws cost frame time”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.
Draw a frame boundary around your samples
Section titled “Draw a frame boundary around your samples”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.
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 moves the work earlier
Section titled “Direct3D 12 moves the work earlier”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.
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?
For a triangle list the two differ by a factor of three. Direct3D 11's DrawIndexed takes an index count; Direct3D 9's DrawIndexedPrimitive takes a primitive count. Confirm which unit an argument carries before porting a threshold, and note that base_vertex_location is signed in Direct3D 11 for the same care-with-units reason.
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.
Plain Markdown, saved in this browser.
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.