nanoraster
0.4.1

How it works

The four render stages, what reproducibility does and does not guarantee, and the factor-only material profile.

Open Markdown

nanoraster is a thin JavaScript surface over a Rust render core. Everything that decides a pixel's value happens in that core; only the plumbing differs per host.

Pipeline

A render moves CPU → GPU → CPU: parse and fit on the CPU, upload–rasterise–read back on the GPU, encode on the CPU. Failures carry the stage name (parse, gpu, encode).

Parse reads the GLB and rejects malformed or structurally unsupported input; a failure here is classified parse. An out-of-profile material is not rejected — it degrades instead, as the material model describes.

Fit and upload compute a camera that frames the model's bounds from your phi, theta, up, projection and margin, then move the geometry to the device once; see Frame the model.

Rasterise draws the model under the studio preset or the rig you supply, tone maps with the ACES filmic curve, then draws authored edge lines. Failures here are GPU-class.

Encode compresses the pixels that came back. A request it cannot represent, such as a transparent JPEG, fails as encode. Because the failure code names the stage that rejected the work, the retry decision is mechanical rather than a guess. With format: 'raw' this stage is skipped and the frame is returned as read back; see Work with raw pixels.

renderImages parses and uploads once, then fits, rasterises and encodes once per view. RenderTimings fields map onto these stages: parse for parse and fit, setup for device acquisition and upload, then per-view render, overlay for the annotation pass, and encode.

Both hosts, Node.js native and browser WebGPU, wrap the same Rust core and codecs. Only GPU rasterisation differs, so bytes may differ by a shade (see Determinism) but any structural difference between hosts is a bug.

Device lifetime

State crosses the boundary as handles; work crosses as plans. A Renderer is the state: the GPU device, shader, pipelines and render targets, whose lifetime is the process or worker. A views plan is the work: every requested image in one value, so the core can schedule the next view's GPU pass while the previous one encodes, which a loop over single renders can never grant.

A renderer keeps the device, shader, pipelines and the last output size's render targets between calls; parsing, camera fitting and the geometry upload happen per call. The one-shot functions run through one shared renderer per process: created on the first call, run in call order, never disposed, and trimmed of render targets larger than 2048² after each call so one large render does not hold that memory.

Reuse changes no pixels: it skips re-creation, not arithmetic, so a warm renderer's output is byte-identical to the one-shot functions on the same adapter. If the device is lost between calls, the renderer rebuilds it on the next call; the loss surfaces once, as a GPU-class failure on the call that hit it.

Determinism

Renders are used as evidence: a CI artifact that proves a part changed, a screenshot an agent compares across a tool loop. Evidence that varies for reasons unrelated to the subject invites false conclusions, so nanoraster removes inputs rather than seeding them. Lighting is the studio preset unless the request carries a rig; tone map and gamma are fixed; camera distance is derived from the model's bounds; angles, format, size, background and annotations are yours.

What is guaranteed:

  • Repeating a request on the same host produces identical bytes, in the same process or a different one.
  • Lighting is identical for every render that states the same lighting, or none.
  • The camera fit is identical for identical bounds and angles.
  • Input failures reproduce exactly.

What is not guaranteed:

  • Identical bytes across different GPUs or drivers; rasterisation differs at the margins.
  • Identical bytes between the native and wasm paths: same core, different device and adapter.
  • GPU failures reproducing; device loss is by definition transient.
  • Byte stability across nanoraster versions: a render fix changes pixels, so treat a version bump as a baseline reset.

For byte-exact comparison in CI, pin the runner image as well as the options, and compare cross-host perceptually rather than by hash. Any lossless format works as a baseline, but a byte-locked baseline also locks the encoder, and a version bump can change lossless WebP bytes as well as pixels. format: 'raw' removes the encoder from the comparison, so a diff can only report the render.

Material model

nanoraster renders factor-only glTF 2.0 metallic-roughness materials. A material is three numbers and a colour, not an image. That restriction is the renderer's supported profile, and it lets a render be reproduced from the model file alone: a texture-backed material would make the output depend on image decoding and mip generation, which the host supplies rather than the GLB, so two hosts could disagree about the same file.

metallicFactor selects between two behaviours rather than blending two looks. At 0 the surface has a diffuse colour and a white specular highlight. At 1 there is no diffuse term: the body colour is reflected environment, tinted by the base colour. A metal has no diffuse lobe, so only the analytic environment gives it a body colour. roughnessFactor spreads the specular lobe: low values give a tight highlight, high values smear it into a broad sheen.

A rough dielectric produces the soft, evenly shaded look usually associated with a neutral matcap:

{  "pbrMetallicRoughness": {    "baseColorFactor": [0.2622506575, 0.3277780981, 0.4072402119, 1],    "metallicFactor": 0,    "roughnessFactor": 0.85  }}

Rendering…

The base colour above is the linear-space equivalent of sRGB #8C9BAB. Most glTF exporters convert an sRGB value for you; passing an sRGB triple directly gives a washed-out surface. Keep metallicFactor at 0, use roughnessFactor between 0.8 and 1, and replace the colour with the tint you want. This is an approximation, not pixel parity: a matcap maps view-space normals into an image, while PBR derives the result from material, surface, camera and lighting.

A texture-backed material is ignored rather than rejected: it falls back to its factors, so the render succeeds and looks flatter than the authoring tool showed. Metals read as grey at small sizes, because only an analytic environment is there to reflect; prefer a dielectric for thumbnails. Authored edge lines are drawn in their own pass, unaffected by the material factors. With lighting held constant, a difference between two renders of the same model is a material difference and nothing else.

On this page