The authoring surface

Rendered from AUTHORING.md at commit d894fc0. The package is imported here by its 0.1.0 name, typeshade.

The authoring surface

fn — every function (and every entry point)

fn authors all functions: plain helpers and @vertex / @fragment / @compute entry points. There is no separate entryFn / computeFn.

Full signature: fn.

  • name — optional. Omit it for an auto _fn{n} name. Keep an explicit name for any fn referenced by a string (an externFn, a placeholder-swap lookup) or compared in a byte-identical snapshot.
  • params — a record { paramName: ShaderType }. Entry points use builtin(...) / location(...) specs in the same record (see below).
  • ret — optional. Omit it and the return type is inferred from the value the body returns. Pass an explicit ShaderType only when you want to pin it.
  • body(p, b?) => Node | void. The body receives the typed param Nodes first (p.lon, p.uv, …), and an optional Builder b second (rarely needed — the ambient If / Let / Var / Return surface covers most bodies).
  • opts{ stage, workgroupSize?, retAttr?, allowEarlyReturn?, lintDisable? }.

The body’s native return value is type-checked against the return type, so a wrong-typed return is a compile error.

// Helper: return type inferred as f32 from `return select(...)`.
export const dist_to_segment = fn(
'dist_to_segment',
{ p: vec2fT, a: vec2fT, b: vec2fT },
({ p, a, b }) => {
const ab = b.sub(a)
const len2 = dot(ab, ab)
const t = clamp(dot(p.sub(a), ab).div(max(len2, 1e-10)), 0, 1)
const segDist = length(p.sub(a).sub(ab.mul(t)))
return select(len2.lt(1e-10), length(p.sub(a)), segDist)
},
)

A function authored with fn() is an FnHandle: it is both the callable and the function declaration. Call it directly — dist_to_segment(uv, p0, p1) — and list it in a module’s funcs: array. There is no callFn('dist_to_segment', …).

Entry points — opts.stage + builtin() / location() params

An entry point is just a fn with opts.stage. Stage-attributed params (@builtin(...), @location(...)) go in the same param record using the same builtin() / location() helpers the IO structs use:

const vs = fn('vs_tile', { vid: builtin('vertex_index', u32T) }, (p) => {
// …compute clip position…
return VsOut.construct({ pos: …, uv: …, vis: f32(1), view_w: clip.w })
}, { stage: 'vertex' })
const cs = fn('cs_match', { gid: builtin('global_invocation_id', vec3uT) }, (p) => {
// …
}, { stage: 'compute', workgroupSize: 64 })
  • stage: 'compute' emits @compute @workgroup_size(N) (workgroupSize defaults to 64).
  • retAttr attaches an attribute to a bare (non-struct) stage return, e.g. -> @location(0) vec4<f32>. A struct return carries its attributes in the struct.

Reserved-word params (#763 H7). A param may be NAMED in (or another GLSL/WGSL reserved word) — the IR carries it and the GLSL backend renames at emit — but JS destructuring cannot BIND that name: write ({ in: inp }) => ….

module — assemble the WGSL module

module({ consts, structs, bindings, funcs })

Each field is an array; any omitted field defaults to []. Order in funcs: is the emit order — keep callees before callers. Not for WGSL’s sake (final-spec WGSL resolves module-scope declarations out of order; it has no prototype syntax at all), but because (a) GLSL ES 3.00 requires declare-before-use — the GLSL backend emits forward prototypes as a safety net, and dependency order keeps working even without them — and (b) a stable order keeps snapshot/golden bytes deterministic:

export const buildRasterModule = (pickEnabled: boolean): ModuleDecl =>
module({
consts: [...PROJECTION_CONSTS, ...ECEF_CONSTS],
structs: [U.struct, Tile.struct, VsOut.decl, rasterFragmentOutput(pickEnabled).decl],
bindings: [U.binding, tex.binding, texSampler.binding, Tile.binding],
funcs: [
...getGpuProjectionFuncs(),
...ECEF_FUNCS,
...RASTER_COLOR_FUNCS,
apply_log_depth,
compute_log_frag_depth,
vs,
buildFs(pickEnabled),
],
})

funcs: as a key-record — name once (#740 R1)

funcs: also accepts a RECORD; each key becomes the fn’s emitted name (a rename of whatever the handle carried, including anonymous fn(params, body) handles), and key order is the emit order (JS preserves string-key insertion order):

module({ funcs: { proj_mercator, wrap_lon_delta, vs_main } })

Record keys are deterministic names — the fnAutoId collision counter behind anonymous handles never reaches emitted WGSL through this form (#763 H9) — so it is safe for snapshot-gated and string-referenced shaders too. Keep the ARRAY form when the decl list is spread across sources or post-processed as data (e.g. a blanket allowEarlyReturn map).


composeModule — variant composition via placeholders

When a base module has variation seams, mark them with b.placeholder('tag') and fill them per variant with composeModule, instead of hand-rolling a clone-and-swap walk:

const base = module({ funcs: [/* … fs_fill ends with */ (_p, b) => b.placeholder('fill-return')] })
const composed = composeModule(base, { 'fill-return': variantFillReturnStmts })

It descends into if/for/switch bodies, and is strict by default: an un-swapped placeholder or a swap key that matches no placeholder throws (the silent-on-GPU / throws-on-CPU footgun becomes a loud compose-time error). Pass { allowUnswapped: true } for deliberate bare survival.

rawStmt — the verbatim escape hatch, paired per target

When a statement must be hand-written — a pre-built string from another generator, a construct the IR does not model — splice it with rawStmt. It carries one payload per target: the same statement, spelled for each backend.

// the FACTORY form — when you assemble a `Stmt[]` body array by hand
const PAIRED = rawStmt({
wgsl: 'return vec4<f32>(1.0, 0.0, 0.0, 1.0);',
glsl: 'return vec4(1.0, 0.0, 0.0, 1.0);',
})
const fs: FuncDecl = {
name: 'fs_main',
attrs: ['@fragment'],
stage: 'fragment',
params: [],
ret: vec4fT,
retAttr: '@location(0)',
body: [PAIRED],
}

Inside a fluent fn() body use b.raw(payload) instead — a bare rawStmt(...) call there is a silently discarded expression (the returned Stmt is never pushed, so nothing is emitted).

The MEANING is fixed (“splice these bytes here”); only the SPELLING is per-target — the same per-target-spelling pattern the intrinsic registry uses (INTRINSICSSpelling record). Unlike Spelling, which requires BOTH sides, one side may be omitted here (see below) — but at least one is required at the type level: rawStmt({}) does not compile. Each backend emits its own side verbatim at the enclosing body indent.

Fail-closed, symmetrically. A backend handed a raw with no payload for ITS target throws UnsupportedFeatureError (SD0030) — a wgsl-only raw is a hard build error on GLSL, and a glsl-only raw is a hard build error on WGSL. Omitting a side is a decision that “this module does not build for that target”, never a silent mis-emit. So supply every spelling the module must build for; the error names the missing one and quotes the side you did give.

Only the FIRST line gets the indent. The emitter prepends the body indent to the payload as a whole, so line 2+ of a multi-line payload lands at column 0. Indent continuation lines yourself if the output shape matters.

Identifiers inside raw text are yours to keep valid. The DSL does not read into a raw payload, so nothing rewrites it: mangle()/obfuscate() rename what they can see, and a textual reference is not something they can see. (A module containing any raw makes the mangle a no-op module-wide for this reason; see §8.) The concrete GLSL-side mine: the GLSL backend actively renames params and locals that collide with GLSL reserved words (glsl-sanitize.tsin, sample, filter, texture, …), so raw glsl text naming the OLD identifier will reference a variable that no longer exists. The WGSL side has no such renamer, so the risk is asymmetric even though the contract (the author keeps the raw text valid) is identical in both directions.

A raw anywhere in the module disables GLSL stage scoping. Raw text is opaque to the IR’s reference walk, so the GLSL backend’s per-stage reachability filter (glsl.ts’s stageScope) bails to null for the whole module. HELPERS: every helper fn is then emitted into every stage — a helper the entry never calls still reaches the writer, so a wgsl-only raw inside it still throws the whole GLSL emit. ENTRIES: an entry of a DIFFERENT stage is still dropped before the body walk, so enforcement is per-stage — a one-sided raw does not build for any stage whose emitted fn set contains that raw, not “for that target” globally. Pair the payloads on every raw in a module that must build for both targets, reachable or not.

⚠ Because scoping is off, fragment-only machinery in ANY helper of a raw-carrying module is emitted into the vertex stage toodpdx/dpdy/fwidth via intrinsics, discard — and fails to compile there. Keep such modules helper-clean, or split the raw out into its own module.

(Whole-module dead-function elimination does not save an uncalled helper either — but not for this reason: deadFnElim (passes/opt/dce-fns.ts) is an available-but-unwired pass, deliberately absent from DEFAULT_PASSES, so tree-shaking simply never runs. It also bails on any raw, for any caller that does wire it.)

The CPU oracle has no evaluation for raw text at all and throws on any target — raw is GPU-only.

Edit this page