Rendered from AUTHORING.md at commit 26de7be8. The package is imported here by its 0.2.0 name, typeshade.
Your first shader
After this page you have a shader module written in TypeScript with two entry points, the same module emitted once as WGSL for WebGPU and once as GLSL ES 3.00 for WebGL2, and you know which call produced each string. The shader fills the screen with a colour gradient.
Everything the page uses comes from the package barrel:
import { fn, module, ioStruct, builtin, location, emitModule, emitGlslStages, sub, vec2, vec4, vec2fT, vec4fT,} from 'typeshade'Two kinds of name appear there. vec2fT and vec4fT are type tokens, which is what you
write where a declaration needs a type. vec2 and vec4 build a node, a typed expression
the graph is made of. A node carries its type in TypeScript, and you build larger
expressions by calling methods on it: x.mul(4).sub(1) is a multiply and a subtract. sub
is the same subtraction as a free function, for the expression whose left side is a literal.
A vertex entry point
An entry point is a function the GPU calls directly: once per vertex, once per fragment,
or once per compute invocation. This page uses the first two. You declare one with fn
and opts.stage. Everything else you write is a plain helper, declared with the same
fn.
The vertex stage runs once per vertex. It produces the clip space position and whatever
the fragment stage needs from it, packed in an IO struct: a record of fields where each
field carries an attribute. builtin('position', …) marks the value the hardware itself
consumes, and location(0, …) marks a value that is interpolated across the triangle and
read back by the fragment stage.
const VsOut = ioStruct('VsOut', { pos: builtin('position'), uv: location(0, vec2fT),})builtin needs no type token. WGSL fixes the type of every builtin but clip_distances,
so builtin('position') reads vec4<f32> from the id; writing the token is what would let
you disagree with the spec.
This shader has no vertex buffer. It draws three vertices that cover the screen, and it
derives their positions from the vertex index alone. Stage attributed params go in the
same param record as ordinary ones, using the same builtin and location helpers:
const vs = fn( 'vs', { vi: builtin('vertex_index') }, ({ vi }) => { const x = vi.bitAnd(1).f32().mul(4).sub(1) const y = vi.shr(1).f32().mul(4).sub(1) return VsOut.construct({ pos: vec4(x, y, 0, 1), uv: vec2(x.mul(0.5).add(0.5), y.mul(0.5).add(0.5)), }) }, { stage: 'vertex' },)The body receives the params as typed nodes, so vi is a u32 node and .f32() converts
it. The cast sits in the chain beside .mul and .sub, so the line reads left to right;
the free f32(vi) and the older toF32(vi) build the same node. A bare number operand
takes its type from the node it meets, which is why bitAnd(1) needs no u32(1). The
return type is inferred from the value the body returns, here the struct built by
VsOut.construct.
A fragment entry point
The fragment stage runs once per candidate pixel and returns a colour. It takes the vertex
output as a param, so the fields declared on VsOut are what it reads: vo.uv gives back
a vec2<f32> node with .x and .y on it.
const fs = fn( 'fs', { vo: VsOut }, ({ vo }) => { const shade = sub(1, vo.uv.y) return vec4(vo.uv.x, shade, 0.5, 1) }, { stage: 'fragment' },)A bare fragment return defaults to @location(0), the first colour attachment, so nothing
is written for it. Pass retAttr to send a non-struct return somewhere else.
A number literal takes its type from the operand next to it, so
vec4(vo.uv.x, shade, 0.5, 1) needs no wrapper. sub(1, x) is the free-function form of
x’s own .sub, for the expression whose LEFT side is the literal: a bare number carries
no methods, so the method form has to be written f32(1).sub(x). add, mul and div
have the same pair.
Assembling the module
A module is the unit that emits. module takes arrays of consts, structs, bindings and
funcs, and each field defaults to empty, so this shader declares two of them:
const gradient = module({ structs: [VsOut.decl], funcs: [vs, fs],})VsOut.decl is the struct declaration behind the handle. The handle is what you construct
values with and read fields off; the declaration is what the module emits. Order in
funcs is the emit order, so keep a function after the ones it calls.
Emitting both targets
emitModule(gradient) returns the whole module as one WGSL string, both entries included:
struct VsOut { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32>,}
@vertexfn vs(@builtin(vertex_index) vi: u32) -> VsOut { let _cse0 = ((f32((vi & 1u)) * 4.0) - 1.0); let _cse1 = ((f32((vi >> 1u)) * 4.0) - 1.0); return VsOut(vec4<f32>(_cse0, _cse1, 0.0, 1.0), vec2<f32>(((_cse0 * 0.5) + 0.5), ((_cse1 * 0.5) + 0.5)));}
@fragmentfn fs(vo: VsOut) -> @location(0) vec4<f32> { return vec4<f32>(vo.uv.x, (1.0 - vo.uv.y), 0.5, 1.0);}GLSL ES 3.00 compiles one stage at a time, each source with its own main, so GLSL comes
back one string per stage. emitGlslStages(gradient) returns { vertex, fragment } from
a single lowering of the module, the pass that rewrites the IR into the shapes the target
can spell:
#version 300 esprecision highp float;precision highp int;
out vec2 uv;
void main() { uint vi = uint(gl_VertexID); float _cse0 = ((float((vi & 1u)) * 4.0) - 1.0); float _cse1 = ((float((vi >> 1u)) * 4.0) - 1.0); gl_Position = vec4(_cse0, _cse1, 0.0, 1.0); uv = vec2(((_cse0 * 0.5) + 0.5), ((_cse1 * 0.5) + 0.5));}#version 300 esprecision highp float;precision highp int;
in vec2 uv;layout(location = 0) out vec4 _ret;
void main() { _ret = vec4(uv.x, (1.0 - uv.y), 0.5, 1.0);}The source was the same for both calls. The differences between the two outputs are the
ones each language forces: the IO struct becomes a matching out and in pair of
varyings, the fragment return becomes a declared output variable, the builtin position
becomes gl_Position, and gl_VertexID is a signed int so the u32 param picks up a
cast. Emitting does not change the module, so you can emit it for either target, in either
order, as many times as you like.
Where to go next
Let and Var force a value to carry a name of its own, and
Values and mutation covers when a name is required
and how .assign mutates a value.
Functions and entry points has the rest of
what fn and module accept, including the compute stage.
Layouts and resources is how a uniform block, a
vertex layout or a texture is declared once and read everywhere.
Emitting and reflection covers the other emit
entry points and the pipeline metadata a host binds from.