Skip to content

Expressions

PNGine supports compile-time arithmetic expressions for calculating sizes, counts, and other numeric values. Expressions are S-expressions written directly in a numeric slot, using the same prefix syntax as the rest of SJON, the S-expression language PNGine documents are written in.

An expression is a form whose head is an arithmetic function, with numeric arguments (literals, (define …) symbols, or nested expressions). It evaluates at compile time and must result in a number.

(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))]))

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. What a slot then does with the result is the slot’s rule: a byte count must come out a whole non-negative number, so (/ 100 3) is refused there and (floor (/ 100 3)) is 33.

Expressions nest by composition; no operator precedence or grouping parentheses are needed, since the structure is explicit:

(ceil (/ NUM_PARTICLES 64)) ; ceil(NUM_PARTICLES / 64)
(* (+ 1 2) 3) ; (1 + 2) * 3 = 9
(/ 10 (+ 2 3)) ; 10 / (2 + 3) = 2

Defines can be used in expressions:

(define :name NUM_PARTICLES :value 2048)
(define :name PARTICLE_SIZE :value 32)
(define :name WORKGROUP_SIZE :value 64)
(buffer :name particles
:size (* NUM_PARTICLES PARTICLE_SIZE)
:usage [storage])
(compute-pass :name simulate
:pipeline simPipeline
(dispatch :workgroups [(ceil (/ NUM_PARTICLES WORKGROUP_SIZE))]))
(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)))
  • All expression results are double-precision floats. An integer slot (like :size) does not truncate: a fractional result is refused on the slot, :size evaluates to 33.333333333333336, and this slot takes an integer, and the message says what to wrap it in. 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: :size does not evaluate to a number: it divides by zero. A constant name inside the expression that no (define …) declares is the same refusal with the name in it, :size does not evaluate to a number: NUMM is not a (define …) constant (only a bare name is resolved by the validator; inside an expression it is the evaluator that looks it up).
  • A wrong argument count is an arity_mismatch and an unknown function an unknown_form, both on the expression.
:size (floor (/ 100 3)) ; 33 bytes; a bare (/ 100 3) is refused here

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.