Rendered from AUTHORING.md at commit 26de7be8. The package is imported here by its 0.2.0 name, typeshade.
Control flow
After this page you can branch, loop and dispatch inside a shader body, and you can tell a
statement form from a value form. A statement form pushes code onto the body being built
and hands back no value you can bind, the way If and Loop do. A value form builds
the same branch internally and returns a node you can bind to a const, the way when and
matchEnum do. Reach for a value form when the branch exists to pick a value, and for a
statement form when it exists to do something.
If, elif and else
If(cond, body) takes a bool node and a zero argument closure. The body authors into the
innermost open scope, so there is no builder object to thread through it. Chain
.elif(cond, body) and .else(body) on the result.
If(p.idx.eq(1), () => { pos.assign(vec2(3, -1))}) .elif(p.idx.eq(2), () => { pos.assign(vec2(-1, 3)) }) .else(() => { pos.assign(vec2(-1, -1)) })These are statements. A body that ends in a native return value is not read as a value
the chain produces, and writing one is rejected with SD0115 rather than silently dropped:
the value would go nowhere and the branch would emit as an empty block. To leave the
enclosing function from inside a branch, use Return or ReturnIf, covered at the end of
this page; for a branch that exists to pick a value, use when, below.
.not() is logical negation, so a guard on the false case reads If(hit.not(), …) rather
than If(hit.eq(bool(false)), …).
Loop
Loop is the C style for loop. A fixed trip count is the short form, and covers most
loops:
Loop(64, (i) => { acc.addAssign(i.f32())})The counter runs 0u up to the count, stepping by one. Reach for the three part form when
the counter starts somewhere other than zero, counts down, or is tested against something
that is not a literal. It takes the counter’s initial value, a condition, a body, and an
optional step that defaults to +1. An optional leading name string names the counter in
the emitted source, in either form.
Loop( u32(0), (i) => i.lt(64), // the condition receives the counter… (i) => { // …and so does the body, so declare (i) here too acc.addAssign(i.f32()) },)The two spell the same loop and emit the same for header. A bare number compares against
the counter’s own kind, so i.lt(64) emits i < 64u for a u32 counter and needs no
u32(64).
Both callbacks receive the counter. A body written () => {} that mentions i is legal
JavaScript closure syntax, and i is undefined there: tsc reports Cannot find name 'i'
at the authoring line. The counter arrives as a mutable node, so assigning to it inside the
body is allowed.
An error thrown while a body is being built carries the body it came from, so the message
starts with the function and the body kind, for example in Loop body.
Break, continue and discard
Three terminators end a piece of work early. Break() exits the nearest enclosing loop, or
the current switch case. Continue() skips to the next iteration of the nearest enclosing
loop. Discard() kills the current fragment invocation with no colour or depth write, and
belongs in a fragment stage. An If or Switch body nested in a loop is not itself a loop
boundary, so a Break() inside a guard targets the loop around the guard.
const dists = Var('dists', arrayT(f32T, 64))
Loop( u32(0), (i) => i.lt(count), (i) => { const d = Let(dists.at(i)) // an array node knows its own element type If(d.lt(0), () => Continue()) // no distance recorded, next iteration If(d.lt(0.001), () => Break()) // close enough, leave the loop nearest.assign(min(nearest, d)) },)
If(alpha.lt(0.01), () => { Discard()})Switch
Switch(scrut) dispatches on a single integer value, the scrutinee, which is an i32 or
a u32 node. .case(n, body) adds a case label and .default(body) adds the optional
default arm and closes the chain. It lowers to a real switch on both targets.
Switch is a statement, so a switch that picks a value declares the variable first and
assigns to it in the arms:
const radiusPx = Var(rawRadius)Switch(sizeMode) .case(1, () => radiusPx.assign(rawRadius.div(viewport.z))) .case(2, () => radiusPx.assign(rawRadius.mul(dpr))) .default(() => {}) // the default arm may be empty, and it terminates the chainThe value form of the same dispatch is matchExpr(scrutinee, cases, default), which
returns the result as a node. Its arms are [caseValue, value] pairs and its default is the
fall-through value.
Choosing a value with when
when is the condition side value form. It takes values only: no name, no type token, with
the result type inferred from the arms. The two argument shapes are a two arm form and an
N arm form, where the first arm whose condition holds wins.
// two armsconst dir = when( segLen.lt(1e-6), () => vec2(1, 0), () => segVec.div(segLen),)
// N arms: an array of [condition, () => value] pairs, then the else valueconst clip = when( [ [projParams.x.lt(0.5), () => transformMat4(mvp, vec4(rel2d, 0, 1))], [projParams.x.lt(6.5), () => transformMat4(mvp, vec4(relG, 0, 1))], ], () => transformMat4(mvp, vec4(ecefRtc, 1)),)when declares the variable and the if chain internally and returns the result node, so
the emitted code is what the hand written var v; if (…) v = … gives you. Each arm is a
thunk, so its value is built inside that arm’s branch. A Let written in an arm lands in
that branch, and the GPU runs only the branch it takes. select(cond, a, b) is the eager
two way alternative: both arms are evaluated and one is chosen, which suits a pair of
cheap values. Use when for dispatch on conditions or ranges, and Switch or matchExpr
when there is a single integer scrutinee.
Folding a loop with reduce
An accumulator is a value carried from one iteration of a loop to the next. reduce
carries one for you. It takes the accumulator’s initial value, then the loop’s initial
counter, condition, body and optional step. The body returns the next accumulator, and
reduce returns the final one for use after the loop.
const best = reduce( f32(1e10), u32(0), (i) => i.le(STEPS), (acc, i) => { const q = bezierPoint(i) return min(acc, length(p.sub(q))) }, u32(1),)reduce declares the variable, the loop and the assignment internally, so this emits the
same code as writing Var plus Loop plus assign yourself.
Exhaustive dispatch with enumU32
enumU32 declares a u32 enum from a name to value map, and matchEnum dispatches on it
with one arm per member. The arms object has to cover every member: leave one out, or add a
key that is no member, and tsc reports it, so adding a member surfaces every site that
has to handle it. The dispatch lowers to the same matchExpr the hand written form gives.
const Kind = enumU32({ Line: 0, Fill: 1, Stroke: 2 })
const color = matchEnum(seg.kind, Kind, { Line: () => lineColor, Fill: () => fillColor, Stroke: () => strokeColor, // drop an arm and it is a compile error})Kind.members.Fill is a Node<'u32'> literal you can compare against, and Kind.values
holds the raw integers the case labels use. Prefer matchEnum over a bare Switch whenever
the case set is closed: a forgotten case is a compile error, so it never reaches a pixel.
Early returns
A native return value inside a control flow body is not an early exit, because that would
read as a silent fall-through. Write early exits with Return(value), or with
ReturnIf(cond, value) for a guard clause, which emits what If(cond, () => Return(value))
emits.
If(winding.ne(0), () => { Return(f32(1).sub(min_dist))})Return(f32(1).add(min_dist))The same guard on one line:
ReturnIf(winding.ne(0), f32(1).sub(min_dist))Return(f32(1).add(min_dist))The final return value of a fn body is native TypeScript and stays type checked, so
that one needs nothing. Return and ReturnIf are for exits from inside an If, a Loop
or a Switch. The single exit rule that diagnose runs asks a function for one return, as
its last statement. Pass { allowEarlyReturn: true } as the last argument to fn to say
the early exit is deliberate, and the rule stops reporting it.