(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.
Syntax
Section titled “Syntax”(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(pass-graph …)
Section titled “(pass-graph …)”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).
(pass …)
Section titled “(pass …)”| 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 |
What the prelude injects
Section titled “What the prelude injects”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>.
Examples
Section titled “Examples”Single fullscreen shader
Section titled “Single fullscreen shader”(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.
Multi-pass with feedback
Section titled “Multi-pass with feedback”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; } """))WASM data buffers
Section titled “WASM data buffers”: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.
When to reach for the plain forms instead
Section titled “When to reach for the plain forms instead”(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.
Validation Rules
Section titled “Validation Rules”| 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.
Related
Section titled “Related”(render-pass …)- What the sugar lowers to(texture …)- The pooled textures feedback uses(queue …)- Thepngine-inputsuniform behindpngine(buffer …)-:file, the same data-module convention