Skip to content
Lesson 7.3Camera Geometry and Rendering APIs

OpenGL Draw Calls and State

Understand what indexed OpenGL draws request, how bound state, depth settings and shader inputs change their meaning, and why observation must keep the render thread moving.

Lesson 7.3
Read or listenPrint chapter or book
About 40 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.3Key ideas, reference and questions

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

Lesson 7.3 — OpenGL Draw Calls and State: cheatsheet

  • Remember: A draw request identifies submitted geometry and bound state, not one complete game object.
  • Also useful: A forwarding wrapper is a receptionist with the original extension saved: recording the request must not route it back through the receptionist indefinitely.

Key terms

  • recursion: The failure is recursion: a function calls itself.
  • shader: A shader is a small GPU program invoked many times in parallel.
  • GL_ALWAYS: The cave saves the flags and every general-purpose register before calling its helper, because the interrupted OpenGL function expects them unchanged; it fills the sixth replaced byte with a nop so no fragment of the old instruction remains; it restores normal depth state on the next non-highlighted draw; and it is switched off before its code patch is removed, so GL_ALWAYS is never left active.

Formulas and patterns

  • game calls glDrawElements; → wrapper copies a small sample of the arguments; → wrapper calls the real glDrawElements; → frame continues

Review

  • Q: What must an observational wrapper around an OpenGL draw call do after recording bounded metadata? A: Forward the call with the original calling convention, parameters, and required state
  • Q: A transparent observer wraps a drawing call, records one of its arguments, and forwards the call. What should the player see? A: An identical picture, with only the separate observation log changing
Download cheatsheet (.md)

Lesson 7.2 showed that a draw submission combines geometry with the graphics state already in effect. To learn which submissions correlate with an entity, we need to observe calls without changing the frame they produce. OpenGL applications call functions such as glDrawElements. First consider a whole-call wrapper: it sits between the caller and the real function:

game calls glDrawElements
→ wrapper copies a small sample of the arguments
→ wrapper calls the real glDrawElements
→ frame continues

If the game makes a draw with count = 9, the wrapper records that value and then passes the same arguments to OpenGL. The picture should remain identical; only the separate observation log changes. The wrapper must preserve the exact ABI and forward every normal call. The pinned Urban Terror implementation in Lesson 8.8 instead detours an instruction inside glDrawElements, so it has a narrower restoration opportunity. Keep that difference visible when comparing the two designs.

Observe a draw without holding up rendering. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

receive DrawElements(count = 9)
copy a compact sample
try to enqueue without waiting
if full: increment dropped count
forward the original arguments once
report submitted draws and stored samples

Result: one draw forwarded, one sample accepted. CPU submission does not establish GPU completion.

Final values: queue = room; sampled_count = 9; accepted = 1; dropped = 0; forwarded = 1.

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.

    queue = room.

  2. Step 2, line 2. The copied sample contains count 9; this count is indices, not a model identity.

    queue = room; sampled_count = 9; accepted = 0; dropped = 0; forwarded = 0.

  3. Step 3, line 3. The queue operation returns immediately.

    queue = room; sampled_count = 9; accepted = 1; dropped = 0; forwarded = 0.

  4. Step 4, line 4. No sample was dropped.

    queue = room; sampled_count = 9; accepted = 1; dropped = 0; forwarded = 0.

  5. Step 5, line 5. Pass the same draw arguments to the saved original function once.

    queue = room; sampled_count = 9; accepted = 1; dropped = 0; forwarded = 1.

  6. Step 6, line 6. Result: one draw forwarded, one sample accepted. CPU submission does not establish GPU completion.

    queue = room; sampled_count = 9; accepted = 1; dropped = 0; forwarded = 1.

glDrawElements(mode, count, element_type, indices) asks OpenGL to consume an ordered sequence of vertex indices:

Argument Meaning
mode how index results form primitives, such as triangles or lines
count number of indices to read
element_type width/format of each index, such as 16- or 32-bit unsigned
indices byte offset into a bound index buffer, or an address in old client-memory mode

