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 the render after readback and returns the frame instead
of a file. Use it when the next step needs 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 the layout
before you pass one buffer to another library. Other options apply unchanged,
and the renderer ignores quality. The
RenderedImageFile has the name render.raw and
the mimeType application/octet-stream.
Node has no image decoder: no ImageData, no createImageBitmap, no canvas.
Without a multi-megabyte decoder dependency, an encoded file is where the
pixels stop. 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. With no encoder in the loop, a non-zero count can only mean the model or the request changed. Across hosts, expect a few pixels of rasterisation difference and keep a tolerance. pixelmatch accepts the same two buffers when you want a diff image.
3. Render a turntable as one plan
import { directionFromOrbit, renderImages } from 'nanoraster';
const frames = await renderImages(glb, {
format: 'raw',
width: 480,
height: 360,
views: Array.from({ length: 36 }, (_, step) => ({
id: `f${step}`,
camera: {
framing: 'fit',
direction: directionFromOrbit({ azimuth: step * 10, elevation: 25 }),
},
})),
});
for (const { file } of frames) encoder.stdin.write(file.bytes);The turn is around the caller world's up axis. If the request declares a
world, pass it as the second argument of
directionFromOrbit. One
renderImages call uploads the GLB once and
pipelines the views; 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 accept the same flat RGBA. 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 you request the
next, as Reuse the renderer shows.
4. Paint it in the browser
import { directionFromOrbit, renderImage } from 'nanoraster';const { bytes, width, height } = await renderImage(glb, { format: 'raw', camera: { framing: 'fit', direction: directionFromOrbit({ azimuth: 47, elevation: 28 }), 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…
advanced · direction XYZ
A browser can decode images, so here raw saves time rather than a dependency.
Painting 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; 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. When you re-encode a
raw frame yourself, you give up the byte-identical output the built-in
encoders guarantee.
Annotations. The renderer draws the axis indicator, the label, and the scale bar 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.