Skip to content

(render-bundle …)

Pre-records a pipeline plus its vertex buffers, bind groups and one draw. A (render-pass …) then replays it via :execute-bundles instead of declaring an inline pipeline and draw.

A bundle is recorded once and replayed every frame, which cuts the per-frame encoding cost of a draw sequence that never changes; it is the WebGPU equivalent of a display list.

(render-bundle :name bundle :pipeline pipe
:color-formats [preferred-canvas-format]
:depth-stencil-format depth24plus :sample-count 1
:vertex-buffers [verts] :bind-groups [g]
(draw :vertex-count 3 :instance-count 1))
(render-pass :name draw
(color-attachment :view context-current-texture :load-op clear :store-op store)
(depth-stencil-attachment :view depthTex :depth-load-op clear :depth-store-op store)
:execute-bundles [bundle])
Key / Sub-form Type Required Default Description
:name symbol Yes - Unique bundle name
:pipeline reference Yes - Render pipeline recorded into the bundle (a bundle’s descriptor is its own layout)
:color-formats array Yes - Colour attachment formats the bundle is recorded against (at least one, all colour formats)
:depth-stencil-format symbol No - Depth/stencil format the bundle is recorded against
:sample-count number No 1 Sample count the bundle is recorded against
:vertex-buffers array No - Buffers bound to vertex slots 0..N in order
:bind-groups array No - Bind groups set at @group 0..N in order
:index-buffer reference No - Index buffer when the bundle draws indexed
(draw …) sub-form * - The recorded non-indexed draw
(draw-indexed …) sub-form * - The recorded indexed draw

*A bundle records exactly one draw, and it is a (draw …) or (draw-indexed …) child: the same two forms a render pass issues, with the same keys, so a recorded draw can state :instance-count, :first-vertex, :base-vertex and :first-instance like any other. Zero is positional_missing, two is positional_too_many, and (draw-indirect …) is not among the heads a bundle accepts.

:color-formats is required and needs at least one entry, matching GPURenderPassLayout.colorFormats. Write [preferred-canvas-format] for a bundle replayed into a pass that draws to the canvas. The list holds colour formats only: a depth format there is rejected on the spot, rather than taken as a way to record a depth-only bundle.

:color-formats, :depth-stencil-format and :sample-count record the attachment shape the bundle is compatible with. Together they are WebGPU’s GPURenderPassLayout, and three things have to agree on it: the bundle states it, the :pipeline it records derives one from its colour targets, depth-stencil state and multisample count, and the replaying pass derives one from its attachments. Any disagreement between the three is a compile error, and each is reported where it is written:

render-bundle 'bundle' records :color-formats slot 0 as 'rgba8unorm'
but pipeline 'pipe' declares color target 0 as :format 'bgra8unorm'
render-bundle 'bundle' records 4 sample(s) but pipeline 'pipe' has
sample count 1
color attachment 0 of render-pass 'draw' writes texture 'offscreen' with
:format 'bgra8unorm' but replayed render-bundle 'bundle' was recorded
against 'rgba8unorm'

Two of those are easy to miss, because the value is not in the form you are reading. A pass never states its formats or its sample count: WebGPU takes them from the attachments’ textures, so a bundle can lose to a (texture …) three forms away. And an omitted :sample-count is 1, so a bundle that says nothing disagrees with a 4-sample pipeline.

An absent :depth-stencil-format is a value too, meaning “no depth attachment”, not a wildcard: omitting it under a pipeline that declares (depth-stencil …) is an error, and so is stating one under a pipeline that does not.

A pass that replays bundles does not declare :pipeline or a draw sub-form; the bundle carries both. That is why :pipeline is optional on a render pass.

(define :name NUM_ASTEROIDS :value 500)
(render-bundle :name asteroids :pipeline meshPipeline
:color-formats [preferred-canvas-format]
:depth-stencil-format depth24plus
:sample-count 1
:vertex-buffers [vertices]
:bind-groups [sceneGroup]
(draw :vertex-count 36 :instance-count NUM_ASTEROIDS))
(render-pass :name draw
(color-attachment :view context-current-texture
:clear-value [0 0 0 1] :load-op clear :store-op store)
(depth-stencil-attachment :view depthTex
:depth-clear-value 1.0 :depth-load-op clear :depth-store-op store)
:execute-bundles [asteroids])
(frame :name main :perform [draw])
(render-bundle :name teapot :pipeline meshPipeline
:color-formats [preferred-canvas-format]
:depth-stencil-format depth24plus
:vertex-buffers [vertexBuffer]
:index-buffer indexBuffer
:bind-groups [sceneGroup]
(draw-indexed :index-count 2976))

:execute-bundles takes a list, replayed in order:

(render-pass :name draw
(color-attachment :view context-current-texture
:clear-value [0 0 0 1] :load-op clear :store-op store)
:execute-bundles [terrain props foliage])
Rule Error
A name is unique across every form kind duplicate_cross_ref_target
Referenced pipeline, buffers and bind groups must exist not_cross_ref
A pass’s :execute-bundles entries must name declared bundles not_cross_ref
:color-formats is required missing_required_key
:color-formats needs at least one entry vector_too_short
Exactly one (draw …) / (draw-indexed …) child positional_missing / positional_too_many
Any other sub-form head not_head_member

The layout-equality rules are the compiler’s, since they compare values in three different forms. They carry a located message and no code; the shapes are in Attachment compatibility above, plus:

render-bundle 'bundle' lists 'depth24plus' at :color-formats slot 0; that
list holds COLOR attachment formats
pipeline 'pipe' declares a depth-stencil state but render-bundle 'bundle'
records no :depth-stencil-format
const encoder = device.createRenderBundleEncoder({
colorFormats: ['bgra8unorm'],
depthStencilFormat: 'depth24plus',
sampleCount: 1,
});
encoder.setPipeline(pipeline);
encoder.setVertexBuffer(0, verts);
encoder.setBindGroup(0, group);
encoder.draw(3, 1, 0, 0);
const bundle = encoder.finish();
// each frame:
pass.executeBundles([bundle]);