The call does not directly identify a model. OpenGL combines those indices with the currently bound vertex attributes, textures, transforms, and other state. One character may require several draws—body, head, weapon, shadow—and one draw may contain many instances or a batch of unrelated objects.

With GL_TRIANGLES, every complete group of three indices describes one submitted triangle. The indices may repeat vertices, and some triangles may be degenerate, clipped, back-face culled, or hidden by depth. Therefore:

count / 3 = maximum submitted triangle groups
count / 3 ≠ visible triangle count
count ≠ unique vertex count

For example, this index buffer holds nine indices, so a call with count = 9 submits 9 ÷ 3 = 3 triangles:

00
11
22
32
41
53
62
73
84
triangle 0
triangle 1
triangle 2
nine indices, but only five different vertices: 0, 1, 2, 3, 4
Each box holds one index, and the label above it is its position in the buffer. Triangles 0 and 1 share the edge from vertex 1 to vertex 2, and triangles 1 and 2 share the edge from vertex 2 to vertex 3, so count (9) is not the number of vertices (5).

The indices parameter is especially easy to misread. If an element-array buffer is bound, a small value such as 0x120 is an offset into GPU-managed buffer state, not a process pointer that should be dereferenced.

The CPU usually records commands faster than the GPU completes them. A draw call returning does not mean its pixels are finished. Reads that force the GPU to catch up can stall both processors. The hook sees submitted work: it runs when the CPU queues a draw, not when the GPU finishes its pixels.

CPU frame N+1: build and submit commands ──────────────►
GPU frame N: execute vertices → rasterize → shade ─────────►

This overlap explains why a debugger pause or synchronous query can create a much larger performance change than its own instruction count suggests.

A hook sits on the program’s critical path

Section titled “A hook sits on the program’s critical path”

The render thread has a deadline: finish enough work for the next frame. A hook adds work to a path that may execute thousands of times per second. Even memory-safe code can cause stutter if it allocates, waits for a lock, formats strings, or writes files there.

Split the design into two rates:

  • the capture path copies a tiny fixed-size sample and returns immediately;
  • the analysis path runs later on a worker and may aggregate, label, or save bounded results.
Worker threadBounded channelRender thread, inside the hookWorker threadBounded channelRender thread, inside the hookIf the channel is full, the sample is dropped and a counter goes up. The hook never waits.copy mode, count, and type into a DrawSampletry_send the samplecall the real glDrawElements and returnthe worker takes samples whenever it runsaggregate, label, and save

The render thread’s work ends at try_send; everything slow happens on the worker’s schedule. Measure both dropped samples and time spent in the hook. A dropped sample leaves a gap in the log, while blocking slows the very frames you are trying to measure.

Locating glDrawElements in the debugger

Locating glDrawElements in the debugger.

For a 32-bit Windows OpenGL 1.x target, the calling convention is significant:

use std::ffi::c_void;
type GlDrawElements = unsafe extern "system" fn(
mode: u32,
count: i32,
element_type: u32,
indices: *const c_void,
);

The exact signature comes from the OpenGL API, not from guessing registers.

In an older fixed-function renderer, transforms and arrays may be ordinary OpenGL state. In a modern renderer, shader programs and buffer objects carry the same roles. Hooking the draw call still works, but the state needed to explain a draw changes with the API version and engine.

The wrapper needs a way to reach the real OpenGL function after it has intercepted a call. Store the resolved function pointer once during setup; every draw can then forward through that saved pointer without another lookup on the render thread:

use std::sync::OnceLock;
static ORIGINAL_DRAW_ELEMENTS: OnceLock<GlDrawElements> = OnceLock::new();

Initialization must resolve and store the real function before the wrapper can forward calls. Avoid resolving it recursively through your own wrapper.

The failure is recursion: a function calls itself. Recursion is useful when each call handles a smaller case and a base case eventually returns. Here there is no smaller case. If ORIGINAL_DRAW_ELEMENTS accidentally points to draw_elements_hook, the call path is:

