Your first shader
On this page

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>,
}
@vertex
fn 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)));
}
@fragment
fn 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 es
precision 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 es
precision 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.

Edit this page Report a problem