Skip to content

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.

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 semicolon

The 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.

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

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.

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

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/queue
pngine/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.

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.

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.

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.

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 frame

Each :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.

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.