Render in the browser
Run nanoraster on browser WebGPU, bundle the WebAssembly artifact, and degrade gracefully where WebGPU is missing.
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.