game → draw_elements_hook → stored "original" → draw_elements_hook → ...

No call reaches OpenGL, so no call can return. Each nested call keeps a stack frame for its return address and local values, as Lesson 3.8 explained. The thread’s finite stack (often 1 MiB by default for a Windows executable, but configurable) eventually fills and Windows raises a stack-overflow exception, usually on the first draw. Resolving the real function before installing the wrapper breaks this cycle. The failure table at the end of the lesson calls this “Infinite recursion.”

Begin with a transparent wrapper:

unsafe extern "system" fn draw_elements_hook(
mode: u32,
count: i32,
element_type: u32,
indices: *const c_void,
) {
let original = ORIGINAL_DRAW_ELEMENTS
.get()
.expect("OpenGL wrapper was not initialized");
// 🔁 SAFETY: the wrapper preserves the API signature and forwards the exact
// arguments supplied by the original caller.
unsafe { original(mode, count, element_type, indices) };
}

Run the game and verify that rendering is unchanged. Only then add the code that copies samples.

Draw calls can occur thousands of times per frame. Synchronous file output inside the hook will destroy performance and can cause re-entry problems.

Use a bounded channel and allow drops:

#[derive(Clone, Copy, Debug)]
struct DrawSample {
mode: u32,
count: i32,
element_type: u32,
}
// 🎯 In the hook, observe the state immediately before forwarding the call:
let _ = sender.try_send(DrawSample { mode, count, element_type });

A worker thread can aggregate samples later. If the channel is full, losing a sample is better than blocking the render thread.

The count argument may correlate with a model in one exact build:

Filtering draw calls by count

Filtering draw calls by their count argument.

But count alone is fragile. Different objects can share a vertex count, and model updates can change it.

Collect features such as:

  • draw mode;
  • element count;
  • bound texture;
  • shader or fixed-function state;
  • model/view transform;
  • call site.

Then change one visible object and compare the samples.

Capture the smallest feature vector that can answer the current question:

#[derive(Clone, Copy, Debug)]
struct DrawFingerprint {
call_site: usize,
mode: u32,
index_count: i32,
index_type: u32,
texture: u32,
frame_number: u64,
}

call_site distinguishes engine submission paths, but compiler inlining and updates can change it. texture can identify a material in one scene, but skins, quality settings, and atlases can change it. A feature can be trusted to identify a category only after changing one thing at a time — the model, the map, a graphics setting — leaves it pointing at the same objects.

This is a classification problem. A threshold such as count > 500 separates the examples you observed, not necessarily the concept “player.” Test it against weapons, detailed scenery, menus, different models, and different graphics settings. Report false positives (something your classifier caught that is not a player) and false negatives (a player your classifier missed) instead of quietly turning a correlation into a definition.

A shader is a small GPU program invoked many times in parallel. The vertex shader and fragment shader of Lesson 7.2’s pipeline are the two central here: the first turns one vertex into a clip-space position plus values to interpolate, and the second turns one covered sample into a color, depth, or other output.

Values such as texture coordinates and normals are interpolated across a triangle. The fragment shader usually does not receive the original vertex value. This is why a draw’s texture, program, uniforms, and vertex layout are part of its identity.

Older fixed-function OpenGL calls hide these programs behind predefined state, but the pipeline still performs equivalent transformations and color calculations.

A shader works only with what the draw has bound for it:

  • Interpolated inputs, the per-vertex values blended across the triangle.
  • Uniforms, values that are the same for every invocation of one draw, such as the matrices, a light’s colour, or the time since the game started.
  • Textures and buffers, images it can sample at a (u, v) position and arrays of data it can read, bound before the draw as part of the state from Lesson 7.2.

A small fragment shader that turns a texture grey shows how little a shader can be. It weights the red, green, and blue of the pixel by how bright the eye finds each:

