Skip to content

Repository files navigation

BroMetal

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 and brometal/shader-functions are stable-by-intent.

Quick start

npm install brometal

1. 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 build

Each 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 — leave npx brometal dev running in a second terminal.

Where to look next

  • examples/ in this package — every example from the website, as real source. The shaders/ 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.

Why not three.js?

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.

Sizing the canvas

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 is inline, 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. Setting width="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.

Camera

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.

Geometries

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.

Prebuilt shaders

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 it

Every prebuilt targets a fullscreen quad (aPosition/aUv from createPlane({ width: 2, height: 2 })) with uTime/uAspect uniforms; image effects add a uTex sampler.

Shader functions

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.

Textures and lighting

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.

Instancing

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.

Render targets, physics and shadows

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.

Website & 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 package

Example 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.

Deploying to Vercel

  1. Import the GitHub repo in Vercel and set Root Directory to packages/website — everything else is auto-detected (vercel.json + the vercel-build script).
  2. 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 brometal delivers, and the CLI gets exercised in CI on every deploy.
  3. npm run release handles the version handoff automatically: after publishing it updates brometal-published in 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.

What the DSL supports (MVP)

  • Types: float, vec2, vec3, vec4, mat4, sampler2D (uniforms only for mat4/sampler2D)
  • Per-vertex attributes and per-instance instanceAttributes
  • const and mutable let locals, float arithmetic (+ - * /), compound assignment (+= -= *= /=, x++), comparisons, if/else
  • for loops 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 like vec4(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.

js13k builds

brometal prod --js13k swaps the runtime for one sized to a 13 kB budget:

npx brometal prod --js13k        # → dist/brometal.js + dist/shaders.js

You 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.

Starter

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.html

Everything 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.

Handling failure

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.

WebGPU

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
Loading

Everything above the line happens once, on your machine. The browser receives finished shader text and the runtime — never the compiler.

Compiled, not configured

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 calls getAttribLocation.
  • 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=mediump for mobile-leaning targets (default highp).

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.

Repo layout

packages/brometal/  # the npm package: compiler, CLI, WebGPU runtime, camera, textures, mat4 math
packages/website/   # Next.js site (brometal.dev): homepage + all example pages

Development

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 pixels

npm 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.

About

BroMetal is an LLVM-inspired compiler that transforms TypeScript into highly optimized GPU shaders for WebGPU. Write GPU code in TypeScript, compile to WGSL, and eliminate shader boilerplate

Topics

Resources

Stars

102 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages