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]);

Every key and value on this page traced to the WebGPU name it stands for, with a link to the definition. How the tracing is made, and what keeps it from rotting, is the subject of Where the Words Come From.

(render-bundle …) mirrors GPURenderPassLayout and GPURenderBundleEncoderDescriptor (MDN).

Key WebGPU Note
:name GPUObjectDescriptorBase.label the label of GPUObjectDescriptorBase, and the name every cross-reference resolves
:pipeline GPURenderCommandsMixin.setPipeline()
:color-formats GPURenderPassLayout.colorFormats
:depth-stencil-format GPURenderPassLayout.depthStencilFormat
:sample-count GPURenderPassLayout.sampleCount
:vertex-buffers GPURenderCommandsMixin.setVertexBuffer()
:bind-groups GPUBindingCommandsMixin.setBindGroup()
:index-buffer GPURenderCommandsMixin.setIndexBuffer()

Not expressible in PNGine yet: GPURenderBundleEncoderDescriptor.depthReadOnly, GPURenderBundleEncoderDescriptor.stencilReadOnly.

The recorded commands of a bundle (:pipeline, :vertex-buffers, :bind-groups, :index-buffer and the (draw …) children) are the same GPURenderCommandsMixin methods a render pass records, on a GPURenderBundleEncoder instead.

texture-format (:color-formats on (render-bundle …), :depth-stencil-format on (render-bundle …)) spells the GPUTextureFormat enum.

50 values
Value WebGPU Note
rgba8unorm "rgba8unorm"
rgba8snorm "rgba8snorm"
bgra8unorm "bgra8unorm"
rgba16float "rgba16float"
rgba32float "rgba32float"
depth24plus "depth24plus"
depth24plus-stencil8 "depth24plus-stencil8"
depth32float "depth32float"
stencil8 "stencil8"
depth16unorm "depth16unorm"
r8unorm "r8unorm"
rg8unorm "rg8unorm"
r16float "r16float"
rg16float "rg16float"
r32float "r32float"
r32uint "r32uint"
rgba8unorm-srgb "rgba8unorm-srgb"
rgba8uint "rgba8uint"
rgba8sint "rgba8sint"
r8snorm "r8snorm"
r8uint "r8uint"
r8sint "r8sint"
rg8snorm "rg8snorm"
rg8uint "rg8uint"
rg8sint "rg8sint"
r16uint "r16uint"
r16sint "r16sint"
rg16uint "rg16uint"
rg16sint "rg16sint"
r32sint "r32sint"
rg32uint "rg32uint"
rg32sint "rg32sint"
rg32float "rg32float"
rgba16uint "rgba16uint"
rgba16sint "rgba16sint"
rgba32uint "rgba32uint"
rgba32sint "rgba32sint"
rgb10a2unorm "rgb10a2unorm"
rgb10a2uint "rgb10a2uint"
rg11b10ufloat "rg11b10ufloat"
rgb9e5ufloat "rgb9e5ufloat"
bgra8unorm-srgb "bgra8unorm-srgb"
r16unorm "r16unorm"
r16snorm "r16snorm"
rg16unorm "rg16unorm"
rg16snorm "rg16snorm"
rgba16unorm "rgba16unorm"
rgba16snorm "rgba16snorm"
depth32float-stencil8 "depth32float-stencil8"
preferred-canvas-format PNGine’s own the runtime’s negotiated canvas format; see References
52 of the spec's 101 values PNGine does not offer

bc1-rgba-unorm, bc1-rgba-unorm-srgb, bc2-rgba-unorm, bc2-rgba-unorm-srgb, bc3-rgba-unorm, bc3-rgba-unorm-srgb, bc4-r-unorm, bc4-r-snorm, bc5-rg-unorm, bc5-rg-snorm, bc6h-rgb-ufloat, bc6h-rgb-float, bc7-rgba-unorm, bc7-rgba-unorm-srgb, etc2-rgb8unorm, etc2-rgb8unorm-srgb, etc2-rgb8a1unorm, etc2-rgb8a1unorm-srgb, etc2-rgba8unorm, etc2-rgba8unorm-srgb, eac-r11unorm, eac-r11snorm, eac-rg11unorm, eac-rg11snorm, astc-4x4-unorm, astc-4x4-unorm-srgb, astc-5x4-unorm, astc-5x4-unorm-srgb, astc-5x5-unorm, astc-5x5-unorm-srgb, astc-6x5-unorm, astc-6x5-unorm-srgb, astc-6x6-unorm, astc-6x6-unorm-srgb, astc-8x5-unorm, astc-8x5-unorm-srgb, astc-8x6-unorm, astc-8x6-unorm-srgb, astc-8x8-unorm, astc-8x8-unorm-srgb, astc-10x5-unorm, astc-10x5-unorm-srgb, astc-10x6-unorm, astc-10x6-unorm-srgb, astc-10x8-unorm, astc-10x8-unorm-srgb, astc-10x10-unorm, astc-10x10-unorm-srgb, astc-12x10-unorm, astc-12x10-unorm-srgb, astc-12x12-unorm, astc-12x12-unorm-srgb

Checked against the WebGPU specification at revision b8c0fa9; the links go to the current draft.