Shader Builder
The rendering/webgpu/shaders module in @alleninstitute/vis-core lets you build WGSL
shaders as data instead of hand-writing source strings. You assemble a shader from small,
composable declarations — structs, uniforms, textures, functions, and entry points — and the
module generates the WGSL text for you.
This page walks from the simplest possible shader up to a dynamic, config-driven renderer. None of it runs on this page; every snippet is copy-pasteable and the Generated WGSL panels show exactly what each builder produces.
The mental model
Section titled “The mental model”Three ideas are all you need:
- Every declaration knows how to render itself. Each builder returns an object with a
gen()method that emits its own slice of WGSL. - A shader is just a list of declarations.
shader([...])stamps the list with a stableidand hands you back aWgslShader. asSource()turns it into text. It callsgen()on each declaration and joins the results with newlines.
import { decls, attrs, types, shader, asSource, vertexInput } from '@alleninstitute/vis-core';Three “buckets” hold the constructors:
decls— declarations:struct,member,uniform,texture,sampler,storage,func,param,returns,constant,vertexEntry,fragmentEntry,computeEntry, and more.attrs— attributes:builtin,location,interpolate,align,size,invariant,blendSrc, and the rest of the WGSL@attributeset.types— types: the singletonsf32,u32,vec2f,vec4f,mat4x4f, … plus the constructorsvec,mat,texture,sampler,fixedArray,runtimeArray,storageTexture, andatomic.
The top-level shader, asSource, and vertexInput helpers are imported directly (they are not
part of any bucket). Anywhere a type is expected you may pass either a types value or a plain
WGSL type string — types.vec4f and 'vec4f' generate identical output — so the two are freely
interchangeable. The snippets below prefer the types constructors, as they provide additional
type-safety in TypeScript code and validate their inputs at runtime — see
Type safety for exactly what is and isn’t checked.
Import styles
Section titled “Import styles”The buckets are a convenience, not a requirement — every constructor is also a top-level export. Pick whichever style reads best; they are fully interchangeable and the examples below deliberately mix all three.
-
Buckets keep the import list short and make call sites self-documenting (
decls.struct,attrs.location,types.vec4f):import { decls, attrs, types } from '@alleninstitute/vis-core'; -
Direct named imports pull in only the constructors you actually use, so call sites lose the bucket prefix entirely:
import { struct, member, uniform, location, vec2f, vec4f } from '@alleninstitute/vis-core'; -
Aliased buckets rename the buckets to whatever format works best for your code; for example, a single letter alias allows for terse, dense call sites: (
d.struct,a.location,t.vec4f):import { decls as d, attrs as a, types as t } from '@alleninstitute/vis-core';
One caveat for direct imports: the bare names texture and sampler are the declaration
constructors (equivalent to decls.texture / decls.sampler). The matching WGSL type
constructors live only on the bucket — reach for types.texture(...) / types.sampler (or an
aliased t.texture(...)) to avoid the name clash.
Level 1 — A solid-color triangle
Section titled “Level 1 — A solid-color triangle”The smallest useful shader: a vertex stage that positions three hard-coded clip-space points and a fragment stage that paints them a flat color. No inputs, no uniforms.
import { decls, attrs, types, shader, asSource } from '@alleninstitute/vis-core';
// A module-scope constant holding the three clip-space corners.const positions = decls.constant( 'positions', 'array<vec2f, 3>(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5))', types.fixedArray(types.vec2f, 3));
// @vertex: read the builtin vertex index, look up the position, emit clip coordinates.const vs = decls.vertexEntry( 'vs_main', [decls.param('vertexIndex', types.u32, [attrs.builtin('vertex_index')])], () => 'return vec4f(positions[vertexIndex], 0.0, 1.0);', decls.returns(types.vec4f, [attrs.builtin('position')]));
// @fragment: return a constant color.const fs = decls.fragmentEntry( 'fs_main', [], () => 'return vec4f(1.0, 0.4, 0.1, 1.0);', decls.returns(types.vec4f, [attrs.location(0)]));
const triangle = shader([positions, vs, fs]);const wgsl = asSource(triangle);Generated WGSL
const positions: array<vec2f, 3> = array<vec2f, 3>(vec2f(0.0, 0.5), vec2f(-0.5, -0.5), vec2f(0.5, -0.5));@vertex fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> @builtin(position) vec4f { return vec4f(positions[vertexIndex], 0.0, 1.0); }@fragment fn fs_main() -> @location(0) vec4f { return vec4f(1.0, 0.4, 0.1, 1.0); }Notice how each builder maps directly onto a WGSL construct: attrs.builtin('vertex_index') becomes
@builtin(vertex_index), decls.returns(types.vec4f, [attrs.builtin('position')]) becomes the
-> @builtin(position) vec4f return clause, and the entry-point stage attribute (@vertex /
@fragment) is added for you by vertexEntry / fragmentEntry.
Level 2 — Structs and uniforms
Section titled “Level 2 — Structs and uniforms”Most shaders take data from the host. Declare a struct for your uniform block, bind it with
uniform(name, type, group, binding), and reuse struct declarations anywhere a type is
expected — an inter-stage VsOut struct, for example. This example uses direct named imports,
so the constructors appear without a bucket prefix.
import { struct, member, uniform, func, param, returns, builtin, location, vec2f, vec4f, shader, asSource,} from '@alleninstitute/vis-core';
const Camera = struct('Camera', [ member('view', vec4f), // xy = min corner, zw = max corner]);
const VsOut = struct('VsOut', [ member('position', vec4f, [builtin('position')]), member('color', vec4f, [location(0)]),]);
// group 0, binding 0const camera = uniform('camera', Camera, 0, 0);
// A reusable helper function.const applyCamera = func( 'applyCamera', [param('dataPos', vec2f), param('view', vec4f)], () => 'let size = view.zw - view.xy; let unit = (dataPos - view.xy) / size; return vec4f(unit * 2.0 - 1.0, 0.0, 1.0);', returns(vec4f));
const program = shader([Camera, VsOut, camera, applyCamera]);const wgsl = asSource(program);Generated WGSL
struct Camera { view: vec4f }struct VsOut { @builtin(position) position: vec4f, @location(0) color: vec4f }@group(0) @binding(0) var<uniform> camera: Camera;fn applyCamera(dataPos: vec2f, view: vec4f) -> vec4f { let size = view.zw - view.xy; let unit = (dataPos - view.xy) / size; return vec4f(unit * 2.0 - 1.0, 0.0, 1.0); }Because Camera and VsOut are ordinary values, you can pass them anywhere a type identifier is
accepted — as the uniform’s type, as a function parameter type, or as an entry point’s return
type — and the generator emits the struct’s name.
Level 3 — Declarative vertex inputs
Section titled “Level 3 — Declarative vertex inputs”Buffer-backed vertex attributes have rules: every leaf needs exactly one @location or
@builtin, and locations must be unique. vertexInput(...) validates all of that up front and
hands back the classified attributes plus a convenience entry(...) for the vertex stage.
import { decls, attrs, types, shader, asSource, vertexInput } from '@alleninstitute/vis-core';
const Vertex = decls.struct('Vertex', [ decls.member('position', types.vec2f, [attrs.location(0)]), decls.member('color', types.vec4f, [attrs.location(1)]),]);
const input = vertexInput([Vertex]);
// input.attributes -> [{ name: 'position', location: 0, wgslType: 'vec2f', struct: 'Vertex' },// { name: 'color', location: 1, wgslType: 'vec4f', struct: 'Vertex' }]// Use these to build your GPUVertexBufferLayout without repeating yourself.
const VsOut = decls.struct('VsOut', [ decls.member('position', types.vec4f, [attrs.builtin('position')]), decls.member('color', types.vec4f, [attrs.location(0)]),]);
const vs = input.entry( 'vs_main', () => 'var out: VsOut; out.position = vec4f(v.position, 0.0, 1.0); out.color = v.color; return out;', decls.returns(VsOut));
// Drop the interface's struct declarations in alongside your own.const program = shader([...input.structs, VsOut, vs]);const wgsl = asSource(program);Generated WGSL
struct Vertex { @location(0) position: vec2f, @location(1) color: vec4f }struct VsOut { @builtin(position) position: vec4f, @location(0) color: vec4f }@vertex fn vs_main(v: Vertex) -> VsOut { var out: VsOut; out.position = vec4f(v.position, 0.0, 1.0); out.color = v.color; return out; }If two members claim the same @location, or a leaf has neither a @location nor a @builtin,
vertexInput(...) throws with a message pointing at the offending field — so mistakes surface at
build time rather than as a silent pipeline error.
Level 4 — Textures and samplers
Section titled “Level 4 — Textures and samplers”Sampled resources follow the same pattern as uniforms: declare them with a group/binding and use them by name in a function body.
import { decls, attrs, types, shader, asSource } from '@alleninstitute/vis-core';
const albedo = decls.texture('albedo', types.texture('2d', 'f32'), 0, 0);const albedoSampler = decls.sampler('albedoSampler', types.sampler, 0, 1);
const fs = decls.fragmentEntry( 'fs_main', [decls.param('uv', types.vec2f, [attrs.location(0)])], () => 'return textureSample(albedo, albedoSampler, uv);', decls.returns(types.vec4f, [attrs.location(0)]));
const program = shader([albedo, albedoSampler, fs]);const wgsl = asSource(program);Generated WGSL
@group(0) @binding(0) var albedo: texture_2d<f32>;@group(0) @binding(1) var albedoSampler: sampler;@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f { return textureSample(albedo, albedoSampler, uv); }Level 5 — Config-driven shaders
Section titled “Level 5 — Config-driven shaders”The real payoff: because declarations are plain data, you can generate them. Here a single
function turns a runtime configuration into a complete shader whose vertex layout, uniform block,
and body all adapt to the list of columns you pass in. Generator code tends to repeat the bucket
name on every line, so this example aliases the buckets to d, a, and t to keep the
call sites tight.
import { decls as d, attrs as a, types as t, shader, asSource, type WgslShader } from '@alleninstitute/vis-core';
type PointConfig = { /** Name of the vec2 position column. */ position: string; /** Quantitative columns, each gets a vertex attribute and a `<col>_range` uniform. */ quantitative: string[];};
function buildPointShader(cfg: PointConfig): WgslShader { const Vertex = d.struct('Vertex', [ d.member('vIndex', t.u32, [a.builtin('vertex_index')]), d.member(cfg.position, t.vec2f, [a.location(0)]), ...cfg.quantitative.map((col, i) => d.member(col, t.f32, [a.location(i + 1)])), ]);
const Uniforms = d.struct('Uniforms', [ d.member('view', t.vec4f), ...cfg.quantitative.map((col) => d.member(`${col}_range`, t.vec2f)), ]);
const VsOut = d.struct('VsOut', [ d.member('position', t.vec4f, [a.builtin('position')]), d.member('color', t.vec4f, [a.location(0)]), ]);
return shader([ Vertex, Uniforms, VsOut, d.uniform('unis', Uniforms, 0, 0), d.vertexEntry( 'vs_main', [d.param('v', Vertex)], () => `var out: VsOut; out.position = vec4f(v.${cfg.position}, 0.0, 1.0); out.color = vec4f(1.0); return out;`, d.returns(VsOut) ), d.fragmentEntry('fs_main', [d.param('v', VsOut)], () => 'return v.color;', d.returns(t.vec4f, [a.location(0)])), ]);}
const wgsl = asSource(buildPointShader({ position: 'xy', quantitative: ['size', 'opacity'] }));Generated WGSL (position: ‘xy’, quantitative: [‘size’, ‘opacity’])
struct Vertex { @builtin(vertex_index) vIndex: u32, @location(0) xy: vec2f, @location(1) size: f32, @location(2) opacity: f32 }struct Uniforms { view: vec4f, size_range: vec2f, opacity_range: vec2f }struct VsOut { @builtin(position) position: vec4f, @location(0) color: vec4f }@group(0) @binding(0) var<uniform> unis: Uniforms;@vertex fn vs_main(v: Vertex) -> VsOut { var out: VsOut; out.position = vec4f(v.xy, 0.0, 1.0); out.color = vec4f(1.0); return out; }@fragment fn fs_main(v: VsOut) -> @location(0) vec4f { return v.color; }Variations to the config are reflected directly in the resulting shader: struct fields, binding layout,
and expressions generate in alignment to the config inputs. For a fully worked example that adds categorical
color tables, gradient lookups, spatial filtering, and highlighting on top of this pattern, see
example.test.ts
in the vis repository.
Type safety
Section titled “Type safety”The builder generates WGSL text; it is not a WGSL compiler. Understanding where its guarantees
stop is the difference between catching a mistake at your keyboard and chasing a
createShaderModule error at runtime. Checking happens in three layers:
- Compile-time (TypeScript) — using the
typesbucket limits type data to what WGSL will accept. - Runtime (Zod) — the
typesconstructors re-check their arguments and throw on bad input, even when the compiler was bypassed. - Not checked at all — anything below the level of a single type node: raw type strings, shader body text, host-data correspondence, and WGSL’s own semantic rules.
Layer 1 — What the types bucket checks at compile time
Section titled “Layer 1 — What the types bucket checks at compile time”Every types constructor accepts only the values WGSL actually allows for that position, encoded as
narrow TypeScript unions. The component-type domains are split by role, so illegal combinations
don’t type-check:
vec(size, component)—sizeis2 | 3 | 4;componentexcludesbool(there is novec3<bool>component in a buffer-facing type).mat(cols, rows, component)—cols/rowsare2 | 3 | 4;componentisf32 | f16only.texture(dimension, component)— sampled-texture components aref32 | i32 | u32.atomic(component)—i32 | u32only.- Texture dimension domains differ per texture class: depth textures reject
1d, storage textures rejectcube/cube_array. storageTexture(dimension, format, access)—formatis restricted to the spec’s storage texel formats,accesstoread | write | read_write.
import { types } from '@alleninstitute/vis-core';
types.vec(3, 'f32'); // oktypes.vec(3, 'bool'); // compile error: 'bool' is not a WgslNumericScalarTypetypes.mat(4, 4, 'i32'); // compile error: 'i32' is not a WgslFloatScalarTypetypes.depthTexture('1d'); // compile error: '1d' is not a WgslDepthTextureDimensionThe pre-instantiated singletons (types.vec4f, types.mat4x4f, …) carry their exact variant type,
and composition is checked too: types.fixedArray(types.vec2f, 3) only accepts a real
WgslDataType as its element.
Layer 2 — What the types bucket checks at runtime
Section titled “Layer 2 — What the types bucket checks at runtime”The constructors don’t stop at the type system: scalar, vec, mat, texture, depthTexture,
multisampledTexture, storageTexture, and atomic run their Zod schema on the constructed object
and throw if it’s invalid. This matters precisely when the compiler can’t see the value — a
cast to any, a plain-JavaScript caller, or a value read from untyped/external configuration:
import { types } from '@alleninstitute/vis-core';
types.vec(5 as any, 'f32'); // throws at runtime: 5 is not a valid vec sizetypes.storageTexture('cube' as any, 'rgba8unorm', 'write'); // throws: invalid storage dimensionFor well-typed call sites this is redundant with Layer 1 — but it turns “silently builds a malformed type” into “throws immediately at the call site”, which is the useful behavior when data crosses a boundary the compiler doesn’t cover.
Three cases are trusted by construction and are not re-validated:
fixedArray(element, size)validates onlysize(it must be a positive integer); it does not re-walk theelement, which is assumed to have already come from a validating constructor.runtimeArray(element)performs no runtime check — same rationale.- The pre-instantiated singletons are plain literals authored by the library, so they are trusted as correct.
Attribute constructors (attrs.align, attrs.blendSrc, attrs.builtin, attrs.location, …) and
vertexInput(...) validate at runtime as well, throwing on out-of-range values, unknown builtin
names, duplicate @locations, and leaves that carry neither a @location nor a @builtin.
Layer 3 — What is not checked
Section titled “Layer 3 — What is not checked”Everything below is outside the builder’s reach. None of it produces a TypeScript error or a runtime throw — it surfaces only when the driver compiles the generated WGSL (or worse, as a wrong result).
-
Raw type strings. Anywhere a type is accepted, a plain
stringis also accepted and passed through verbatim. This is the deliberate escape hatch that makestypesopt-in — and it bypasses both checking layers:import { member } from '@alleninstitute/vis-core';member('x', 'vec9000<banana>'); // compiles, runs, and emits invalid WGSL -
Struct ↔ host-data correspondence.
struct<TsShape>(name, fields)accepts a phantom TypeScript shape, but nothing checks that the shape’s keys and types line up with thememberfields you actually declared — the generic and the field list can freely disagree. (The phantom is a hook for a future typed-slot API; in this module it is inert.) -
WGSL semantic and layout rules. Struct
@align/@sizelegality, whetherf16usage requires anenable f16;directive, storage texel formats that need an optional WebGPU feature, the rule that runtime-sized arrays are only valid in storage, texture component/format compatibility, and whether a type is even host-representable (nothing rejects asamplerplaced inside a uniform block) are all unenforced. -
Cross-declaration consistency. The Zod parse validates a single type node’s internal shape. It does not relate declarations to one another — matching group/binding numbers, a uniform’s type agreeing with the resource you bind, and so on are your responsibility.
-
Shader body text. Function bodies are opaque
() => stringthunks. Nothing inside them is parsed or type-checked.
Is it type-safe? A per-part summary
Section titled “Is it type-safe? A per-part summary”| Shader part | Compile-time | Runtime | Notes |
|---|---|---|---|
A types value (e.g. types.vec(...)) | ✅ checked | ✅ throws | narrow domains + Zod parse |
fixedArray size / runtimeArray | ✅ checked | ⚠️ partial | only fixedArray size is parsed; elements trusted |
A raw type string ('vec4f', anything) | ❌ | ❌ | passed through verbatim — the escape hatch |
Attribute values (location, align, …) | ✅ checked | ✅ throws | via attrs constructors |
| Vertex-input wiring | — | ✅ throws | unique @location, exactly one location/builtin |
| Struct ↔ host-data shape | ❌ | ❌ | phantom TsShape, currently inert |
| WGSL semantics / memory layout | ❌ | ❌ | not modeled |
| Cross-declaration consistency | ❌ | ❌ | per-node validation only |
| Shader body source | ❌ | ❌ | opaque () => string |
The practical takeaway: prefer the types constructors over raw strings wherever you can — you get
both layers of checking for free — and treat the remaining rows as your own responsibility, ideally
verified by actually building the pipeline in a test.
Future Developments
Section titled “Future Developments”Future enhancements may improve the overall type safety and validation of the shader builder, at which time this document will be updated to reflect those improvements. However, not all of these limitations can necessarily be addressed, due to the nature of the cross-language/cross-environment boundary between TypeScript and WebGPU/graphics drivers, so some limitations will likely always exist.
Constructor reference
Section titled “Constructor reference”Every constructor below is reachable two ways: on its bucket (decls.struct, attrs.location,
types.vec4f) and as a bare top-level export (struct, location, vec4f) you can import
directly. The one exception is texture / sampler: the bare names are the declaration
constructors, so use types.texture(...) / types.sampler for the type versions.
decls — declaration constructors
Section titled “decls — declaration constructors”| Constructor | Emits |
|---|---|
constant(name, init, type?) | const name: type = init; |
override(name, type?, init?, attrs?) | var<override> name: type = init; |
privateVar(name, type?, init?) | var<private> name: type = init; |
workgroupVar(name, type) | var<workgroup> name: type; |
uniform(name, type, group, binding) | @group @binding var<uniform> name: type; |
texture(name, type, group, binding) | @group @binding var name: type; |
sampler(name, type, group, binding) | @group @binding var name: type; |
storage(name, type, group, binding, access?) | @group @binding var<storage, access> name: type; |
struct(name, members) | struct name { ... } |
member(name, type, attrs?) | a struct field |
param(name, type, attrs?) | a function parameter |
returns(type, attrs?) | a function return clause |
func(name, params, body, ret?, attrs?) | fn name(...) -> ret { ... } |
vertexEntry / fragmentEntry / computeEntry | a func with the stage attribute |
attrs — attribute constructors
Section titled “attrs — attribute constructors”| Constructor | Emits |
|---|---|
builtin(name) | @builtin(name) |
location(n) | @location(n) |
interpolate(type, sampling?) | @interpolate(type, sampling) |
align(n) | @align(n) |
size(n) | @size(n) |
invariant() | @invariant |
blendSrc(0 | 1) | @blend_src(n) |
id(n) | @id(n) |
mustUse() | @must_use |
diagnostic(severity, message) | @diagnostic(severity, "message") |
workgroupSize(x, y?, z?) | @workgroup_size(...) |
types — type constructors and singletons
Section titled “types — type constructors and singletons”Any of these can be passed wherever a WGSL type is expected; they render identically to the equivalent type string.
| Member | Emits |
|---|---|
f32, u32, i32, f16, bool | scalar types |
vec2f … vec4h | vector singletons (vec{2,3,4}{i,u,f,h}) |
mat2x2f … mat4x4h | matrix singletons |
vec(size, component) | vecN<...> from parts |
mat(cols, rows, component) | matCxR<...> from parts |
texture(dimension, component) | texture_<dim><component> |
sampler, sampler_comparison | sampler singletons |
storageTexture(dimension, format, access) | texture_storage_... |
fixedArray(element, size) | array<T, N> |
runtimeArray(element) | array<T> |
atomic(component) | atomic<T> |
For the complete, authoritative surface see the module source under
packages/core/src/rendering/webgpu/shaders.