Values and mutation
On this page

Rendered from AUTHORING.md at commit 26de7be8. The package is imported here by its 0.2.0 name, typeshade.

Values and mutation

After this page you can write an intermediate value, give it a type where the code needs one, mutate it, and tell when a value has to carry a name of its own.

Type tokens

A type token is a value that names a shader type. Every one ends in T. You write a token where a declaration needs a type: a function parameter, a struct field, a bound resource, an array’s element type. A token names a type, so it never stands in for a value, though a few value builders take one as an argument, as arrayLit and .at do below.

const scale = fn('scale', { v: vec2fT, k: f32T }, ({ v, k }) => v.mul(k))

f32T is the float scalar and the type a bare number falls back to when there is no operand to take a type from. u32T and i32T are the integer scalars, boolT is what every comparison produces, and vec2fT, vec3fT, vec4fT are the float vectors. The unsigned twins are vec2uT, vec3uT and vec4uT, the signed ones vec2iT, vec3iT and vec4iT. arrayT(elem, n) builds a fixed length array type out of another token.

Each vector token has a constructor beside it that builds a value: vec2, vec3 and vec4 for the float vectors, and vec2u/vec3u/vec4u and vec2i/vec3i/vec4i for the integer ones. A bare number component takes the element kind, so vec3u(1, 2, 3) emits vec3<u32>(1u, 2u, 3u) where vec3(1, 2, 3) emits floats.

Handed one whole vector of the same size, the constructor converts rather than composes: vec3(v) on a vec3<u32> is WGSL’s vec3<f32>(v), every component through the scalar conversion. That is what it emits, and now what it computes on the CPU as well — the oracle used to pass the source components through unchanged, so vec3u(vec3(1.7, 2.9, -3.2)) read back [1.7, 2.9, -3.2] where WGSL gives [1, 2, 0]. A float source saturates into an integer target and i32/u32 reinterpret two’s-complement, on every target: GLSL ES 3.00 leaves an out-of-range source undefined, so the GLSL writer spells the conversion through _f2i and _f2u, which saturate as WGSL does (Rule 11.12).

Plain const bindings

Author every intermediate value as a plain JavaScript const. There is nothing to wrap it in:

const ab = b.sub(a)
const len2 = dot(ab, ab)

The const holds a node, and a node remembers how it was built, so using the name twice uses the same expression twice. The emit pass then decides for each value whether it becomes an inlined expression, a shared let (its common subexpression cache), or a var. That decision is not yours to write down.

The GLSL target adds one hoist of its own. An argument to a struct constructor whose value comes from a function that can discard is bound to a local variable immediately before the constructor call. It happens on every GLSL emit, so there is nothing to mark in the source, and the WGSL output is unaffected.

Let and Var

Two functions give a value a name when the name has to exist. Let(value) binds the value once and hands back a read-only node. Var(init) declares a mutable variable; the type comes from the initial value, or you pass a type token, with or without an initial value. Both take an optional leading name string, which becomes the name in the emitted source. Leave the name out and the binding takes a function-unique auto name (_v0, _v1), so pass a name wherever you want to read the emitted source.

const d = Let('d', length(p).sub(1)) // let d = …; bound once, read only
const t = Var('t', f32(0)) // var t: f32 = 0.0;
const hits = Var('hits', u32T) // var hits: u32;

A Let binding, a function parameter and a module constant are read-only nodes. They carry every method that reads a value and none that writes one, so assigning to them is a tsc error at the authoring line. Declare with Var when you mean to mutate.

A Let stops being a matter of taste inside a loop that mutates a variable. The subexpression cache cannot share a value that reads a mutated variable, because the value differs at every read, so a value derived from that mutated variable and read twice is re-emitted at each use until you bind it with Let. A derivative such as fwidth needs a name for a different reason: WGSL requires the call in uniform control flow, so bind it with Let outside the branch that reads it.

Mutation with assign

JavaScript cannot overload =, so mutation is a method on the value being written. .assign(v) writes a value, and .addAssign, .subAssign, .mulAssign and .divAssign are the compound updates, emitting x += v and its siblings.

const min_dist = f32(1e10) // a plain const…
min_dist.assign(min(min_dist, d)) // …becomes a var because something assigns to it
winding.addAssign(1) // emits `winding += 1`
o.pos.assign(vec4(pos, 0, 1)) // a struct field is a target too

