Compiler internals
On this page

Rendered from AUTHORING.md at commit 26de7be8. The package is imported here by its 0.2.0 name, typeshade.

Authoring shaders with typeshade

After this page you know what one TypeScript source turns into, how to import the authoring surface, and which page of this guide answers which question. The samples are written against the source in this repository, and examples/ holds complete shaders authored the same way.

What you write and what comes out

You write a shader as typed expressions and statements in TypeScript. Each expression you build is a node: a small typed object that records an operation and its operands. The graph of nodes is the intermediate representation, the IR, and the IR is what the package compiles. You do not assemble shader text by hand, so a wrong type or a misspelt field is a TypeScript error in your editor.

Two calls carry the structure. fn declares a function: its parameters, its body, and, for an entry point, the stage it runs at. An entry point is the function the GPU calls, once per vertex, per fragment or per compute invocation; every other function is a helper it calls. module gathers the functions, the structs they use, the resources they read and the constants they share into one module value.

That module value is the input to four outputs:

  • emitModule returns WGSL, the shading language WebGPU accepts.
  • emitGlslStages returns { vertex, fragment }, GLSL ES 3.00 source for WebGL2, for a module that has both a vertex and a fragment entry point.
  • compileModule returns the CPU oracle: the same IR evaluated in JavaScript with every value a double, which gives you the mathematically intended answer to compare a GPU run against.
  • reflect returns the metadata a host needs to build the pipeline: bind groups, the std140 and std430 byte layouts a uniform or storage buffer has to match, vertex attributes and entry signatures.
import {
fn,
module,
abs,
length,
f32T,
vec2fT,
emitModule,
compileModule,
reflect,
} from 'typeshade'
// One helper. The return type is inferred from the value the body returns.
const ringMask = fn('ring_mask', { uv: vec2fT, radius: f32T }, (p) =>
abs(length(p.uv).sub(p.radius)),
)
const m = module({ funcs: [ringMask] })
const wgsl = emitModule(m) // WGSL source, as a string
const cpu = compileModule(m) // cpu.fns.ring_mask([0.3, 0.4], 0.5) === 0
const meta = reflect(m) // bind groups, layouts, entry signatures

emitModule(m) gives you this:

fn ring_mask(uv: vec2<f32>, radius: f32) -> f32 {
return abs((length(uv) - radius));
}

The names you passed survive into the output, the parameter types became WGSL types, and the return type came from the body. The optimizer adds bindings of its own where a subexpression repeats, so an emitted body can carry a name you did not write.

Importing

Author from the package root. It re-exports the whole authoring and emit surface: the IR, the layout declarators, the WGSL and GLSL backends, the validator, the CPU oracle and reflect.

import { fn, module, vec4, If, Switch, when, emitModule, reflect } from 'typeshade'
import {
ioStruct,
uniformStruct,
structDecl,
builtin,
location,
storageBuffer,
resource,
} from 'typeshade'

Other subpaths carry surface you do not need in order to author and emit, and an import you never write costs nothing in your bundle:

  • typeshade/dev has the development tooling: lint reports, optimizer measurement and source locations in errors.
  • typeshade/emit-prod has the ship-time text plugins that mangle, minify and obfuscate the emitted source.
  • typeshade/compute has the runner that dispatches a portable compute kernel on whichever backend the host has.
  • typeshade/debug steps one invocation of a "use typeshade" shader on the CPU oracle: it stops at each statement the author wrote, reports the source span and the frame’s locals, and resolves breakpoints by line. It also carries the launch configuration that describes such a run as data, keyed by what the entry declares rather than by argument position, with a baked JSON Schema for a launch.json and a formatter that renders a value in the shader type its author wrote. It is what an editor’s debug adapter and the Playground’s step panel are both built on. See docs/debugging.md.
  • typeshade/vite has the Vite plugin through which an ordinary host file imports a .shade.ts and calls its helper functions, which run on the CPU. See Calling a module’s helpers from host code.

This package ships the authoring surface, and the small runtime a host import runs on. The shaders themselves live in your repository and import the package like any other dependency.

How this guide is ordered

The pages are in learning order, one topic each, and reading them in order the first time is the shortest path. After that, each page stands on its own. Per-function detail lives on that function’s reference page, which every first mention here links to.

The pages that follow, in order:

  • Your first shader: write a module with a vertex and a fragment entry point, and emit it for both targets.
  • Values and mutation: make a value, give it a type, and change it.
  • Functions and entry points: declare a helper, an entry point, and the module that carries them.
  • Control flow: branch, loop, dispatch on an integer and return early.
  • Layouts and resources: declare an IO struct, a uniform block, a storage buffer or a texture once, and read the fields back with types.
  • Emitting and reflection: emit WGSL and GLSL, and read the pipeline metadata a host binds against.
  • The CPU oracle: run the same module in double precision and compare its numbers against a GPU run.
  • Diagnostics: read a coded error, and collect every failure in a module into one report.
  • Conditional programs: build one specialized program per feature combination.
  • Capabilities & extensions: declare the GPU features a module’s emit depends on, and check a booted device against them.
  • fp64: get double precision on hardware that has only floats.
  • GLSL float precision: emit a GLSL stage at mediump, and see what that changes in the source.
  • Production emit: mangle, minify and obfuscate the shipped source, and read a driver log back through the renaming.
  • Raw statements: splice a hand-written statement into a module, and know what it costs on each target.
  • Migrating a GLSL shader: look up the GLSL construct in front of you and see how it is spelled here.

If you have never used this package, start with the next page. If you are here to port a shader you already have, read Migrating a GLSL shader first and follow its links back.

Edit this page Report a problem