Render Bundles
Pre-recording draw commands
The Cost of Recording
Every frame, your application builds a command buffer: set pipeline, bind resources, draw, set pipeline, bind resources, draw. This sequence repeats identically whenever the scene has not changed. Render bundles let you record that sequence once, then replay it with a single call, reducing CPU overhead in scenes with static geometry.
The Recording Cost
Recording draw commands is not free. Each call to setPipeline, setBindGroup, or draw involves JavaScript-to-native transitions, validation, and state tracking. For a scene with thousands of objects, this CPU work can dominate frame time even when the GPU finishes quickly.
Interactive: Bundle vs direct recording
Command Sequence
8 objects × 4 callsDirect recording processes each command every frame.
The problem is not the GPU—it is the CPU repeatedly encoding the same instructions. If the draw sequence is identical across frames, that work is wasted. Render bundles solve this by moving the encoding cost to initialization time.
What is a Render Bundle?
A render bundle is a pre-recorded sequence of draw commands. You create a GPURenderBundleEncoder, record commands to it exactly as you would to a render pass encoder, then call finish() to produce a GPURenderBundle object. That bundle can be executed repeatedly with a single executeBundles call.
Record once, execute many
Bundle
Frames
Record the bundle once at startup
The bundle captures everything needed to replay the draws: pipeline bindings, vertex buffers, index buffers, draw calls, and their arguments. At execution time, the driver simply replays this pre-validated sequence without re-processing each command.
Creating a Bundle
Bundle creation follows the same pattern as recording to a render pass, with one difference: you create a GPURenderBundleEncoder instead of beginning a render pass.
// Create the bundle encoder with compatible format configuration
const bundleEncoder = device.createRenderBundleEncoder({
colorFormats: ["rgba8unorm"], // Must match render pass
depthStencilFormat: "depth24plus", // Must match render pass
sampleCount: 1, // Must match render pass
});
// Record commands exactly as you would in a render pass
bundleEncoder.setPipeline(pipeline);
bundleEncoder.setVertexBuffer(0, vertexBuffer);
bundleEncoder.setIndexBuffer(indexBuffer, "uint16");
bundleEncoder.setBindGroup(0, bindGroup);
bundleEncoder.drawIndexed(indexCount);
// Finish to produce the bundle
const bundle = bundleEncoder.finish();Interactive: Bundle creation
createRenderBundleEncoder({ colorFormats: [...] })setPipeline(pipeline)setVertexBuffer(0, vertexBuffer)setIndexBuffer(indexBuffer, 'uint16')setBindGroup(0, bindGroup)drawIndexed(indexCount)finish(→ GPURenderBundle)The format configuration in createRenderBundleEncoder tells the encoder what kind of render pass this bundle will be used with. A bundle created with colorFormats: ["rgba8unorm"] can only execute in render passes with that same format.
Executing Bundles
To execute a bundle, call executeBundles on a render pass encoder:
const pass = commandEncoder.beginRenderPass({
colorAttachments: [{
view: textureView,
loadOp: "clear",
storeOp: "store",
clearValue: { r: 0, g: 0, b: 0, a: 1 },
}],
depthStencilAttachment: {
view: depthView,
depthLoadOp: "clear",
depthStoreOp: "store",
depthClearValue: 1.0,
},
});
// Execute one or more bundles
pass.executeBundles([staticGeometryBundle, uiBundle]);
// Can still issue direct commands after bundles
pass.setPipeline(dynamicPipeline);
pass.draw(dynamicVertexCount);
pass.end();Multiple bundles can be executed in sequence, and you can mix bundle execution with direct commands in the same render pass.
When to Use Bundles
Bundles provide the greatest benefit when:
- Draw count is high: Scenes with hundreds or thousands of distinct draw calls benefit most
- Scene is static: Bundles must be re-recorded if geometry, pipelines, or bindings change
- CPU is the bottleneck: If the GPU is already saturated, bundles will not help
Performance comparison
Direct Recording
Render Bundle
CPU-bound: bundles eliminate the encoding bottleneck, but GPU time is unchanged.
Typical use cases include terrain meshes that never change, static environment geometry in games, text rendering where the glyphs are pre-generated, and UI elements that do not animate every frame.
What Can Go in a Bundle
Bundles support most render commands:
| Supported | Not Supported |
|---|---|
setPipeline | beginOcclusionQuery / endOcclusionQuery |
setVertexBuffer | setViewport |
setIndexBuffer | setScissorRect |
setBindGroup | setBlendConstant |
draw | setStencilReference |
drawIndexed | executeBundles (no nesting) |
drawIndirect | |
drawIndexedIndirect |
Bundle limitations
✓ Supported in Bundles
✗ Not Supported
Click a command to see details. Unsupported commands involve state that varies between executions.
The excluded commands involve state that typically varies between executions or queries that must complete before the bundle replays. Viewport, scissor, blend constant, and stencil reference must be set directly on the render pass encoder before or after executing the bundle.
Limitations and Caveats
Format Compatibility
The bundle's format configuration must exactly match the render pass. Mismatched formats cause validation errors:
// Bundle created with rgba8unorm
const bundle = device.createRenderBundleEncoder({
colorFormats: ["rgba8unorm"],
depthStencilFormat: "depth24plus",
}).finish();
// ERROR: Render pass uses rgba16float
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: hdrTextureView, // format: "rgba16float"
loadOp: "clear",
storeOp: "store",
}],
});
pass.executeBundles([bundle]); // Validation errorNo Dynamic State in Bundles
State like viewport, scissor rect, stencil reference, and blend constant cannot be recorded into bundles. If your scene requires changing these between draws, those draws cannot be bundled together—or you must set the state on the render pass before/after the bundle.
// Set viewport before bundle
pass.setViewport(0, 0, width, height, 0, 1);
pass.setStencilReference(1);
// Execute bundle (uses the viewport and stencil reference set above)
pass.executeBundles([bundle]);
// Change state for subsequent direct draws
pass.setViewport(100, 100, 200, 200, 0, 1);
pass.draw(otherVertexCount);Re-recording Cost
When the scene changes—objects added, removed, or their bindings updated—you must re-record the bundle. If your scene is highly dynamic, the re-recording cost may exceed the savings from bundle execution. Measure before assuming bundles help.
Bind Group Lifetimes
Resources referenced by bind groups in a bundle must remain valid until the bundle is no longer used. If you destroy a buffer that a bundle references, the bundle becomes invalid. Manage resource lifetimes carefully.
Structuring for Bundles
A common pattern is to separate static and dynamic geometry:
// At initialization
const staticBundle = recordStaticGeometry(device, staticMeshes);
// Each frame
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(passDescriptor);
// Static geometry in a bundle
pass.executeBundles([staticBundle]);
// Dynamic geometry recorded directly
for (const dynamic of dynamicObjects) {
pass.setPipeline(dynamic.pipeline);
pass.setBindGroup(0, dynamic.bindGroup);
pass.draw(dynamic.vertexCount);
}
pass.end();
device.queue.submit([encoder.finish()]);This approach captures the benefit of bundles for static content while retaining flexibility for dynamic content.
Indirect Drawing and Bundles
Bundles support drawIndirect and drawIndexedIndirect, which read draw arguments from a GPU buffer. This combination is powerful: the bundle encodes a fixed sequence of indirect draws, but the actual draw counts come from the buffer, which can be updated by compute shaders.
// Bundle records indirect draw commands
bundleEncoder.setPipeline(pipeline);
bundleEncoder.setVertexBuffer(0, vertexBuffer);
bundleEncoder.setBindGroup(0, bindGroup);
bundleEncoder.drawIndirect(indirectBuffer, 0);
bundleEncoder.drawIndirect(indirectBuffer, 16);
bundleEncoder.drawIndirect(indirectBuffer, 32);
const bundle = bundleEncoder.finish();
// Each frame: compute shader updates indirectBuffer counts
// Bundle execution uses the updated counts without re-recording
pass.executeBundles([bundle]);This enables GPU-driven rendering where the CPU never sees per-object draw counts.
Debugging Bundles
Since bundles are opaque pre-recorded objects, debugging draw calls inside them requires special attention. Most GPU debugging tools can expand bundle contents, but the process differs from inspecting direct commands.
Tips for debugging:
- Temporarily replace bundle execution with direct recording to isolate issues
- Use validation layers to catch format mismatches early
- Label bundles with
bundle.label = "static terrain"for identification in debug tools
Key Takeaways
- Render bundles pre-record draw commands, eliminating per-frame CPU encoding overhead
- Create bundles with
createRenderBundleEncoder, execute withexecuteBundles - Bundle format configuration must match the render pass exactly
- Bundles cannot contain viewport, scissor, stencil reference, or blend constant changes
- Best suited for static geometry with high draw counts where the CPU is bottlenecked
- Combine with indirect drawing for GPU-driven rendering without re-recording
- Re-record bundles when the draw sequence changes; measure to confirm benefit