Lesson 7
The same frame on a real GPU
The pipeline lesson's frame drawn through WebGPU (or WebGL2): adapters and devices, pipelines and why their state is baked up front, bind groups, command encoders and queues, and what happens to a draw call on its way to the GPU, with the D3D12, Vulkan and Metal names for each.
The first lessons ran on a software rasteriser, so you could stop it after any pixel. From here on the widgets run on your real GPU, through WebGPU, or WebGL2 where the browser has no WebGPU. This lesson draws the pipeline lesson’s frame again, with the same floor, box, camera and shading, and looks at what it takes to get it onto the GPU: the objects you create once, and the commands you record every frame.
WebGPU is a good way in because it has the same shape as D3D12, Vulkan and Metal, with fewer sharp edges: no manual memory management, no barriers. Each concept below gives the native names too.
Side by side
On the left is the software rasteriser’s 64 × 40 frame; on the right the GPU’s. The label above the widget says which API is in use, and if WebGPU is available you can switch between it and WebGL2.
Loading…
Both sides use the same camera. The GPU’s vertex shader multiplies each position by one 4 × 4 view-projection matrix, built so that it gives exactly the clip-space position the rasteriser’s camera computes (x and y from −1 to 1 after the divide, depth from 0 to 1). The fragment shaders compute the same brightness: the floor’s checkerboard from its texture coordinates, the box’s faces from their normal and the one light. Near plane and culling do the same on both sides: at 4.6 m the near plane cuts into the box, and with culling on you see through it.
The GPU’s frame has 2 passes: the scene is drawn into a 64 × 40 colour and depth target, and a second pass copies it to the canvas with one triangle that covers the screen, sampling without filtering so the pixels stay square. That’s 3 draw calls and 15 triangles submitted: the floor’s 2, the box’s 12 and the full-screen triangle. Choose Full and the scene pass renders at the canvas’s own size instead, every device pixel of it, in the same two passes.
Mark pixels that differ reads the GPU’s frame back to the CPU and compares it with the software one. Expect them to agree almost everywhere. Where they don’t, it’s on edges, where the checker squares meet, and where the near plane cuts: GPUs snap vertices to their own sub-pixel grid (D3D requires 8 bits; core Vulkan only guarantees 4, the Roadmap 2026 profile 8, and each device reports its subPixelPrecisionBits), and they interpolate in 32-bit floats where the rasteriser uses 64-bit. How many differ depends on your GPU and driver.
Time 60 frames measures each pass on the GPU with timestamp queries, if your browser offers them: WebGPU’s optional timestamp-query feature, or WebGL2’s EXT_disjoint_timer_query_webgl2, which Firefox and Safari don’t expose. A CPU timer can’t do this: the calls return long before the GPU runs them. Chrome rounds WebGPU timestamps to 100 µs, so passes this small often read 0. A later lesson, Timing the GPU, comes back to this.
Lose the device destroys the GPU device (or, on WebGL2, loses the context) to show the recovery path. Devices are lost for real when a driver updates or crashes, when the OS resets a hung GPU, or when the browser reclaims resources. WebGPU reports it through the device.lost promise; the widget then asks for a new adapter and device and rebuilds every buffer, pipeline and bind group, because nothing made with the old device survives.
Adapters, devices and queues
An adapter is a GPU, or a software fallback (which a headless browser or a blocklisted driver may give you). You ask it for a device, your own connection to it, and say which optional features and higher limits you need; anything you don’t ask for isn’t available, even if the hardware has it. This lesson asks for timestamp-query when the adapter lists it, and nothing else. A device comes with a queue, where finished command buffers and uploads are sent.
| WebGPU | D3D12 | Vulkan | Metal |
|---|---|---|---|
GPUAdapter |
IDXGIAdapter |
VkPhysicalDevice |
MTLDevice (no separate adapter) |
GPUDevice |
ID3D12Device |
VkDevice |
MTLDevice |
GPUQueue |
ID3D12CommandQueue |
VkQueue |
MTLCommandQueue |
GPUBuffer, GPUTexture |
ID3D12Resource |
VkBuffer, VkImage (plus memory you allocate) |
MTLBuffer, MTLTexture |
GPUShaderModule |
DXIL bytecode | VkShaderModule (SPIR-V) |
MTLLibrary, MTLFunction |
GPUBindGroupLayout |
a descriptor table in the root signature | VkDescriptorSetLayout |
(argument buffer layout) |
GPUPipelineLayout |
ID3D12RootSignature |
VkPipelineLayout |
(none) |
GPURenderPipeline |
ID3D12PipelineState |
VkPipeline |
MTLRenderPipelineState |
GPUBindGroup |
descriptors in a descriptor heap | VkDescriptorSet |
argument buffer, or one bind call each |
GPUCommandEncoder |
ID3D12GraphicsCommandList |
VkCommandBuffer |
MTLCommandBuffer |
GPURenderPassEncoder |
the commands between BeginRenderPass and EndRenderPass |
vkCmdBeginRendering … vkCmdEndRendering |
MTLRenderCommandEncoder |
The renderer creates its buffers once: the floor’s vertices take 128 bytes and its indices 12; the box’s take 768 and 72. Each vertex is 32 bytes: a position, a normal and a texture coordinate. queue.writeBuffer copies the data in; on D3D12 or Vulkan you’d write it into an upload or staging buffer and record a copy yourself.
Pipelines, and why their state is baked up front
A render pipeline is the shaders plus almost all the fixed-function state around them, fixed when it’s created: the vertex layout, the topology, the cull mode and which winding faces front, the depth test, the blend state and the formats of the targets it draws into. You can’t change any of it later. To draw with back faces kept instead of culled, you bind a different pipeline.
Why not set state piece by piece, as D3D11 and OpenGL do? Because the hardware doesn’t keep it piece by piece. Settings depend on each other, and some of them end up compiled into the shader code itself: on some GPUs the vertex fetch, the conversion to the target’s format or the blend are instructions in the shader. Microsoft’s D3D12 documentation puts it this way: PSOs “were designed to allow the GPU to pre-process all of the dependent settings in each pipeline state, typically during initialization”. On D3D11 and OpenGL the driver only learns the full combination at the draw call, and may have to compile a new variant of the shader right there: that’s a classic source of hitches the first time something is drawn. A pipeline moves the cost to load time. This renderer uses createRenderPipelineAsync, which compiles without blocking the page.
The price is combinations. This lesson has two fragment shaders (checker and lit) and two cull modes, so it creates 5 pipelines: those four and one for the present pass. A game with thousands of materials, vertex formats and pass setups has tens of thousands, which is why engines precompile and cache them and why “shader compilation stutter” is still a phrase.
Not everything is baked:
- WebGPU sets the viewport, scissor rectangle, blend constant and stencil reference on the pass.
- D3D12 sets those, list or strip for the topology (the PSO only knows points, lines, triangles or patches), and all resource bindings and render targets on the command list.
- Vulkan lets you mark parts of a pipeline as dynamic state: viewport, scissor, blend constants, depth bias and stencil reference since 1.0, and since 1.3 (from
VK_EXT_extended_dynamic_state) cull mode, front face, depth test and others. - Metal keeps depth and stencil state in a separate
MTLDepthStencilState, and sets cull mode and winding on the render encoder.
WebGL2 has no pipeline object at all. It’s OpenGL ES 3.0: a program (the linked shaders) plus global state you switch on and off. Switch the widget to WebGL2 and culling becomes gl.enable(CULL_FACE) or gl.disable(CULL_FACE) before the draw; the browser (through ANGLE, which turns WebGL into Direct3D 11 on Windows) and the driver work out the combination at draw time. WebGL2’s clip-space depth is also OpenGL’s −1 to 1 rather than 0 to 1, so its vertex shader converts.
Bind groups
Shaders read resources: buffers of constants, textures, samplers. They’re bound in bind groups, and a pipeline’s layout says which groups it expects and what’s in each. Group them by how often they change:
- Group 0, per frame: the view-projection matrix, the light and the background colour, 112 bytes, written once per frame and bound once, at the start of the scene pass.
- Group 1, per draw: the draw’s colour. Both draws’ blocks live in one buffer, 256 bytes apart, because WebGPU’s default
minUniformBufferOffsetAlignmentis 256: a bind group can only point at a range starting on that boundary.
Creating a bind group does the slow part ahead of time: checking the resources against the layout and building the descriptors the GPU reads. Binding it in the frame is cheap, close to handing over a pointer, which is the point. In D3D12 a bind group is a range of descriptors in a descriptor heap, bound as a root descriptor table; in Vulkan it’s a descriptor set.
Command encoders and queues
Nothing is drawn when you call setPipeline or drawIndexed. The calls are recorded into a command buffer: a command encoder records it, a render pass groups the commands that draw into the same targets, finish() closes the buffer, and queue.submit() hands it to the GPU. That’s the same structure in D3D12 (command list, Close, ExecuteCommandLists), Vulkan (vkBeginCommandBuffer, vkEndCommandBuffer, vkQueueSubmit) and Metal (command buffer, render command encoder, commit). Recording is plain CPU work, so engines record on several threads at once and submit in order.
The inspector lists every call the renderer made, recorded as it ran: what it created while loading, and what it records for one frame. Open a call to see its arguments and, for WebGPU, the D3D12, Vulkan and Metal calls that do the same job.
Loading…
A WebGPU frame here is 23 calls, not counting the createView on the canvas texture: the inspector records calls on the device, queue, encoders and passes, and a view is made on the texture itself. One writeBuffer for the frame’s constants, then the encoder; the scene pass binds group 0 once, then for each draw sets the pipeline, group 1, the vertex and index buffers, and draws; the present pass gets the canvas’s current texture, sets one pipeline and one bind group, and draws three vertices with no vertex buffer at all. Then finish and submit. There’s no present call: the browser shows the canvas texture once the page’s task is done.
The WebGL2 frame is 25 calls, about the same number, but they do different work. There’s no encoder, no pass and no submit: each call changes the context’s global state, and the browser and driver turn that into commands. The scene pass has to switch depth testing on, and the present pass has to switch it and culling off again, because state carries over from whatever ran last.
What happens to a draw call
Follow pass.drawIndexed(36, 1, 0, 0, 0) down.
- In the browser. WebGPU validates everything a native API leaves to you: the 36 indices must lie inside the bound index buffer, the bind groups must match the pipeline’s layout, and no resource may be both read and written in one pass. In Chrome the calls are serialised by Dawn’s wire client and sent to the GPU process, where Dawn validates them, tracks how each resource is used and inserts the barriers between passes that D3D12 and Vulkan make you write, then records D3D12, Metal or Vulkan commands.
- In the driver’s user-mode part. The native API’s draw becomes the GPU’s own command packets in a command buffer: register writes for any state that changed, pointers to the descriptors, and the draw itself. This is also where D3D11 and OpenGL drivers do their validation and draw-time shader patching; D3D12 and Vulkan drivers did that at pipeline creation. Fabian Giesen’s A trip through the graphics pipeline, part 1 walks through this layer.
- At submit. The kernel-mode driver and the OS scheduler queue the command buffer for the GPU, between other processes’ work, and make sure the memory it uses is resident.
- On the GPU. The command processor reads the packets, sets up state and starts the vertex work. From there it’s the pipeline you stepped through in the first lessons, thousands of vertices and pixels at a time.
That’s why a draw call costs CPU time even when the GPU work is tiny, and why the next lesson is about drawing more with fewer of them.
Takeaways
- Adapter → device → queue: ask for the features you need; handle device loss by rebuilding everything.
- A pipeline bakes shaders and fixed-function state at creation, so the driver can compile and pre-process the full combination once. The cost is one pipeline per combination.
- Bind groups (descriptor sets, descriptor tables) bind resources in blocks, grouped by how often they change.
- Calls are recorded into command buffers and submitted; nothing runs on the GPU until then.
- A draw call is validated and translated in the browser, turned into command packets by the driver, scheduled by the OS and read by the GPU’s command processor. WebGL2 does the same with global state and no explicit recording.
Further reading
- WebGPU, the W3C specification, and MDN’s pages on
GPUDevice.lostandbeginRenderPass(timestamp writes). - Microsoft, Managing graphics pipeline state in Direct3D 12: what’s in a PSO and what’s set on the command list.
- Khronos,
VkDynamicStateand the required limits (subPixelPrecisionBits). - Fabian Giesen, A trip through the graphics pipeline 2011, parts 1 and 2: the driver stack and the command processor.
- Dawn’s overview, and Chrome’s WebGPU developer features (timestamp quantisation).
- web3dsurvey: which browsers expose
EXT_disjoint_timer_query_webgl2.