npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

brometal

v0.18.0

Published

Write shaders in TypeScript, compiled at build time to WGSL, with a tiny WebGPU runtime. Write TypeScript. Lift Shaders. Ship Shredded.

Readme

BroMetal

Write TypeScript. Lift Shaders. Ship Shredded.

BroMetal is LLVM-inspired compiler infrastructure for GPU programming that transforms TypeScript into highly optimized GPU shaders. It compiles a typed TypeScript DSL to WGSL and ships a WebGPU runtime — buffers, uniforms, pipelines, bind groups, and the render loop are all handled for you. await createRenderer(canvas) gives you a WebGPU renderer, and throws where WebGPU is unavailable.

Pre-1.0: BroMetal is evolving fast. Minor versions may include breaking changes — every one is documented in CHANGELOG.md. The shader() DSL and brometal/shader-functions surfaces are stable-by-intent; runtime APIs may still shift until 1.0.

Install

npm install brometal

Write a shader in TypeScript

// 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);
  },
});

Compile it

npx brometal dev    # compile all *.shader.ts and watch for changes
npx brometal prod   # one-shot optimized build (constant folding, dead-varying pruning)

Each name.shader.ts compiles to a sibling name.shader.gen.ts — a dependency-free module with the WGSL plus typed interface metadata. Your app imports the generated module; the compiler never reaches your bundle.

Render

import { createRenderer, createProgram, mat4 } from 'brometal';
import cubeShader from './shaders/cube.shader.gen';

const renderer = await createRenderer(canvas);   // WebGPU; throws where unavailable
const program = createProgram(renderer, cubeShader);

program.attributes.aPosition.set(positions);
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();
});

Everything is typed end-to-end: the 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; the shader compiler enforces the varyings contract with file:line:col diagnostics.

The canvas

Do not set the width and height attributes. BroMetal never reads them and owns the drawing buffer; you own the CSS. A ResizeObserver tracks the CSS box, the buffer follows it at the device pixel ratio, and renderer.aspect stays correct — there is no resize handler to write and no setSize to call.

The one rule: the canvas needs a CSS size, and its container needs a size of its own.

<div id="stage">
  <canvas id="scene"></canvas>
</div>
#stage {
  width: 100%;
  height: 100vh;      /* a definite size — not height: auto */
}

#stage canvas {
  display: block;     /* a canvas is inline by default, which leaves a gap below it */
  width: 100%;
  height: 100%;
  min-width: 0;       /* flex and grid items refuse to shrink without these */
  min-height: 0;
}
const canvas = document.querySelector('canvas');
const renderer = await createRenderer(canvas);

That is all of it. Resize the window, drop the canvas in a flex or grid cell, put it in a resizable pane — the buffer keeps up on its own.

Why not the attributes

They are a 2D-canvas legacy and they actively cause trouble here:

  • A canvas with no CSS size takes its layout box from its drawing buffer, so sizing one to the other feeds output back into input. BroMetal detects this, leaves the buffer alone, and warns once naming the fix — but you get a fixed size that never sharpens on a high-DPI display.
  • The attributes set the canvas's intrinsic size, which is the automatic minimum size of a flex item. width="800" plants an 800px floor in the layout algorithm, and the canvas overflows its flex container instead of fitting it.

React

createRenderer is async — requesting a WebGPU adapter and device both are — so the component can unmount before it resolves. StrictMode runs effects twice in development, which makes that the common case rather than the rare one, so the cancellation flag below is not optional.

import { useEffect, useRef } from 'react';
import { createRenderer, createProgram } from 'brometal';
import cubeShader from './shaders/cube.shader.gen';

export function Scene() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (canvas === null) return;
    let cancelled = false;
    let cleanup: (() => void) | null = null;

    void (async () => {
      const renderer = await createRenderer(canvas);
      if (cancelled) {
        renderer.destroy();
        return;
      }
      const program = createProgram(renderer, cubeShader);
      program.attributes.aPosition.set(positions);
      program.setIndices(indices);

      const stop = renderer.loop(() => program.draw());
      cleanup = () => {
        stop();
        program.dispose();
        renderer.destroy();
      };
    })();

    return () => {
      cancelled = true;
      cleanup?.();
    };
  }, []);

  // Sized by CSS, like any other element. The parent needs a definite size.
  return <canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />;
}

