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.
Expression Syntax
Section titled “Expression Syntax”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.
Where a bare name works
Section titled “Where a bare name works”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).
Functions
Section titled “Functions”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.
Nesting
Section titled “Nesting”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
64The 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).
Common Patterns
Section titled “Common Patterns”Three shapes cover most documents; lift them as they are.
Buffer Size Calculation
Section titled “Buffer Size Calculation”(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])Compute Dispatch
Section titled “Compute Dispatch”(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))]))Array Stride and Offsets
Section titled “Array Stride and Offsets”(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)))Refusals
Section titled “Refusals”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 aninteger — 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 …) constantAn 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.
Comparison with WGSL
Section titled “Comparison with WGSL”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.
Related
Section titled “Related”(define …)- Define constants- References - Reference syntax
(data …)- Data and shape generators- SJON language docs - Expressions are a SJON feature, not a PNGine one