Work with raw pixels
Return the RGBA frame instead of an encoded file, for pixel diffs, video frames and texture uploads with no image decoder in the loop.
format: 'raw' stops after readback and returns the frame instead of a file.
Use it when the next step wants pixels rather than an image: a diff, a video
frame, a texture.
1. Render the frame
import { renderImage } from 'nanoraster';
const { bytes, width, height } = await renderImage(glb, {
format: 'raw',
width: 640,
height: 480,
});bytes is straight-alpha (not premultiplied) sRGB RGBA8: exactly
width * height * 4 bytes, row-major, top row first, no padding. Other
libraries return premultiplied, BGRA or bottom-up buffers; check before piping
one into another. Other options apply unchanged, quality is ignored, and the
RenderedImageFile is named render.raw with
mimeType application/octet-stream.
Node has no image decoder (no ImageData, createImageBitmap or canvas), so
an encoded file is where the pixels stop unless you add one, and a decoder is
a multi-megabyte dependency. Raw output has nothing to decode.
2. Diff two renders
import { renderImage } from 'nanoraster';
const options = { format: 'raw', width: 512, height: 512 } as const;
const baseline = await renderImage(baselineGlb, options);
const candidate = await renderImage(candidateGlb, options);
let changed = 0;
for (let index = 0; index < baseline.bytes.length; index += 1) {
if (Math.abs(baseline.bytes[index] - candidate.bytes[index]) > 8) changed += 1;
}Determinism makes the count meaningful: one host and one request give identical bytes, so a non-zero count means the model or the request changed, and with no encoder in the loop it can mean nothing else. Across hosts, expect a few pixels of rasterisation difference and keep a tolerance. pixelmatch takes the same two buffers when you want a diff image.
3. Render a turntable as one plan
import { renderImages } from 'nanoraster';
const frames = await renderImages(glb, {
format: 'raw',
width: 480,
height: 360,
views: Array.from({ length: 36 }, (_, step) => ({ id: `f${step}`, phi: 60, theta: step * 10 - 180 })),
});
for (const { file } of frames) encoder.stdin.write(file.bytes);One renderImages call uploads the GLB once and
pipelines the views, which a loop of single renders cannot. Pipe the frames, in
order, into a video encoder that reads raw RGBA:
ffmpeg -f rawvideo -pixel_format rgba -video_size 480x360 -framerate 25 -i pipe:0 turntable.mp4GIF encoders take the same flat RGBA, and a plan can mix formats, so one call
can return a WebP poster beside the raw frames. Every view arrives at once, at
width * height * 4 bytes each (36 frames at 480×360 is 25 MB); for a long
sequence hold a renderer and write each batch out before requesting the next,
as Reuse the renderer shows.
4. Paint it in the browser
import { renderImage } from 'nanoraster';const { bytes, width, height } = await renderImage(glb, { format: 'raw', phi: 62, theta: -38, margin: 0.08,});// No copy and no decode: ImageData wraps the memory the renderer wrote.const frame = new ImageData(new Uint8ClampedArray(bytes.buffer), width, height);const canvas = document.querySelector('canvas');canvas.width = width;canvas.height = height;canvas.getContext('2d').putImageData(frame, 0, 0);Rendering…
A browser can decode images, so here raw saves time rather than a dependency:
painting the buffer through ImageData is about 2.5× faster per frame than
decoding lossless WebP. The same buffer uploads unchanged as a WebGL or WebGPU
texture and as a WebCodecs video frame. nanoraster renders stills and reads
every frame back through the CPU; it is not a three.js replacement, so use a
canvas renderer for live interaction.
Variations
Encode when the destination wants a file. Documents, wires and viewers
(PDFs, vision models, glTF textures, <img> and HTTP) take an encoded format;
Format and annotate chooses one. Re-encoding a raw
frame yourself gives up the byte-identical output the built-in encoders
guarantee.
Annotations. The axis indicator, label and scale bar are drawn before readback, so they are in the buffer.
Workers. postMessage(bytes, [bytes.buffer]) transfers a frame without a
copy; Render in the browser shows where the renderer
belongs.
Format and annotate
Choose the encoder, background, and quality, then add the axis indicator, label, and scale bar that let a render stand on its own.
Render for LLMs
Size, encode, and annotate renders for multimodal models — pixel-based billing, lossless input, and the annotations that carry spatial context.