Rendered from AUTHORING.md at commit 26de7be8. The package is imported here by its 0.2.0 name, typeshade.
Functions and entry points
After this page you can declare a helper, call it from another function, write an entry point for the vertex, fragment or compute stage, and assemble the module that carries them.
Declaring a function
fn authors every function in a shader, both plain helpers and the entry points a pipeline
runs. It takes an optional name, a record of parameters keyed by name, and a body. The body
receives the typed parameter nodes as its first argument, so destructuring that argument
gives you one node per parameter.
// 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) },)What fn returns is a handle, one object that is both the callable and the function
declaration. You call the handle directly, and you list it in a module.
A parameter may carry a name that GLSL reserves, such as in, sample, filter or
texture. The IR keeps the name and the GLSL backend renames it at emit. JavaScript
destructuring cannot bind such a name, so write ({ in: inp }) => ….
Return types
Leave the return type out and it is inferred from the value the body returns. The body’s
native return is checked against that type, so a wrongly typed return is a compile error.
A body that returns nothing at all is inferred too, and lands on void.
One case still needs an explicit type token: a body whose value leaves through an ambient
Return(value) inside a nested closure. TypeScript reads such a body as returning nothing,
the same shape as a genuinely void one, so the two are separated once the body has run, and
omitting the token there is rejected with SD0113 naming the token to write.
// Inferred: dot() yields f32, so luma returns f32.const luma = fn('luma', { c: vec3fT }, ({ c }) => dot(c, vec3(0.2126, 0.7152, 0.0722)))
// Inferred too: this one writes into a storage buffer and returns no value, so it is void.const store = fn('store', { i: u32T, v: f32T }, ({ i, v }) => { outputB.at(i).assign(v)})
// Pinned: the value leaves through the ambient Return(), which tsc cannot see.const firstHit = fn( 'first_hit', { d: f32T }, f32T, ({ d }) => { If(d.lt(0), () => { Return(f32(0)) }) Return(d) }, { allowEarlyReturn: true },)Calling a function
A handle accepts a single object keyed by parameter name. That form checks argument names, types and completeness, and it autocompletes. A handle also accepts positional arguments, which TypeScript does not check for arity, type or order, so a swap of two arguments of the same type compiles.
externFn is the call-only counterpart. It declares the signature of a function whose body
is linked in at emit from elsewhere, so you get a typed call now and there is no declaration
to list in a module. Use a real fn handle whenever the callee can be imported at the call
site.
const d = dist_to_segment({ p: uv, a: p0, b: p1 })const same = dist_to_segment(uv, p0, p1) // the unchecked form
const toneMap = externFn('host_tone_map', { c: vec4fT }, vec4fT)const mapped = toneMap({ c: colour })Entry points
An entry point is a fn whose options carry stage, one of 'vertex', 'fragment' or
'compute'. A compute entry also takes workgroupSize, which defaults to 64 and emits
@compute @workgroup_size(N).
const WINDOW = u32(8)const params = resource('params', vec4uT, { group: 0, binding: 2 })
const reduceKernel = fn( 'reduce_windows', { gid: builtin('global_invocation_id') }, ({ gid }) => { const idx = gid.x If(idx.ge(params.node.x), () => { Return() }) const base = idx.mul(WINDOW) // …fold WINDOW input elements into one output element… }, { stage: 'compute', workgroupSize: 64, allowEarlyReturn: true },)The guard leaves the function before its last statement, so the options also carry
allowEarlyReturn: true. Control flow covers early exits
and that option.
A body that returns nothing needs no return type token: the voidT a compute entry used to
write is inferred.
Stage parameters
A stage passes its inputs through attributed parameters. builtin(name) declares a value
the hardware supplies, such as 'vertex_index', 'position' or 'global_invocation_id',
and reads the type from the id: WGSL fixes one for every id but clip_distances, whose
array<f32, N> length you pick and therefore pass. location(n, type) declares a numbered
slot that carries data between stages. The same two helpers describe the fields of an IO struct, which ioStruct
declares once and both stages then share;
Layouts and resources has the field map, the
interpolation modes and the accessors the handle carries.
const VsOut = ioStruct('VsOut', { pos: builtin('position'), uv: location(0, vec2fT),})
const vsFull = fn( 'vs_full', { idx: builtin('vertex_index') }, (p) => { const pos = vec2(-1, -1) If(p.idx.eq(1), () => { pos.assign(vec2(3, -1)) }).elif(p.idx.eq(2), () => { pos.assign(vec2(-1, 3)) }) return VsOut.construct({ pos: vec4(pos, 0, 1), uv: vec2(pos.x.add(1).mul(0.5), pos.y.add(1).mul(0.5)), }) }, { stage: 'vertex' },)
// U is a uniform block declared alongside these functions.const fsGradient = fn( 'fs_gradient', { vo: VsOut }, (p) => { const t = p.vo.uv.y.add(U.field.mix_bias) const rgb = mix(U.field.bottom.rgb, U.field.top.rgb, t) return vec4(rgb, 1) }, { stage: 'fragment' },)A fragment function that returns a plain value gets @location(0), the first colour
attachment, without asking. Pass retAttr to send it somewhere else, and on a vertex
stage, where there is no such default. A stage function that returns a struct carries the
attributes in the struct fields.
Assembling a module
module collects the declarations that emit together, and every one of its fields is
optional. consts, structs, bindings and funcs take declarations directly, and uses
takes handles that carry their own, the form
Layouts and resources uses. A module also carries
the overrides of Conditional programs and the
enables of Capabilities & extensions.
const gradientModule = module({ structs: [U.struct, VsOut.decl], bindings: [U.binding], funcs: [vsFull, fsGradient],})Order in the funcs array is the emit order. Keep callees before their callers, because GLSL
ES 3.00 requires a declaration before its use, and a fixed order keeps the emitted bytes the
same from run to run. A function you reach through a handle call but do not list is collected
for you and placed before the function that calls it, which means an entry-point-only list
also emits in a valid order.
A resource is not collected that way: a module assembles only the declarations it is
handed, so a uniformStruct or storageBuffer a function reads and uses does not list
emits no var declaration at all, and the first report of that is the driver at pipeline
creation. The uses-declared lint rule names it, so diagnose(m) or lintModule(m)
reports every variable a body reads that nothing declares and says which handle to add.
Naming the functions in a module
A fn without a name gets a placeholder name. To name functions once, pass funcs as a
record: each key becomes the emitted name of its function, and key order is the emit order.
module({ funcs: { dist_to_segment, vs_full: vsFull, fs_gradient: fsGradient } })Record keys are deterministic, so this form is safe for byte-compared snapshots and for functions referenced by string. Keep the array form when the list is spread across several sources or post-processed as data. One caution: a record key renames the shared declaration, so a function already assembled into another module under a different name throws instead of corrupting that module’s emit.