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 can, without configuration. 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 the
adapter you have, without downloading the wasm. It 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 the URL with URL.revokeObjectURL(url) after the image loads.
Otherwise the blob stays resident for the life of the document.
4. Keep the render off the main thread
A render occupies the GPU queue and spends CPU time on the encode. 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. A
transfer of image.bytes.buffer moves the memory instead of a copy. An
unhandled rejection inside the listener would leave the caller in
a permanent wait, so the worker posts a failure 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 you do not need COOP/COEP headers.
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, but readback and encode compete with input.
Capturing a live viewer's camera. Freeze the viewer's numeric pose before
you render. Keep the pose in the same caller world you pass to nanoraster;
never pre-convert it to GLB space. Match the viewer's output aspect. up is
screen up. A three.js camera.up is not: it is the hint lookAt solves
against. Derive up from the camera's world quaternion, or a rolled view
arrives upright.
const screenUp = new THREE.Vector3(0, 1, 0).applyQuaternion(
viewer.camera.getWorldQuaternion(new THREE.Quaternion()),
);
const world = { up: '+z', forward: '-y', unit: 'meter' } as const;
const camera = {
framing: 'fixed',
position: [eye.x, eye.y, eye.z],
target: [target.x, target.y, target.z],
up: [screenUp.x, screenUp.y, screenUp.z],
projection: {
kind: 'perspective',
verticalFieldOfView: viewer.fov,
zoom: viewer.zoom,
},
} as const;
const image = await renderImage(glb, {
format: 'webp',
width: 1600,
height: 900,
world,
camera,
});For an orthographic viewer, use kind: 'orthographic' and set verticalSpan
to its unzoomed caller-world height. If the viewer stores canonical GLB camera
values, transform them back to its declared caller world once in the adapter.
Place the camera gives the equations and clipping rules.