Conditional programs
On this page

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

Conditional programs

After this page you can decide which of several programs to build from one source, tell when a variant is one constant instead of a different program, hand the choice to a host that owns the defines, and name the result so a cache cannot serve one variant for another.

A shader that has to differ by feature, elevation present or absent, 3D or flat, is where a GLSL codebase reaches for #define and a ladder of #ifdef. This package has no preprocessor. A module is an ordinary JavaScript value returned by an ordinary function, so the thing that varies is a function parameter, and a plain if decides what goes into the IR. The arm that loses is never built, so nothing has to be stripped later, and the emitted program carries the winning arm’s math with no dispatch chain around it.

Three questions hide under “the program changes”, and each one has its own answer: the shape of the program differs, a single value differs, or the choice belongs to the host. A capability the device may or may not have is a fourth question, and Capabilities & extensions answers it.

A builder parameter and a plain if

To specialize is to build one program for one set of choices, with the choices made in TypeScript before the module exists. Write the builder to take the fact and branch on it.

import { fn, module, f32, resource, samplerT, texture2dfT, textureSample, vec2fT } from 'typeshade'
const dem = resource('dem', texture2dfT, { group: 0, binding: 0 })
const demSampler = resource('dem_sampler', samplerT, { group: 0, binding: 1 })
const buildTerrain = (hasElevation: boolean) => {
const height = fn('height', { uv: vec2fT }, ({ uv }) =>
hasElevation ? textureSample(dem.node, demSampler.node, uv).x : f32(0),
)
return module({
bindings: hasElevation ? [dem.binding, demSampler.binding] : [],
funcs: [height],
})
}

The bindings move with the shape here, and that is the reason to prefer this over a preprocessor. With hasElevation false the elevation texture is never declared, so it is absent from reflect(), absent from the bind group layout, and the host creates no resource for it. An #ifdef leaves the declaration in the source and leaves the layout to be kept in sync by hand, which is how a disabled feature still costs a binding slot.

Some choices cannot be made until draw time. Those stay as a runtime branch, and when gives you a value picked by a condition the GPU evaluates. Specializing is for a choice you already know while you build.

Statement slots with composeModule

Variants sometimes share a whole module and differ in one run of statements inside one function. Mark the seam with b.placeholder('tag') in the base module and fill it per variant with composeModule. A placeholder is a marker statement carrying a tag, and a swap is the list of statements that replaces it.

import { composeModule, fn, module, vec4, vec4fT, type Stmt } from 'typeshade'
const base = module({
funcs: [
fn('fill_color', {}, vec4fT, (_p, b) => {
b.placeholder('fill')
}),
],
})
const solid: Stmt[] = [{ s: 'return', expr: vec4(1, 0, 0, 1).expr }]
const composed = composeModule(base, { fill: solid })

composeModule descends into if, for and switch bodies, and returns a new module with the function bodies rewritten and everything else carried through. It fills statement slots, so it contributes no consts, structs or bindings, and it is no substitute for an #include. Its reference page covers what it does with a slot you left unfilled and with a swap key that matches no placeholder.

A value that differs with override

If every variant would emit the same program with one constant changed, specializing multiplies pipelines for nothing. Declare a specialization constant instead, with overrideConst. A specialization constant is a module-scope value whose read stays symbolic through every DSL pass and is pinned by the host when it creates the pipeline.

import { f32, f32T, If, Var, fn, module, overrideConst } from 'typeshade'
const quality = overrideConst('quality', f32T, 1.0)
const shade = fn('shade', { base: f32T }, ({ base }) => {
const acc = Var(base)
If(quality.node.gt(f32(1)), () => {
acc.assign(acc.mul(f32(2)).add(f32(0.5)))
})
return acc
})
const m = module({ overrides: [quality.decl], funcs: [shade] })

WGSL emits override quality: f32 = 1.0; and the host pins it through the pipeline’s constants. GLSL ES 3.00 has no driver-side equivalent, so the backend emits a default that lets the module compile on its own, and a host that wants another value re-emits with emitGlslModule(m, 'fragment', { overrideValues: { quality: 2 } }). Both host shapes read the set of constants to supply from reflect().overrides.

Because the read stays opaque to the optimizer, the branch above survives every pass and the driver removes it per pipeline variant, which is the classic ubershader mechanism. The rule of thumb: if two variants would compile to the same instruction sequence with one literal changed, that is an override; if they compile to different code, that is a build-time parameter.

Axes the host decides

An axis is one thing the program varies on, together with the list of values it can take. One point in the axis space is a variant. When the host picks the point at runtime, from state you do not own, the matrix itself becomes the authored thing: variantFamily takes the axes, a builder for one point, and a key derivation, and builds every point.

import { fn, f32T, module, variantFamily } from 'typeshade'
const family = variantFamily({
axes: { quality: ['low', 'high'] },
build: ({ quality }) =>
module({
funcs: [fn('shade', { x: f32T }, ({ x }) => (quality === 'high' ? x.mul(2).add(0.5) : x))],
}),
key: ({ quality }) => `shade:${quality}`,
})
family.keys // ['shade:low', 'shade:high']
family.emit('wgsl') // Map { 'shade:low' => '…', 'shade:high' => '…' }
family.get('shade:high')?.reflection // that variant's own reflection

Every variant carries its module, its own reflection and its key, and emit returns one preprocessor-free source per key for either target. For a GLSL host that already owns the define, emitGuarded lowers the same matrix into a single source with a generated #if ladder, one arm per key, each arm the same code emit produces with the preamble every arm shares hoisted above the ladder. variantFamily’s reference page covers that shape and what it asks of the variants.

The identity of a specialized program

A specialized program is a different program, so every axis you specialize on has to appear in every key that names it. That means the id of a cached pipeline, and the id of a baked artifact that serves bytes without running the builder. The key takes the same facts the builder takes.

const keyFor = (hasElevation: boolean, method: number) =>
`${hasElevation ? 'dem' : 'flat'}:${method}`

The failure a short key causes carries no error. A key that names less than the builder reads hands one variant’s compiled shader to another variant’s draw: it compiles, it links, it renders, and the pixels are wrong. If you add an axis to a builder, add it to the key in the same commit. variantFamily asks for the key as a function of the axes, and throws when two points produce the same key, which turns the same mistake into a build-time error.

Edit this page Report a problem