Skip to content

(pass-graph …) / (pass …)

(pass …) is sugar for a fullscreen shader: write the fragment body and the pngine/pass-v1 lowering hook, the compiler step that expands sugar into ordinary forms, generates every resource around it (uniform buffer, output texture, sampler, pipeline, bind group, render pass and frame), creating only what the WGSL actually references.

Passes live inside a (pass-graph …) container, which the hook lowers as a unit. That matters as soon as one pass samples another: the container sees every pass at once, so cross-pass texture ids sequence coherently.

(pass-graph
(pass :name <name>
:code "<WGSL fragment source>"
:feedback true ; optional: ping-pong texture for feedback
:file ["a.wasm" "b.wasm"] ; optional: WASM data files → D0, D1, …
:init "<WGSL compute source>")) ; optional: one-shot compute init

Takes (pass …) children positionally. The passes run in document order; the last one renders to the canvas, and earlier ones render to textures the later passes can sample by name. A (pass …) written outside a (pass-graph …) is refused with a located diagnostic naming the graph as its parent (it used to validate, generate nothing, and leave the payload empty).

Key Type Required Description
:name symbol Yes Pass name; also the texture name later passes sample
:code string Yes WGSL fragment (or compute) source
:feedback boolean No Ping-pong texture pair; the previous frame is readable as prev_<name>. Not on the last pass: it renders to the canvas, which has no previous texture to read
:init string No One-shot WGSL compute source, run once per loaded payload
:file array No WASM file paths → D0, D1, … storage buffers

The hook scans the WGSL and injects @binding(N) declarations for whatever it finds referenced; you never write the bindings yourself:

Resource Injected when Binding
Uniform buffer (pngine) The code references pngine 0
Pointer buffer (pointer) The code references pointer next
Sampler (samp) The code names samp (textureSample(tex, samp, uv)); a textureLoad read binds none next
Dep textures A prior (pass …) output is referenced by name next
Feedback texture (prev_<name>) :feedback true next
Data buffers (D0, D1, …) :file present next

pngine carries the same fields as the built-in pngine-inputs uniform: time, width, height, aspect. Data buffers are injected as var<storage, read> D0: array<f32>.

(pass-graph
(pass :name main :code """
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let uv = pos.xy / vec2f(pngine.width, pngine.height);
return vec4f(uv, sin(pngine.time) * .5 + .5, 1);
}
"""))

That is the whole document: no pipeline, bind group, pass or frame to declare.

pass0 samples its own previous frame through prev_pass0; main samples pass0’s output by name and renders to the canvas. Both passes belong to the same (pass-graph …), which is what keeps their pooled texture ids in step. The feedback has to sit on the earlier pass: the last pass renders to the canvas, and WebGPU keeps no previous canvas texture, so :feedback true on it is refused with the message below.

(pass-graph
(pass :name pass0 :feedback true :code """
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let uv = pos.xy / vec2f(pngine.width, pngine.height);
let history = textureSample(prev_pass0, samp, uv).rgb;
let t = pngine.time;
let c = vec2f(sin(t) * .3 + .5, cos(t * .7) * .3 + .5);
let glow = smoothstep(.15, .0, length(uv - c));
let col = .5 + .5 * cos(t * 2. + vec3f(0., 2., 4.));
return vec4f(mix(col * glow, history, 0.92), 1);
}
""")
(pass :name main :code """
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
let uv = pos.xy / vec2f(pngine.width, pngine.height);
let scene = textureSample(pass0, samp, uv).rgb;
let vignette = smoothstep(1.2, 0.3, length(uv - 0.5));
return vec4f(scene * vignette, 1);
}
"""))
; invalid: renders to the canvas and cannot feed back
(pass-graph
(pass :name trail :feedback true :code """
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
return textureLoad(prev_trail, vec2i(pos.xy), 0) * 0.98;
}
"""))

:file embeds binary data from WASM modules as storage buffers, which avoids long WGSL const array declarations. Each file becomes D0, D1, … in order:

(pass-graph
(pass :name main :file ["colors.wasm"] :code """
// D0 auto-injected: var<storage, read> D0: array<f32>;
fn gv(i: u32) -> vec3f {
let b = i * 3u;
return vec3f(D0[b], D0[b + 1u], D0[b + 2u]);
}
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
return vec4f(gv(0u), 1);
}
"""))

The module convention is the same one audio payloads use:

Export Type Required Description
m memory Yes Contains the data
l i32 global Yes Byte length of the data
s i32 global No Start offset in memory (default 0)
gen function No Called before the memory is read

array<f32> has a stride of 4 (packed). array<vec3f> has a stride of 16, a third of it padding, so index a flat array<f32> manually when size matters.

(pass …) covers fullscreen fragment work: shader art, post-processing, feedback effects. It has no vertex buffers, no meshes, no instancing, and no control over the pipeline state. As soon as you need geometry, a depth buffer, blending or several bind groups, write the ordinary (render-pipeline …) + (render-pass …) + (frame …) forms, which is what the sugar lowers to anyway.

The two styles do not mix inside one (pass-graph …), but a document may declare other forms alongside it.

Rule Error
A (pass …) needs :code missing_required_key
The WGSL must declare an @fragment or @compute function lowering_hook_failed, pass main: its WGSL declares no entry point
Pass names must be unique within the graph duplicate_cross_ref_target on the generated resources (main__tex, main__pipe, …)
:file paths resolve against the source file’s directory cannot read 'colors.wasm': FileNotFound
WGSL errors are reported against the injected source located WGSL diagnostic

The hook names what it generates after the pass: <name>__shader, <name>__tex, <name>__pipe, <name>__bg, and a render pass called <name> itself. That is why two passes with one name collide on the generated forms rather than on the (pass …) forms.