Skip to content

(frame …)

Defines a frame that orchestrates the execution of passes and queue operations.

(frame :name name
:init [initStep]
:before [writeUniforms]
:perform [pass1 pass2 pass3])
Key Type Required Default Description
:name symbol Yes - Unique frame name
:perform array Yes - Passes and queues run every frame, in order
:init array No [] One-shot compute steps: (compute-pass …) and (init …) names
:before array No [] (queue …) ops run before each frame’s passes

All three lists are capped at 2048 entries. The number is the 64 KB command buffer’s: even the cheapest step that emits anything costs about 18 bytes of it, so a longer list would describe a frame the runtime cannot execute.

Type: array of references

The main sequence of steps to execute each frame. Order matters: they run left to right, and repeating a name repeats the work, which is how a multi-stage algorithm names the same pass several times.

:perform [writeUniforms computePass renderPass]

Can reference:

  • (render-pass …) - Render passes
  • (compute-pass …) - Compute passes
  • (queue …) - Queue operations (buffer writes)

The three form kinds share one name namespace, so a step is never ambiguous: a render pass and a queue that both claim update collide at the second declaration with a duplicate_cross_ref_target.

Type: array of references

One-shot compute steps, for work like seeding a particle buffer or filling a heightmap. It takes (compute-pass …) and (init …) names only, so a (queue …) named here is a not_cross_ref; a queue that has to run belongs in :before or :perform.

:init [resetParticles seedHeightmap]

Each entry lowers to the exec_pass_once opcode, which runs once per loaded payload, keyed by the pass. Not once per frame, not once per scene: load the same PNG twice and each load runs it once, while seeking, pausing, restarting the animation or handing the runtime a saved frame counter does not re-arm it. Only a fresh load does.

Three consequences follow:

  • A repeated entry is refused. :init [spawn spawn] asks for two runs and can only get one, so the compiler rejects it rather than emitting an op that can never fire. :perform is the opposite: repetition there is meaning.
  • Two frames may share one :init pass. It runs once in total, in whichever frame is rendered first. For per-scene setup, give each scene its own pass.
  • A pass in both :init and :perform warns. Both lists are honoured as written, so it runs twice on the first frame and once per frame after.

Type: array of references

(queue …) operations run before each frame’s passes, typically the ones that update uniforms. This list takes queue names only.

:before [updateSimParams]

(init …) is a sugar form for one-shot compute initialization of a storage buffer, run once per loaded payload. It is referenced from a frame’s :init array. The pngine/init-v1 lowering hook (the compiler step that turns sugar into ordinary forms) expands it into a (compute-pipeline …), a (bind-group …) (with one (entry …) entry), and a (compute-pass …).

(define :name NUM_PARTICLES :value 2048)
(init :name initParticles :buffer particleBuffers :module initShader
:workgroups [(ceil (/ NUM_PARTICLES 64))])
Key Type Required Description
:name symbol Yes Unique name (referenced from :init)
:buffer reference Yes Storage buffer to initialize, bound at @binding 0
:module reference Yes (shader-module …) holding the @compute init entry
:workgroups array Yes Workgroup counts as [x], [x y] or [x y z]

:workgroups has the same shape as the (dispatch :workgroups …) it lowers to: a vector of one to three elements, each a literal, a bare (define …) constant, or a bounded expression over those constants. It is read while the hook lowers, before the emitter runs, but against the same document-wide constant environment, so both slots behave the same. A name in it that no (define …) declares is a located error on the (init …), not a silent zero.

(frame :name main :perform [renderPass])

Include (queue …) operations in :perform to update uniforms each frame:

(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeTime
(write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(render-pass :name render
(color-attachment :view context-current-texture
:clear-value [0 0 0 1] :load-op clear :store-op store)
:pipeline mainPipeline
:bind-groups [uniformGroup]
(draw :vertex-count 3))
(frame :name main :perform [writeTime render])

Use :init with an (init …) step for one-time compute setup that runs before the first frame:

(init :name resetParticles :buffer particles :module initShader :workgroups [32])
(compute-pass :name updateParticles
:pipeline simPipeline
:bind-groups [simGroup]
(dispatch :workgroups [64]))
(render-pass :name drawParticles
(color-attachment :view context-current-texture
:clear-value [0 0 0 1] :load-op clear :store-op store)
:pipeline renderPipeline
(draw :vertex-count 2048))
(frame :name particles
:init [resetParticles]
:perform [updateParticles drawParticles])
(frame :name deferred
:perform [geometryPass lightingPass postProcess])
(frame :name simulation
:init [initBuffers]
:before [writeUniforms]
:perform [physicsCompute renderParticles])

Each frame execution follows this order:

  1. :init steps (once per loaded payload, via exec_pass_once)
  2. :before queues (every frame, before the main passes)
  3. :perform passes and queues (every frame, via exec_pass)

A simulation step that reads its neighbours cannot safely write over the generation it is still reading, so it keeps two buffers and alternates:

(buffer :name particles :size 32768 :usage [vertex storage] :pool 2)
(compute-pass :name simulate
:pipeline simPipeline
:bind-groups [simGroup]
:bind-groups-pool-offsets [0]
(dispatch :workgroups [64]))
(render-pass :name draw
(color-attachment :view context-current-texture
:clear-value [0 0 0 1] :load-op clear :store-op store)
:pipeline renderPipeline
:vertex-buffers [particles]
:vertex-buffers-pool-offsets [0]
(draw :vertex-count 2048))
(frame :name boids :perform [simulate draw])

The runtime automatically alternates pool offsets each frame.

Rule Error
A name is unique across every form kind duplicate_cross_ref_target
:perform required missing_required_key
Referenced passes and queues must exist not_cross_ref
:init names a (compute-pass …) or (init …), never a queue not_cross_ref
:before names a (queue …) not_cross_ref
A list longer than 2048 entries vector_too_long

Two more are the compiler’s, and carry a located message with no code:

Rule Message shape
:init names the same pass twice frame 'main': ':init' names 'setup' more than once, but a one-shot pass runs once per loaded payload
A pass in both :init and :perform (a warning, not an error) frame 'main': 'setup' runs as a one-shot :init AND every frame via :perform, so it runs twice on the first frame

Each frame creates a command encoder and submits to queue:

const encoder = device.createCommandEncoder();
// Execute init steps (once per loaded payload, keyed by pass id)
if (!hasRunOnce) {
for (const step of frame.init) {
executePass(step, encoder);
}
}
// Execute perform passes (every frame)
for (const pass of frame.perform) {
executePass(pass, encoder);
}
device.queue.submit([encoder.finish()]);

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.

(frame …) is PNGine’s own. WebGPU has no frame: a page calls requestAnimationFrame and encodes what it likes. The frame form is the schedule the runtime replays, and none of its keys is a WebGPU word.

Key WebGPU Note
:name PNGine’s own the name the runtime plays
:init PNGine’s own compute passes run once, before the first frame
:before PNGine’s own queues run at the top of every frame
:perform PNGine’s own the passes, in order, every frame

(init …) is PNGine’s own. Sugar, lowered by the pngine/init-v1 hook into a (compute-pipeline …), a (bind-group …) and a one-shot (compute-pass …); the words are the ones those forms carry.

Key WebGPU Note
:name PNGine’s own the name (frame :init [...]) refers to
:buffer PNGine’s own the storage buffer the module fills, bound at @binding(0)
:module PNGine’s own the (shader-module …) with the compute entry point
:workgroups GPUComputePassEncoder.dispatchWorkgroups() the three counts of the lowered dispatch

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