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 initialisation 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 initialise, 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)

For double-buffered compute simulations:

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