Skip to content

Expressions

One particle count decides three numbers: the buffer’s byte size, the dispatch’s workgroup count, and the draw’s instance count. Change the count in one place by hand and forget the others, and the document breaks quietly. So I let numeric slots take arithmetic instead of copies of the same number: S-expressions, written directly in the slot, in the same prefix syntax as the rest of SJON (the S-expression language PNGine documents are written in), evaluated at compile time. This document is the example for the whole page:

(define :name NUM_PARTICLES :value 4096)
(buffer :name particles :size (* NUM_PARTICLES 16) :usage [storage])
(compute-pass :name step :pipeline simPipeline
(dispatch :workgroups [(ceil (/ NUM_PARTICLES 64))]))

One (define …) now drives both the buffer and the dispatch; change 4096 and everything derived from it follows.

An expression is a form whose head is an arithmetic function, with numeric arguments: literals, (define …) names, or nested expressions. It must come out a number by the end of compilation, and what the slot then does with that number is the slot’s own rule (a byte size wants a whole non-negative one; more on refusals below).

Integer slots also accept hex literals, read as the number they spell: :write-mask 0xF, :stencil-read-mask 0xFF, :mask 0xFFFFFFFF.

Every numeric slot accepts a bare (define …) name as well as a literal or an expression: byte sizes and offsets, binding and group indices, pool sizes, device limits, a sampler’s :max-anisotropy, vector elements, and the draw and dispatch counts.

(define :name SIZE :value 256)
(define :name NUM_PARTICLES :value 2048)
(buffer :name uniformBuf :size SIZE :usage [uniform])
(draw :vertex-count 6 :instance-count NUM_PARTICLES)

The name is resolved against the document’s (define …) forms, so a misspelt constant is a not_cross_ref diagnostic on that slot, and a literal outside the slot’s range still reports the range it missed.

Two slots are the exception, because their symbol spelling is already taken by a member set: :write-mask (member all, a bare name is not_member) and a (wasm-call :args […]) element (the runtime built-ins, a bare name is union_no_branch_matched). In both, a constant is written as arithmetic, (* MASK 1).

All operations are written in prefix form. The heads are SJON’s core expression table, which PNGine evaluates as is; the ones worth knowing:

Function Description Example Result
+ Addition (+ 1 2) 3
- Subtraction (- 5 3) 2
* Multiplication (* 4 5) 20
/ Division (/ 10 4) 2.5
ceil Round up (ceil 2.3) 3
floor Round down (floor 2.7) 2
round Round to nearest (round 2.5) 3
fract Fractional part (fract 2.7) 0.7
mod Remainder (mod 7 3) 1
pow Power (pow 2 10) 1024
min, max Smallest / largest of the arguments (max 1 2 3) 3
clamp (clamp x lo hi) (clamp 100 0 16) 16
abs, sign, sqrt Magnitude, sign, square root (sqrt 16) 4
sin, cos, tan, atan2, radians, degrees, (pi), (tau) Trigonometry (* 2 (pi)) 6.283…
<, >, <=, >=, =, !=, and, or, not, if Comparison and choice (if (> N 1000) 64 16) 64 or 16

+, -, * and / accept any number of arguments ((* NUM 4 4) multiplies all three; (* 4) is 4 and (- 5) is -5), except that / needs at least two. The rounding and trigonometric functions take one; mod, pow and atan2 take two; clamp takes three. A head outside the table is an unknown_form diagnostic and a wrong argument count an arity_mismatch.

Expressions nest by composition; there is no operator precedence and there are no grouping parentheses, because the structure is already explicit. Watch the evaluator take the running example’s dispatch apart:

(ceil (/ NUM_PARTICLES 64)) the constant is substituted...
(ceil (/ 4096 64)) ...the innermost form reduces...
(ceil 64) ...and the slot receives a number
64

The same shape covers whatever the infix world needs parentheses for: (* (+ 1 2) 3) is (1 + 2) × 3, and (/ 10 (+ 2 3)) is 10 ÷ (2 + 3).

Three shapes cover most documents; lift them as they are.

(define :name VERTEX_COUNT :value 1000)
(define :name FLOATS_PER_VERTEX :value 8)
(define :name BYTES_PER_FLOAT :value 4)
(buffer :name vertices
:size (* VERTEX_COUNT FLOATS_PER_VERTEX BYTES_PER_FLOAT)
:usage [vertex])
(define :name NUM_ITEMS :value 4096)
(define :name WORKGROUP_SIZE :value 256)
(compute-pass :name process
:pipeline processPipeline
(dispatch :workgroups [(ceil (/ NUM_ITEMS WORKGROUP_SIZE))]))
(shader-module :name code :code """
@vertex fn vs(@location(0) pos: vec3f, @location(1) nrm: vec3f,
@location(2) uv: vec2f) -> @builtin(position) vec4f {
return vec4f(pos + nrm * 0.0 + vec3f(uv, 0.0) * 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return vec4f(1.0, 1.0, 1.0, 1.0);
}
""")
(render-pipeline :name mesh
:layout auto
(vertex :module code :entry vs
(vertex-buffer :array-stride (* 4 8) ; 8 floats = 32 bytes
(attribute :shader-location 0 :offset 0 :format float32x3)
(attribute :shader-location 1 :offset (* 4 3) :format float32x3)
(attribute :shader-location 2 :offset (* 4 6) :format float32x2)))
(fragment :module code :entry fs
(target :format preferred-canvas-format)))

Every expression result is a double-precision float, and I made the slots strict about what they accept rather than helpful behind your back: an integer slot does not truncate a fractional result, it refuses it and names the fix. These are the evaluator’s messages verbatim:

`:size` evaluates to 33.333333333333336, and this slot takes an
integer — wrap the expression in (floor …) or (ceil …)

A negative result in a non-negative slot is refused the same way. Division by zero does not evaluate to a number, so the slot rejects it, and a constant inside an expression that no (define …) declares gets the same refusal with the name in it (only a bare name in the slot is resolved by the validator; inside an expression it is the evaluator that looks it up):

`:size` does not evaluate to a number — it divides by zero
`:size` does not evaluate to a number — `NUMM` is not a (define …) constant

An exercise: earn two of these on purpose. In the running example, change the buffer’s (* NUM_PARTICLES 16) to (/ NUM_PARTICLES 3) and run pngine validate; then misspell NUM_PARTICLES inside the dispatch and run it again. The first refusal hands you the (floor …)-or-(ceil …) fix, the second names exactly which constant it could not find. Neither leaves you guessing, and that is the property to expect from every message on this page.

PNGine expressions are not WGSL expressions:

Feature PNGine WGSL
Evaluation Compile time Shader compile time
Form S-expression (* a b) Infix a * b
Functions SJON’s core table (arithmetic, rounding, min/max/clamp, trigonometry, comparisons) Full stdlib
Use Resource creation Shader logic

Use PNGine expressions for resource configuration, WGSL for shader logic.