// Fragment shader, in GLSL: turn the texture's colour into grey.
vec3 colour = texture(image, uv).rgb;
float grey = dot(colour, vec3(0.299, 0.587, 0.114));
out_colour = vec4(vec3(grey), 1.0);

For a pixel of (200, 100, 50), the grey is 0.299 × 200 + 0.587 × 100 + 0.114 × 50 = 59.8 + 58.7 + 5.7 = 124.2, so the pixel becomes about (124, 124, 124). A shader that reads the time as a uniform can animate: a brightness of 0.5 + 0.5 × sin(time) is 0.5 at time 0 and 1.0 at time 1.5708 (a quarter of a turn in radians), and it repeats.

Shaders are written in a shading language: GLSL for OpenGL, HLSL for Direct3D, WGSL for WebGPU. A driver, or the engine ahead of time, compiles them to the GPU’s own instructions. Engines that support several graphics APIs often write a shader once and translate it for each.

Each on/off switch in a shader’s source doubles the programs that can be compiled, so three switches give 2 × 2 × 2 = 8 variants. Instancing draws many copies of one mesh in one call, with a 4 × 4 matrix of 16 four-byte floats, 64 bytes, per copy. A compute shader does not draw at all: it reads and writes buffers for work such as particles. And every draw costs CPU time even when the GPU’s share is tiny: at 5 microseconds each, 10,000 draws take 50 ms, three 16.67 ms frames. If halving the window’s pixel count leaves the frame time unchanged, the CPU is the limit. Lesson 7.11 works through these beside the bound state that each Direct3D draw carries.

OpenGL is a state machine: a call changes settings that remain active until another call changes them. Two depth settings show how much a later draw inherits:

glDepthRange(near, far) controls where a fragment’s depth lands in the depth buffer after projection, by remapping the normalized -1..1 range onto whatever window-space interval you pass in. The default call is glDepthRange(0.0, 1.0): keep every fragment’s real depth. Calling glDepthRange(0.0, 0.0) instead collapses that mapping to a single point — every fragment’s stored depth becomes 0.0, the value reserved for the nearest possible surface, no matter how far away the vertex actually was. GL_LEQUAL passes a fragment whose depth is less than or equal to the stored depth, and no stored depth is below 0.0, so a collapsed fragment passes that test. A strict test such as GL_LESS would reject it wherever 0.0 is already stored, for example from an earlier collapsed draw. Switching glDepthFunc to GL_ALWAYS removes the comparison altogether: the test passes unconditionally, whatever is already in the buffer. The wrapper makes both calls, flattening the depth and then skipping the test, so highlighted geometry is drawn through walls however the game left its depth function.

The exact-build Urban Terror 4.3.4 wallhack applies that combination to player-sized draws from a cave inside glDrawElements. The cave saves the flags and every general-purpose register before calling its helper, because the interrupted OpenGL function expects them unchanged; it fills the sixth replaced byte with a nop so no fragment of the old instruction remains; it restores normal depth state on the next non-highlighted draw; and it is switched off before its code patch is removed, so GL_ALWAYS is never left active. It is in Lesson 8.8, after the DLL loading and detour lessons that its installation depends on.

State restoration must match what was changed. A robust typed wrapper queries or tracks the previous depth function, depth range, enabled client arrays, color, and bindings. Restoring guessed “defaults” works only until another legitimate render pass enters with different state. A middle-of-function cave, like that one, has fewer safe options, which is a real limitation of that interception point rather than an implementation detail to ignore.

Common draw-call failures

Result Check
Immediate crash Signature, calling convention, or original pointer
Infinite recursion Original resolved to wrapper
Missing geometry Parameters were not forwarded exactly
Broken later draws Graphics state not restored
Huge slowdown Blocking or allocation inside the hook

The first success is not a visual effect. It is a transparent wrapper that produces a bounded stream of samples while the game draws normally.

Knowledge check

Preserve the call contract

What must an observational wrapper around an OpenGL draw call do after recording bounded metadata?

Finished Lesson 7.3?

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.