Control flow
On this page

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 chain

The 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 arms
const dir = when(
segLen.lt(1e-6),
() => vec2(1, 0),
() => segVec.div(segLen),
)
// N arms: an array of [condition, () => value] pairs, then the else value
const 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.

Edit this page Report a problem