Write TypeScript. Lift Shaders. Ship Shredded.
Shaders written as typed TypeScript, compiled ahead of time to WGSL, with a WebGPU runtime to drive them. No shader compiler ships to the browser — the runtime is ~19 KB minified, 7 KB gzipped.
brometal.dev · npm · Discord
Pre-1.0. Minor versions may break; every change is in CHANGELOG.md. The
shader()DSL andbrometal/shader-functionsare stable-by-intent.
npm install brometal1. Write a shader as plain TypeScript in a *.shader.ts file:
// src/shaders/cube.shader.ts
import { shader, vec4 } from 'brometal';
export const Cube = shader({
attributes: { aPosition: 'vec3', aColor: 'vec3' },
uniforms: { uMvp: 'mat4' },
varyings: { vColor: 'vec3' },
vertex({ aPosition, aColor }, { uMvp }, v) {
v.vColor = aColor;
return uMvp.mul(vec4(aPosition, 1));
},
fragment(_uniforms, { vColor }) {
return vec4(vColor, 1);
},
});2. Compile it. This step is not optional:
npx brometal dev # compile every *.shader.ts, then watch for changes
npx brometal prod # one-shot optimized buildEach name.shader.ts produces a sibling name.shader.gen.ts — a dependency-free
module holding the finished WGSL plus typed interface metadata.
3. Import the generated module — never the source:
import { createRenderer, createProgram, mat4 } from 'brometal';
import cubeShader from './shaders/cube.shader.gen'; // .gen, not .shader
const renderer = await createRenderer(canvas); // WebGPU; throws where unavailable
const program = createProgram(renderer, cubeShader);
program.attributes.aPosition.set(positions); // Float32Array
program.attributes.aColor.set(colors);
program.setIndices(indices);
renderer.loop((t) => {
program.uniforms.uMvp.set(mat4.multiply(projection, mat4.multiply(view, mat4.rotationY(t))));
program.draw();
});If you edit a shader and nothing changes on screen, you did not recompile. Nothing at runtime reads
.shader.ts. This is the single most common problem people hit — leavenpx brometal devrunning in a second terminal.
examples/in this package — every example from the website, as real source. Theshaders/folder there is the fastest way to learn the DSL, and it is what an AI coding agent should read before writing BroMetal code.AGENTS.md— the DSL rules, and the failure modes that are silent. Worth reading before your first non-trivial shader.- brometal.dev/examples — the same examples, running.
Different tool for a different job. three.js is a scene graph with materials, loaders and a renderer; BroMetal is a shader compiler with a thin runtime. Reach for three.js when you want a scene assembled for you. Reach for BroMetal when the shader is the thing you are building.
| BroMetal | three.js | |
|---|---|---|
| Shaders | Typed TypeScript, checked at build time | GLSL/TSL strings |
| Shader compilation | Ahead of time, on your machine | In the browser, at startup |
| Runtime size | ~19 KB min / 7 KB gzip | ~600 KB min |
| Backends | WebGPU | WebGL and WebGPU renderers |
| Scene graph | None — you own the draw loop | Yes |
The practical difference is where errors surface. A typo in a GLSL string is a console message at runtime, or a black screen with no message at all. In BroMetal it fails the build, with a file, line and column.
Compiling ahead of time also means a page with many shaders starts instantly rather than pausing while the driver works through them.
Do not set the width/height attributes. BroMetal never reads them and
owns the drawing buffer; you own the CSS. (The npm README has the same guidance
with a React example.)
<div id="stage"><canvas id="gl"></canvas></div>#stage { width: 100%; height: 100vh; } /* the container has a size */
#stage canvas {
display: block; /* canvas is inline by default */
width: 100%; height: 100%;
min-width: 0; min-height: 0; /* let it shrink inside flex/grid */
}That is the whole contract. A ResizeObserver tracks the CSS box, the drawing
buffer follows it at the device pixel ratio, and renderer.aspect stays
correct — no resize handler to write, no setSize to call. Flex and grid
containers work; so does anything else, as long as the container has a size
of its own.
Two footguns the CSS above disarms, both of which come from the canvas being a replaced element:
display: block— a canvas isinline, so it sits on a text baseline and leaves a few stray pixels beneath it.min-width: 0; min-height: 0— a flex item will not shrink below its automatic minimum size, which for a canvas is its intrinsic size. Settingwidth="800"therefore plants an 800px floor in the layout algorithm and the canvas overflows its flex container instead of fitting it. This is the clearest reason not to use the attributes at all.
If a canvas has no CSS size, its layout box is derived from the drawing buffer, and matching one to the other would feed output back into input — the buffer doubles every frame on a high-DPI screen, or latches to 1×1 after a single zero-width read. BroMetal detects that case, leaves the buffer exactly as authored, and warns once naming the fix. It renders; it just will not fill its container or sharpen on a retina display until you give it CSS.
Everything is typed end-to-end: the attribute/uniform records in shader() drive the WGSL declarations, the generated metadata, and the program.attributes.* / program.uniforms.* accessors — a typo'd uniform name is a compile error in your app, and the compiler enforces the varyings contract (vertex must write every varying) with file:line:col diagnostics.
createCamera gives you a positionable, rotatable camera that compiles down to a single mat4 uniform:
const camera = createCamera({ position: [0, 0, 6] });
camera.setPosition(x, y, z);
camera.setRotation(rx, ry, rz); // radians, applied yaw (Y) → pitch (X) → roll (Z)
camera.lookAt(x, y, z); // aim at a world position
camera.setLens({ fovY, near, far });
renderer.loop(() => {
program.uniforms.uViewProj.set(camera.viewProjection(aspect));
program.draw();
});The view-projection matrix is cached against position, rotation, lens, and aspect — an unmoved camera costs zero matrix math per frame, and nothing allocates. The GPU sees one mat4 regardless of how the camera moves; all per-vertex transformation stays in the shader.
The classic parametric shapes ship built in, each producing positions, normals, UVs, and indices ready for the runtime:
import { createCube, createSphere, createTorusKnot } from 'brometal';
const sphere = createSphere({ radius: 1.3, widthSegments: 32, heightSegments: 16 });
program.attributes.aPosition.set(sphere.positions);
program.attributes.aNormal.set(sphere.normals);
program.attributes.aUv.set(sphere.uvs);
program.setIndices(sphere.indices);Available: createCube, createSphere, createPlane, createCylinder, createCone, createTorus, createTorusKnot, createCircle, createRing — Three.js-style parameters, CCW winding validated by tests, automatic 16/32-bit index selection.
brometal/shaders ships 30 complete, ready-to-draw shaders — fire, caustics, domain warp, a raymarched scene, CRT/glitch/halftone image effects, and more — precompiled at package build time. Zero shader compilation happens in your app:
import { createRenderer, createProgram, createPlane } from 'brometal';
import { fireShader } from 'brometal/shaders';
const renderer = await createRenderer(canvas);
const program = createProgram(renderer, fireShader);
// set a fullscreen quad + uTime/uAspect per frame — that's itEvery prebuilt targets a fullscreen quad (aPosition/aUv from createPlane({ width: 2, height: 2 })) with uTime/uAspect uniforms; image effects add a uTex sampler.
brometal/shader-functions ships a curated library of typed GPU functions — noise, hash, easing, color, lighting, and 2D SDFs — that inline into your shader at build time. Import them like any TypeScript function:
import { shader, vec2, vec3, vec4 } from 'brometal';
import { fbm2, cosinePalette } from 'brometal/shader-functions';
export const Noise = shader({
// ...
fragment({ uTime }, { vUv }) {
const n = fbm2(vUv.scale(4).add(vec2(uTime, 0)), 5);
return vec4(cosinePalette(n, a, b, c, d), 1);
},
});The compiler resolves imports (and their dependencies — fbm2 pulls in vnoise2 and hash21 automatically), type-checks every call against the library signatures, and emits only the functions each stage actually uses. Nothing ships at runtime; it's tree-shaken shader text.
Included: hash11 hash21 hash22 hash31 · vnoise2 gnoise2 fbm2 turbulence2 warp2 voronoi2 worleyEdge2 curl2 vnoise3 fbm3 · remap smootherstep rotate2 · easings (quad/cubic/sine/expo/back/elastic/bounce families) · luminance rgb2hsv hsv2rgb cosinePalette adjustSaturation brightnessContrast blendScreen blendOverlay tonemapACES tonemapReinhard gammaCorrect filmGrain · lambert blinnPhongSpec specGGX fresnel toonShade hemisphereLight · sdCircle sdBox2 sdRoundedBox2 sdHexagon sdSegment2 smoothUnion smoothSubtract smoothIntersect fillAA strokeAA · sdSphere3 sdBox3 sdTorus3 sdCapsule3 sdOctahedron3 sdPlane3
Because every function is typed and compile-checked, they're also ideal building blocks for AI coding agents: an agent composing known-good primitives with signatures it cannot violate beats one hand-deriving noise math every time.
Declare a sampler2D uniform and sample it with the texture() intrinsic; light sources are just uniforms your shader math consumes (the full Blinn-Phong lighting model is expressible in the DSL — see the textures-with-light example):
export const Lit = shader({
attributes: { aPosition: 'vec3', aNormal: 'vec3', aUv: 'vec2' },
uniforms: { uViewProj: 'mat4', uLightPos: 'vec3', uTex: 'sampler2D' },
varyings: { vNormal: 'vec3', vUv: 'vec2' },
// ...
fragment({ uLightPos, uTex }, { vNormal, vUv }) {
const diffuse = max(dot(normalize(vNormal), normalize(uLightPos)), 0);
return vec4(texture(uTex, vUv).xyz.mul(0.25 + diffuse), 1);
},
});Texture units are assigned by the compiler and baked into the layout, so the runtime sets each sampler uniform exactly once at link time — program.uniforms.uTex.set(texture) only binds. Load textures with loadTexture(renderer, url) (mipmaps and sensible filtering by default) or wrap any TexImageSource with createTexture.
Declare per-instance inputs with instanceAttributes — they upload to the GPU once and advance per instance, not per vertex:
export const Instanced = shader({
attributes: { aPosition: 'vec3', aColor: 'vec3' },
instanceAttributes: { iOffset: 'vec3', iAxis: 'vec3', iSpeed: 'float' },
uniforms: { uViewProj: 'mat4', uTime: 'float' },
// vertex() receives attributes and instance attributes together
});When a shader declares instance attributes, program.draw() automatically uses instanced rendering. The lots-of-cubes example renders 125,000 independently tumbling cubes in one draw call — each cube's rotation is computed in the vertex shader from a single uTime float, so the per-frame CPU→GPU traffic is one mat4 and one float, total.
createRenderTarget(renderer, { width, height, depth }) is an off-screen surface a program draws into and any shader can sample — a second pass reading what the first one wrote, with nothing round-tripping through the CPU. renderer.drawTo(target, fn, { clear }) points drawing at it.
const shadowMap = createRenderTarget(renderer, { width: 1024, height: 1024, depth: true });
renderer.drawTo(shadowMap, () => depthProgram.draw(), { clear: [1, 1, 1, 1] });
sceneProgram.uniforms.uShadowMap.set(shadowMap.texture);depth: true attaches a depth buffer so the off-screen pass is depth-tested — a shadow map must record the nearest surface to the light, not the last triangle drawn. clear matters wherever zero is a meaningful value rather than an empty one; a distance map cleared to black claims an occluder at the light in every texel the geometry missed.
To sample a target projectively, use the targetUv(clipPosition) intrinsic rather than clip.xy / clip.w * 0.5 + 0.5. The backends disagree about which row of a target NDC +y lands on, so the hand-rolled version is vertically mirrored on one of them — and a mirrored shadow still looks like a shadow, just attached to the wrong side of the object.
The same machinery is what runs simulation on the GPU: state lives in a target, a fragment pass advances it, and the render pass reads positions straight out of it in the vertex shader. See the Ball Physics and Shadow examples.
The examples live as pages of the BroMetal website (packages/website, Next.js):
npm install
npm run build # build the brometal package
npm run dev:website # → http://localhost:3005 (uses the LOCAL workspace package)
npm run prod:website # → production build against the PUBLISHED npm packageExample pages: /examples/rotating-cube, /examples/lots-of-cubes, /examples/camera, /examples/light, /examples/textures, /examples/geometries, /examples/custom-shader, /examples/shader-library, /examples/shader-functions, /examples/terrain, /examples/ocean, /examples/brocraft, /examples/ball-physics, /examples/star-bro.
dev bundles the local packages/brometal source; prod sets BROMETAL_SOURCE=npm, which aliases every brometal import to the published registry package — so the production build exercises exactly what npm users install. A preflight gate compares the published package's export surface against the local one and fails the build if the registry is behind (webpack would otherwise only warn and ship a runtime-broken bundle). To iterate on shaders, run npm run shaders:watch in packages/website alongside the dev server.
- Import the GitHub repo in Vercel and set Root Directory to
packages/website— everything else is auto-detected (vercel.json+ thevercel-buildscript). - Each deploy builds the workspace compiler, runs the publish preflight, prod-compiles the shaders, and builds Next against the published npm package — so brometal.dev always demos exactly what
npm install brometaldelivers, and the CLI gets exercised in CI on every deploy. npm run releasehandles the version handoff automatically: after publishing it updatesbrometal-publishedin the website workspace and commits + pushes the lockfile, so the next Vercel deploy builds against the fresh release. If the site ever uses features not yet published, the preflight fails the deploy with instructions instead of shipping a broken page.
- Types:
float,vec2,vec3,vec4,mat4,sampler2D(uniforms only format4/sampler2D) - Per-vertex
attributesand per-instanceinstanceAttributes constand mutableletlocals, float arithmetic (+ - * /), compound assignment (+= -= *= /=,x++), comparisons,if/elseforloops with float counters —for (let i = 0; i < n; i += 1)- Module-level helper functions with typed signatures (
function palette(t: number): Vec3), compiled to WGSL functions; helpers can call earlier helpers - Vector methods
.add() .sub() .mul() .div() .scale(),mat4.mul(), swizzles (.x,.xyz, …) - Constructors
vec2/vec3/vec4(composite forms likevec4(v3, 1)included) - Intrinsics:
texture reflect normalize dot cross mix clamp length distance sin cos tan asin acos atan abs sign fract floor sqrt pow exp exp2 log mod step smoothstep min max
Anything outside the subset fails compilation with a precise, actionable error.
brometal prod --js13k swaps the runtime for one sized to a 13 kB budget:
npx brometal prod --js13k # → dist/brometal.js + dist/shaders.jsYou get global functions rather than modules — bmInit, bmProgram, bmAttr,
bmTexture, bmDraw, bmLoop, plus mat4 helpers and a matrix stack. Textures,
instancing, alpha blending, depth control and compute are in; validation, error
messages, pipeline caching and uniform rings are out.
Compute is bmCompute / bmStore / bmStorages / bmDispatch. A storage
buffer written by a compute program and bound read-only to one that draws is how
state gets from one to the other — there is no readback in this runtime.
Post-processing is bmTarget(w, h) and bmPassTo(target). Targets are
rgba16float, so a bright-pass can find what came out brighter than white;
programs drawing into one take fmt: 1, and a target binds back through
bmTextures like any other texture.
Both files are source. Concatenate them with your game and minify the whole program in one pass:
cat dist/brometal.js dist/shaders.js game.js > out.js
terser out.js --compress --mangle --toplevel -o out.min.js
# then inline out.min.js into index.html and zip that one file--toplevel matters: without it the API names survive at full length. A
prebuilt bundle could not be mangled jointly with your code at all, which is why
this ships as source.
Measured on a demo using two programs, a texture, instancing and a transparent pass: ~3 KB gzipped for runtime, shaders and game together.
Inline the script rather than shipping it beside the page: a zip charges per
member for its header and directory record, so one file beats two by roughly 150
bytes. The starter's build.mjs does this for you.
templates/js13k is a working entry — spinning textured cube, 2,989 bytes
zipped, leaving 10,323 bytes for your game:
cd templates/js13k && npm install && npm run build
open dist/index.htmlEverything is inlined into that single file, so it opens straight from disk —
file:// is a secure context and WebGPU works there. The build refuses to finish
over 13,312 bytes and prints what is left.
The runtime draws nothing when it fails — no message painted into your canvas, no DOM touched. Showing a problem is the application's job, so what you get is a failure you can catch and identify.
import { createRenderer, isBroMetalError } from 'brometal';
try {
const renderer = await createRenderer(canvas, {
// Fires for failures that happen *after* creation — a lost device, or a
// pipeline that failed validation. These cannot be caught: they arrive
// frames later with none of your code on the stack.
onError: (error) => showToast(error.code, error.message),
});
} catch (error) {
if (isBroMetalError(error) && error.code === 'webgpu-unavailable') {
showUpgradePrompt();
}
}| code | means |
|---|---|
webgpu-unavailable |
no navigator.gpu — the browser has no WebGPU |
gpu-adapter-unavailable |
WebGPU exists but no GPU was granted (VM, remote desktop, acceleration off) |
gpu-device-unavailable |
an adapter was granted but the device request failed |
canvas-context-unavailable |
the canvas would not return a WebGPU context, usually because it already has another kind |
gpu-device-lost |
the device died after creation; nothing will draw again |
gpu-error |
an uncaptured GPU error — failed validation, or out of memory |
Each code reads as the sentence a user should see: errorTitle(code) turns
gpu-adapter-unavailable into "GPU adapter unavailable". The label is derived
rather than looked up, so a code and its wording cannot drift apart.
Wiring onError is worth it even if you only log. WebGPU reports most problems
asynchronously, so an invalid pipeline draws nothing and a lost device stops
producing frames — both with no exception raised anywhere. Without a handler the
runtime warns once to the console, which is a last resort rather than a feature.
Every shader compiles to WGSL. There is no second backend: WebGL2 support was dropped in 0.14 because the features worth building on — compute shaders and storage buffers, chiefly — have no WebGL2 equivalent, and supporting both meant every feature had to be expressible in the older API.
const renderer = await createRenderer(canvas); // throws if WebGPU is missing
const program = createProgram(renderer, cubeShader);
// transparency: createProgram(renderer, s, { blend: 'alpha' | 'additive' })WebGPU ships in Chrome and Edge 113+, Firefox 141+, and Safari 26+. Where it is
absent, createRenderer rejects with a message naming the requirement rather
than degrading quietly.
The compiler absorbs the platform details at build time: WGSL uniform blocks with correct alignment offsets, texture/sampler binding pairs, and the clip-space depth remap.
flowchart TD
subgraph BUILD ["Build time — npx brometal"]
TS[TypeScript] --> Parser
Parser --> TC[Type Checker]
TC --> SA[GPU Semantic Analysis]
SA --> IR[GPU IR]
IR --> OPT[Optimization Passes]
OPT --> WGSL
end
subgraph RUN ["Runtime — browser"]
WebGPU
end
WGSL --> WebGPU
style BUILD fill:none,stroke:#888,stroke-width:1.5px
style RUN fill:none,stroke:#888,stroke-width:1.5px
Everything above the line happens once, on your machine. The browser receives finished shader text and the runtime — never the compiler.
BroMetal's spirit is to decide everything it can at compile time, so the runtime executes a precomputed plan:
- Attribute locations are assigned by the compiler (
layout(location = N)) and baked into the generated module — the runtime never callsgetAttribLocation. - Buffer layout (component sizes, instancing divisors) and uniform upload routines are chosen at compile time and shipped as metadata.
- Unused attributes, uniforms, and varyings are compile-time warnings, not runtime surprises; never-read varyings are stripped from prod builds along with the vertex code that fed them.
- Fragment precision is a build flag:
npx brometal prod --precision=mediumpfor mobile-leaning targets (defaulthighp).
At runtime, the hot path is equally lean: pipelines and bind groups are built once and reused, resize handling is ResizeObserver-driven so the frame loop never reads DOM layout, createRenderer requests the high-performance GPU, opt-in back-face culling (cull: 'back') halves fragment work for closed meshes, and every mat4 function takes an optional out matrix so render loops allocate nothing.
packages/brometal/ # the npm package: compiler, CLI, WebGPU runtime, camera, textures, mat4 math
packages/website/ # Next.js site (brometal.dev): homepage + all example pages
npm run build # compile the package (tsc)
npm test # vitest: compiler goldens, analyzer errors, optimizer, math, CLI
npm run typecheck # strict tsc across package + example
npm run test:gpu # real GPU: drives Chrome and WebKit, asserts on pixelsnpm test runs in node and can only check the text the compiler emits. It
cannot see an invalid bind group, a pipeline that fails to create, or a uniform
that is never uploaded — four such bugs once shipped past a green suite.
test:gpu covers that, driving the system Chrome for the real WebGPU path and
Playwright's WebKit for the no-WebGPU path. WebKit needs a one-time
npx playwright-core install webkit; Chrome is used from the machine, not
downloaded.
Note what test:gpu does not cover: Safari's WGSL validation is stricter
than Chrome's, and a shader Chrome accepts can be rejected there — which shows
up as a pass that draws nothing. Playwright cannot drive real Safari, and its
WebKit build ships no WebGPU, so that gap needs a manual check on a real
Safari.