nanoraster
0.4.1

Render in the browser

Run nanoraster on browser WebGPU, bundle the WebAssembly artifact, and degrade gracefully where WebGPU is missing.

Open Markdown

Render in a browser tab on WebGPU, with the API and render core the Node.js build uses. You need WebGPU enabled and a bundler that can emit a WebAssembly asset; Vite, webpack 5 and Rspack all do without configuration, and Install covers how the artifact is delivered.

1. Check for a WebGPU adapter first

navigator.gpu is necessary but not sufficient: a browser can expose the API and still produce no adapter, or a software one. describeAdapter() names what you got without downloading the wasm, and resolves undefined where there is nothing to bind. Check it before any work; 'cpu' means software rasterisation, an order of magnitude slower.

import { describeAdapter } from 'nanoraster';

const adapter = await describeAdapter();

if (!adapter) {
  // No WebGPU here: show a still image, or render on a server instead.
} else if (adapter.deviceType === 'cpu') {
  console.warn(`software rasterizer: ${adapter.name}`);
}

Browsers report deviceType: 'unknown' unless the adapter declares itself a fallback, because WebGPU withholds the class; name is whatever the browser discloses, from "google swiftshader" in headless Chrome to "" in Firefox.

2. Render from bytes you already hold

Only the source of the GLB differs: a fetch, a file input or an in-memory buffer.

import { renderImage } from 'nanoraster';

const response = await fetch('/model.glb');
const glb = new Uint8Array(await response.arrayBuffer());

const image = await renderImage(glb, {
  format: 'webp',
  width: 640,
  height: 480,
});

3. Show or download the result

Wrap the bytes in a Blob to display them.

const blob = new Blob([image.bytes], { type: image.mimeType });
const url = URL.createObjectURL(blob);

document.querySelector('img')?.setAttribute('src', url);

Revoke it with URL.revokeObjectURL(url) once the image has loaded, or the blob stays resident for the life of the document.

4. Keep the render off the main thread

Rendering occupies the GPU queue and spends CPU time encoding; in a worker, neither blocks input handling. Create the renderer once per worker and dispose it when the worker closes.

// render.worker.ts
import { createRenderer, type Renderer } from 'nanoraster';

let renderer: Promise<Renderer> | undefined;

self.addEventListener('message', async ({ data }: MessageEvent<Uint8Array>) => {
  try {
    renderer ??= createRenderer();
    const image = await (await renderer).renderImage(data, { format: 'webp' });
    self.postMessage(image.bytes, [image.bytes.buffer]);
  } catch (error) {
    self.postMessage({ error: error instanceof Error ? error.message : String(error) });
  }
});

self.addEventListener('close', () => {
  void renderer?.then((handle) => {
    handle.dispose();
  });
});

A renderer is single-realm: bytes cross postMessage, handles never do, and transferring image.bytes.buffer moves the memory instead of copying it. The catch matters: an unhandled rejection inside the listener would leave the caller waiting forever, so a failure is posted back as { error } and bytes mean success. Handle render failures classifies the error; Reuse the renderer covers the lifetime.

Variations

Cross-origin isolation. The browser build does not require SharedArrayBuffer, so COOP/COEP headers are not needed.

Node and browser from one codebase. The import specifier is the same in both, and nothing in your code needs to branch. The browser entry imports no Node.js builtin and resolves the wasm asset without bundler configuration.

Main-thread renders. The one-shot functions work there too, at the price of readback and encode competing with input.

Capturing a live viewer's camera. An orbiting viewer (three.js, Babylon, your own) knows its eye position relative to the target; nanoraster wants phi and theta. Convert the offset; for a Y-up scene:

const anglesFromOffset = ([x, y, z]: readonly [number, number, number]) => {
  const distance = Math.hypot(x, y, z);
  const degrees = 180 / Math.PI;
  return {
    phi: Math.acos(y / distance) * degrees,
    theta: Math.atan2(-z, x) * degrees,
  };
};

const image = await renderImage(glb, { format: 'webp', ...anglesFromOffset(eye) });

Distance is discarded on purpose: nanoraster fits the model itself, so the capture matches the viewer's direction, not its zoom.

On this page