Functions and entry points
On this page

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.

Edit this page Report a problem