The two spellings of an update differ in the emitted text, not in meaning: acc.addAssign(x) emits acc += x and acc.assign(acc.add(x)) emits acc = (acc + x). Prefer the compound form: it is the statement the "use typeshade" compiler builds for a source-level +=, so a helper moved between the two authoring surfaces keeps its emit.

Assigning to a plain const is enough to make it a variable in the emitted source. You do not have to see that coming and declare a Var up front.

Operator methods

Arithmetic, comparison, bitwise operations, component access and indexing are all methods on a node, for the same reason mutation is:

categorymethods
arithmetic.add .sub .mul .div .mod .neg
compound.addAssign .subAssign .mulAssign .divAssign
comparison.lt .gt .le .ge .eq .ne
logical.and .or .not
bitwise.bitAnd .bitOr .bitXor .shl .shr
casts.f32() .i32() .u32() .f64()
components.x .y .z .w · .r .g .b .a · .rgb .xy .xyz … · .swizzle<R>('zxy')
index.at(i) on an array node · .at(i, elemType) otherwise
ternarycond.select(a, b)

A method reads left to right, which is the right default; the expression it cannot spell is the one whose LEFT operand is a literal. add, sub, mul and div are the same four operations as free functions for exactly that case: sub(1, smoothstep(a, b, d)) rather than f32(1).sub(...). Whichever operand is a node types the other, so sub(3, n) emits 3u - n for a u32 n. pow, mix and atan2 take a literal in their first slot too.

f32, i32, u32 and f64 are casts when given a node, f32(i), which is the spelling WGSL and the "use typeshade" surface both use, and literals when given a number. toF32 and its siblings are the older names for the cast and still work.

A few more operations are free functions. select(cond, a, b) is the free spelling of .select, for the times the condition is not the value you want to read first. Mind the argument order: it reads condition first, the reverse of WGSL’s own select(falseValue, trueValue, condition), so select(c, a, b) emits select(b, a, c). mod(x, y) is the floor modulo, the one to reach for wherever an operand can be negative, as in domain repetition and angle folds; the .mod method is % and behaves differently on negative operands. radians and degrees convert angles, so a conversion constant of your own never has to be written or rounded.

const inside = d.lt(0).and(u.ge(0))
const shade = select(inside, 1, 0)
const cell = mod(p.x, 2).sub(1)
const lonRad = radians(lon)
const latDeg = degrees(latRad)

Number literals

A bare number lifts to the type of the operand beside it, so most of the time the wrapper is unnecessary:

x.add(1) // f32 x → x + 1.0
flags.bitAnd(1) // u32 flags → flags & 1u
mode.eq(2) // u32 → mode == 2u
vec4(pos, 0, 1) // components lift to the vector's element type
vec2u(0, 1) // → u32 components

The same lift works inside vector and struct constructors and inside min, max, clamp, mix, pow and smoothstep. Keep an explicit f32(0.5) or u32(16) where there is nothing to infer from: a standalone constant, the type anchoring first argument of a math builtin, or a literal you want to call a method on, as in f32(1).sub(v).

Negative literals lift the same way. x.mul(-6), .add(-0.25) and vec3(-1, 0, 1) emit the signed literal on both targets, so the sign belongs in the number.

Module constants

A module constant is declared once, emitted as a const on both targets, and evaluated by the CPU oracle as well. A scalar whose GPU and CPU values differ, one that the shader should see truncated and the oracle should see in full, is declared with constDecl:

const PI = constDecl('PI', f32T, { wgsl: 3.14159265, cpu: Math.PI })
const area = fn('area', { r: f32T }, ({ r }) => r.mul(r).mul(PI.node))

constDecl hands back a handle with two halves. PI.decl is the declaration the module carries, and PI.node is the typed reference you read inside a function body, so a renamed or misspelled constant is a tsc error at every use site.

A constant that is not a scalar, a colour, a palette, a struct, is declared with constExpr from a literal node the compiler can fold. arrayLit(elem, ...items) builds the array literal to hand it:

const SKY = constExpr('SKY', vec4fT, vec4(0.4, 0.6, 0.9, 1))
const PALETTE = constExpr('PALETTE', arrayT(vec4fT, 3), arrayLit(vec4fT, c0, c1, c2))

PI.decl and the constExpr results go in the module’s consts array. Both are read through .node: PI.node, SKY.node, PALETTE.node.at(i). A constExpr result IS the declaration, so it drops into consts as itself while still answering .node, and the reference is built from the name and type the constant was declared with, so the two cannot disagree. constRef('SKY', vec4fT) is the older spelling, and the string in it is one the type checker cannot check for you.

Edit this page Report a problem