Camera

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

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.

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 gfbm2 turbulence2 warp2 voronoi2 worleyEdge2 curl2 vnoise3 fbm3 · remap smootherstep rotate2 rotate3 gerstnerWave · 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

uniforms: { uLightPos: 'vec3', uTex: 'sampler2D' },
// ...
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 at compile time; program.uniforms.uTex.set(tex) only binds. Load with loadTexture(renderer, url) (mipmapped by default) or wrap any TexImageSource with createTexture. Lights are plain uniforms — full Blinn-Phong is expressible in the DSL.

Models

loadGlb(url) fetches and parses a glTF-Binary (.glb) file into attribute-ready typed arrays — positions, normals, uvs, indices — plus any embedded images:

const model = await loadGlb('/models/ship.glb');
const mesh = model.meshes[0];
program.attributes.aPosition.set(mesh.positions);
program.attributes.aNormal.set(mesh.normals!);
program.attributes.aUv.set(mesh.uvs!);
program.setIndices(mesh.indices!);
const image = model.images[mesh.imageIndex!];
const bitmap = await createImageBitmap(new Blob([image.data], { type: image.mimeType }));
program.uniforms.uTex.set(createTexture(renderer, bitmap, { flipY: false }));

Scope: triangle primitives with embedded (GLB-chunk) buffers and images. Draco compression, skinning, node transforms, and external URIs are not supported; parseGlb(bytes) is the fetch-free variant.

Render targets and shadows

createRenderTarget gives you an off-screen surface to draw into and a texture any shader can sample. It is how the GPU keeps state between frames, and how a second pass sees what a first one drew.

const shadowMap = createRenderTarget(renderer, { width: 1024, height: 1024, depth: true });

renderer.loop(() => {
  // Pass 1: the scene from the light, each fragment writing its distance.
  renderer.drawTo(shadowMap, () => depthProgram.draw(), { clear: [1, 1, 1, 1] });

  // Pass 2: the scene from the camera, reading that map back.
  sceneProgram.uniforms.uShadowMap.set(shadowMap.texture);
  sceneProgram.draw();
});

Two options carry the weight:

  • depth: true attaches a depth buffer, so drawing into the target is depth-tested like drawing to the screen. Off by default, because a state or post-process pass writes one value per texel from a single quad and has nothing to sort. A shadow map does — it has to record the nearest surface to the light, and without the test that is whichever triangle went last.
  • clear sets what the target starts as. Worth setting whenever zero is a meaningful value rather than an empty one: clear a distance map to black and every texel the geometry missed claims an occluder sitting at the light, putting the whole scene in shadow.

Reading a target back with targetUv

To sample a target projectively — a shadow map, a projected texture, a planar reflection — you need the uv a clip-space position lands on. Use targetUv rather than working it out by hand:

const uv = targetUv(uLightViewProj.mul(vec4(worldPosition, 1)));
const nearest = texture(uShadowMap, uv).x;

NDC +y lands on a render target's first row while texture v runs top-down, so the obvious clip.xy / clip.w * 0.5 + 0.5 reads the map vertically mirrored. targetUv gets the flip right. This is worth reaching for rather than hand-rolling: a mirrored lookup still produces a shadow, just attached to the wrong side of the object, which does not look like a coordinate bug.

Targets are RGBA16F and sampled unfiltered — they hold numbers, not pictures. Store what you actually want to compare; the Shadow example writes linear distance to the light rather than a depth value, so one bias constant holds across the whole scene instead of needing to scale with depth.

Instancing

Declare per-instance inputs with instanceAttributes — they upload once and advance per instance, not per vertex. When a shader declares them, program.draw() automatically renders instanced:

export const Instanced = shader({
  attributes: { aPosition: 'vec3', aColor: 'vec3' },
  instanceAttributes: { iOffset: 'vec3', iAxis: 'vec3', iSpeed: 'float' },
  uniforms: { uViewProj: 'mat4', uTime: 'float' },
  // ...
});

Thousands of independently animated objects, one draw call, one mat4 + one float uploaded per frame.

What the DSL supports

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

Links

  • Website & live examples: https://brometal.dev
  • Source and issues: https://github.com/ericdrowell/brometal
  • Discord — questions, showcase, and release chat: https://discord.gg/fNbTnAQqyg