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 (anexternFn, a placeholder-swap lookup) or compared in a byte-identical snapshot.params— a record{ paramName: ShaderType }. Entry points usebuiltin(...)/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 explicitShaderTypeonly 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 optionalBuilderbsecond (rarely needed — the ambientIf/Let/Var/Returnsurface 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)(workgroupSizedefaults to 64).retAttrattaches 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 handconst 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 (INTRINSICS’ Spelling 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.ts — in, 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 too — dpdx/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.