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 reflectionEvery 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.