SJON Syntax
Everything you ask WebGPU to do, you ask through a descriptor: a dictionary
naming a pipeline’s stages, a buffer’s size and usages, a pass’s attachments.
A PNGine document is those descriptors written down, so I gave it a syntax
where each one is a single form: SJON, an S-expression language validated
against the WebGPU schema in
schema/pngine.sjon,
the single source of truth for what a .sjon document may contain. Each key
inside a form is one descriptor member, spelled the way the specification
spells it, so what you know about WebGPU transfers directly.
The language and the engine split the work: SJON owns the syntax, validation,
cross-reference resolution, expressions, and sugar lowering (the expansion of
shorthand forms into ordinary ones); PNGine owns the schema, the pngine/*
lowering hooks, and bytecode emission.
This page is a tour, no more: every form gets a table row here and a full page in the SJON Reference. For the language underneath (its reader syntax, value kinds, and how a host defines a schema), see the SJON documentation. The example throughout is the triangle from Getting Started, and where the triangle is too small to need a feature, I’ll say what kind of document does. Read the prose top to bottom; the tables are for coming back to.
The Shape of a Form
Section titled “The Shape of a Form”A .sjon file is a sequence of forms. Take the triangle’s pass apart:
head symbol keyword key its value v v v (render-pass :name pass (color-attachment <- positional sub-form :view context-current-texture :clear-value [0 0 0 1] <- array value :load-op clear :store-op store) :pipeline pipeline <- bare identifier: a cross-reference (draw :vertex-count 3)) <- another sub-form
; comments start with a semicolonThe head symbol says what the form declares, keyword keys (:key value) fill
in its descriptor, and positional sub-forms nest where WebGPU’s own
descriptors nest: a colour attachment inside a pass, a vertex stage inside a
pipeline.
Value Types
Section titled “Value Types”Seven kinds of value can sit after a key:
| Type | Example | Description |
|---|---|---|
| String | "WGSL code" or """multi-line""" |
Quoted / triple-quoted text |
| Number | 123, 0.5 |
Decimal or float |
| Boolean | true, false |
Boolean values |
| Symbol | myName, triangle-list |
Bare identifier (enum member or cross-ref) |
| Array | [a b c] |
Space-separated list |
| Sub-form | (vertex :module code :entry vs) |
Positional nested form |
| Expression | (* NUM 16), (ceil (/ NUM 64)) |
Compile-time arithmetic |
The Whole Triangle
Section titled “The Whole Triangle”Here is the complete document those pieces came from:
(shader-module :name code :code """ @vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { var pos = array<vec2f, 3>( vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5) ); return vec4f(pos[i], 0.0, 1.0); }
@fragment fn fs() -> @location(0) vec4f { return vec4f(1.0, 0.0, 0.0, 1.0); }""")
(render-pipeline :name pipeline :layout auto (vertex :module code :entry vs) (fragment :module code :entry fs (target :format preferred-canvas-format)))
(render-pass :name pass (color-attachment :view context-current-texture :clear-value [0 0 0 1] :load-op clear :store-op store) :pipeline pipeline (draw :vertex-count 3))
(frame :name main :perform [pass])Read it bottom-up: the frame performs pass, the pass draws with pipeline,
the pipeline reads code. That chain of bare names is the next thing to
understand, right after the tables.
Quick Reference
Section titled “Quick Reference”Every form that exists, grouped the way you’ll reach for them. Skip these now and come back when you need a name; each link is the form’s full page.
Shaders
| Form | Purpose | Details |
|---|---|---|
(shader-module …) |
Named WGSL module | Full docs |
Resources
| Form | Purpose | Details |
|---|---|---|
(buffer …) |
GPU buffer (vertex, uniform, storage, index) | Full docs |
(texture …) |
GPU texture | Full docs |
(texture-view …) |
Explicit view over a texture | Full docs |
(sampler …) |
Texture sampler | Full docs |
(image-bitmap …) |
Decoded image → texture | Full docs |
(data …) |
Embedded data and shape generators | Full docs |
(query-set …) |
Occlusion / timestamp queries | Full docs |
Pipelines
| Form | Purpose | Details |
|---|---|---|
(render-pipeline …) |
Render pipeline | Full docs |
(compute-pipeline …) |
Compute pipeline | Full docs |
(bind-group …) |
Resource bindings | Full docs |
(bind-group-layout …) |
Explicit bind group layout | Full docs |
(pipeline-layout …) |
Explicit pipeline layout | Full docs |
Execution
| Form | Purpose | Details |
|---|---|---|
(render-pass …) |
Render pass with draw commands | Full docs |
(compute-pass …) |
Compute pass with dispatch | Full docs |
(render-bundle …) |
Pre-recorded, replayable draws | Full docs |
(queue …) |
Buffer writes, copies, query resolves | Full docs |
(frame …) |
Frame execution order | Full docs |
Advanced
| Form | Purpose | Details |
|---|---|---|
(init …) |
One-shot compute init (sugar) | Full docs |
(pass-graph …) |
Fullscreen shader-art sugar | Full docs |
(wasm-call …) |
WASM function called every frame | Full docs |
(define …) |
Compile-time constants | Full docs |
(limits …) |
Raise WebGPU device limits | Full docs |
(canvas …) |
Canvas alpha mode | Full docs |
References
Section titled “References”Back to that chain of bare names. main performs pass, pass uses
pipeline, pipeline reads code: resources reference each other by bare
identifier, with no # prefix and no quotes. SJON’s validator resolves each
name document-wide against the declared forms, so declaration order is free
(move the triangle’s (frame …) to the top of the file and it still
validates).
Every :name lives in one namespace across every form kind: a buffer and a
texture cannot both be called shared, and the compiler reports the second
declaration. The built-in spellings (canvas, context-current-texture,
preferred-canvas-format, pngine-inputs) are reserved and cannot be
declared.
An exercise: break the chain. Change the triangle’s :perform [pass] to
:perform [pas] and run pngine validate. You get the location, the slot’s
expected kind, and every form kind that could have declared the missing name:
28:28: form `frame` keyword `:perform` expects `frame-step-list`,element [0]: got `pas` (no `(pngine/compute-pass pngine/queuepngine/render-pass :name …)` form declares this name)That pngine/… spelling is the schema’s own vocabulary showing through:
diagnostics quote the schema they checked you against.
See References for the resolution rules.
Expressions
Section titled “Expressions”The triangle’s only interesting number is :vertex-count 3. But the moment a
document simulates something, its numbers become derived: one particle count
decides a buffer’s size and a dispatch’s workgroup count, and a document
where you change one by hand and forget the other breaks quietly. So every
numeric slot accepts a literal, a bounded compile-time expression over
(define …) constants, or a bare constant name:
(define :name NUM_PARTICLES :value 2048)
(buffer :name particles :size (* NUM_PARTICLES 16) :usage [storage])
(compute-pass :name step :pipeline simPipeline (dispatch :workgroups [(ceil (/ NUM_PARTICLES 64))]))The usual functions are +, -, *, /, ceil, floor and fract; the
full set (mod, pow, min, max, clamp, sqrt, abs, round, the
trigonometric functions, comparisons and if) is on the expressions page.
A bare name works wherever a number does, so :size NUM_PARTICLES and
:instance-count NUM_PARTICLES are both values, not cross-references to
another form. Misspell one and you meet the same diagnostic as in the
exercise above, aimed at that slot.
See Expressions for the evaluator’s rules and refusals.
Shape Generators
Section titled “Shape Generators”The triangle keeps its three vertices inside the shader. Real geometry wants
vertex buffers, and typing out a cube’s vertices by hand teaches nobody
anything, so (data …) takes a positional shape sub-form and generates the
bytes at compile time:
(data :name cubeVertices (cube :format [position4 color4 uv2]))
(data :name planeVertices (plane :format [position3 uv2]))Format specifiers:
position3,position4- Vertex position (vec3f or vec4f)normal3- Surface normal (vec3f)color3,color4- Vertex colour (vec3f or vec4f)uv2- Texture coordinates (vec2f)
See (data …) for the exact byte layouts.
Built-in Uniforms
Section titled “Built-in Uniforms”As written, the triangle is frozen: nothing in it changes between frames.
Runtime data (time, canvas size) enters a document through one door, a
write-buffer whose :data names a built-in source:
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeTime (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))This is exactly how Getting Started makes the triangle spin. pngine-inputs
provides 16 bytes:
| Field | Type | Description |
|---|---|---|
time |
f32 | Elapsed seconds |
width |
f32 | Canvas width |
height |
f32 | Canvas height |
aspect |
f32 | width / height |
Two more built-in sources exist: scene-time-inputs (12 bytes: time, width,
height) and pointer-inputs (48 bytes of pointer state).
See (queue …) for details.
Initialization Passes
Section titled “Initialization Passes”A simulation needs a first generation: something must fill the particle
buffer before the first step reads it. :init runs a pass once per loaded
payload, not once per frame:
(frame :name main :init [setupCompute] ; runs once per loaded payload :perform [updateCompute renderPass]) ; runs every frameEach :init entry names a compute pass: usually an (init …) form, the sugar
that expands into a compute pipeline, a bind group and a compute pass, but any
(compute-pass …) works there. Loading the same PNG again re-arms it;
seeking, pausing or restarting the animation does not.
See (frame …) for details.
Ping-Pong Buffers
Section titled “Ping-Pong Buffers”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)With :pool 2, the runtime creates two buffer instances and swaps their
roles each frame. The
Game of Life sample draws the
swap, frame by frame; (buffer …) has the
rules.
Related
Section titled “Related”- SJON Reference - Complete form documentation
- Where the Words Come From - Every key and value traced to the WebGPU specification
- Getting Started - First program tutorial
- CLI Reference - Command-line tools