rayzee
v11.0.0
Published
Real-time WebGPU path tracing engine built on Three.js
Maintainers
Readme
Rayzee Engine
[![NPM Package][npm]][npm-url] [![Build Size][build-size]][build-size-url] [![NPM Downloads][npm-downloads]][npmtrends-url] [![jsDelivr Downloads][jsdelivr-downloads]][jsdelivr-url]
A real-time WebGPU path tracing engine built on Three.js. Framework-agnostic — use it with React, Vue, vanilla JS, or any other setup.
🌐 Live Demo — the same demo app linked from the root monorepo README, built on this engine.
Table of Contents
- Installation
- Getting Started
- API Reference
- Configuring Assets (CDN URLs & cache namespace)
- PathTracerApp
- Renderer core (
rayzee/core) - engine.cameraManager
- Camera Projection (Orthographic, 360° Panorama)
- engine.lightManager
- engine.animationManager
- engine.timeline
- Materials
- Colour Management
- engine.environmentManager
- engine.denoisingManager
- engine.interactionManager
- engine.transformManager
- Moving and Deforming Objects
- Degradation contract
- Output Methods
- Render Resolution Reserve
- Memory Monitoring
- Logging
- Deterministic & Headless Rendering
- On-disk Storage (OPFS)
- Saving Scene State
- Render Checkpoints
- Events
- Advanced: Custom Pipeline Stages
- All Exports
- Browser Requirements
- Optional Dependencies
- Troubleshooting
- License
Installation
npm install rayzee threethree (>=0.186.0) is a required peer dependency.
Getting Started
Vanilla JS with Vite
Create a project
npm create vite@latest my-raytracer -- --template vanilla cd my-raytracer npm install rayzee threeSet up the HTML
<!-- index.html --> <body style="margin: 0; overflow: hidden;"> <canvas id="viewport"></canvas> <script type="module" src="/main.js"></script> </body>Write the code
// main.js import { PathTracerApp, EngineEvents } from 'rayzee'; const canvas = document.getElementById('viewport'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; const engine = new PathTracerApp(canvas); await engine.init(); // Load a 3D model (place .glb in public/ folder) await engine.loadModel('/scene.glb'); // Or load an environment map // await engine.loadEnvironment('/environment.hdr'); // Start rendering engine.animate(); // Listen for events engine.addEventListener(EngineEvents.RENDER_COMPLETE, () => { console.log('Frame rendered'); }); // Tweak settings engine.settings.set('maxBounces', 8); engine.settings.set('exposure', 1.2); // Use namespaced APIs and direct methods engine.cameraManager.switchCamera(0); engine.lightManager.add('PointLight'); // Capture the current frame as a Blob (host handles save/upload) const blob = await engine.screenshot();Run
npm run dev
Vanilla JS (no bundler)
A single HTML file — no Node.js, no build step. Uses ES module import maps to resolve the pre-built ESM bundle and its dependencies from a CDN.
<!DOCTYPE html>
<html>
<head>
<title>Rayzee Path Tracer</title>
<style>body { margin: 0; overflow: hidden; background: #111; }</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.webgpu.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.tsl.js",
"three/webgpu": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.webgpu.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/",
"oidn-web": "https://cdn.jsdelivr.net/npm/[email protected]/dist/oidn.js",
"rayzee": "https://cdn.jsdelivr.net/npm/rayzee/dist/rayzee.es.js"
}
}
</script>
</head>
<body>
<canvas id="viewport"></canvas>
<script type="module">
import { PathTracerApp } from 'rayzee';
const canvas = document.getElementById('viewport');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const engine = new PathTracerApp(canvas);
await engine.init();
// Replace with your own model URL
await engine.loadModel('https://your-cdn.com/scene.glb');
engine.animate();
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
engine.onResize();
});
</script>
</body>
</html>Serve with any static server (ES modules require HTTP, not file://):
npx serve .Note: The import map approach loads dependencies from a CDN, so initial load is slower than a bundled build. For production, use the Vite setup above.
React
import { useRef, useEffect } from 'react';
import { PathTracerApp } from 'rayzee';
export default function Viewport({ modelUrl }) {
const canvasRef = useRef(null);
const engineRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const engine = new PathTracerApp(canvas);
engineRef.current = engine;
(async () => {
await engine.init();
if (modelUrl) await engine.loadModel(modelUrl);
engine.animate();
})();
return () => engine.dispose();
}, [modelUrl]);
return <canvas ref={canvasRef} style={{ width: '100%', height: '100vh' }} />;
}No special build config is needed — models and HDRs are loaded via URL at runtime.
Integrating Alongside an Existing Three.js App
If your app already has a WebGL/WebGPU rasterized view and you want to add a path-traced mode on demand, run rayzee on its own separate canvas (WebGL and WebGPU can't share one) and toggle visibility.
import { PathTracerApp } from 'rayzee';
// 1. WebGPU detection
if (!navigator.gpu || !(await navigator.gpu.requestAdapter())) return;
// 2. Overlay canvas (hidden until toggled on)
const ptCanvas = document.createElement('canvas');
Object.assign(ptCanvas.style, { position: 'absolute', inset: 0, display: 'none' });
container.appendChild(ptCanvas);
let engine = null;
async function togglePathTrace(on) {
if (on && !engine) {
ptCanvas.width = container.clientWidth;
ptCanvas.height = container.clientHeight;
engine = new PathTracerApp(ptCanvas, { autoResize: false });
await engine.init();
await engine.loadEnvironment('/env.hdr'); // required for realistic lighting
await engine.loadObject3D(yourScene); // rayzee renders its own copy — yourScene is left untouched
engine.animate();
}
ptCanvas.style.display = on ? 'block' : 'none';
hostCanvas.style.display = on ? 'none' : 'block';
on ? engine?.resume() : engine?.pause(); // pause the inactive renderer to avoid GPU contention
}Key constraints:
loadObject3Dcopies the passedObject3D. The engine never reparents, rewrites or disposes your tree, so handing it a subtree of a scene your host still renders is safe — no clone needed on your side. The copy shares geometry, material and texture data by reference, so it costs scene-graph nodes, not GPU memory, and any ancestor transform is baked in so the model renders where your host sees it. The flip side: later edits to the object you passed do not reach the render. Mutateengine.sceneModel(the copy) and callrefitBVH()/refitBLASes()instead.- Rayzee ignores
onBeforeCompile. It reads PBR material properties (albedo, roughness, metalness, …) directly into its own GPU buffers; custom shader injection on the host material has no effect on the path-traced view. - Always load an environment. Path tracing without an env map produces a black background and no indirect lighting.
threeis a peer dep on both sides. Vite/webpack dedupe automatically. For script-tag setups, load one copy ofthreeglobally.
Vite tip
When rayzee is installed from npm, its pre-built dist/rayzee.es.js uses worker and import.meta.url patterns that Vite's dep pre-bundler re-parses incorrectly. Exclude it:
// vite.config.js
export default defineConfig({
optimizeDeps: { exclude: ['rayzee'] },
});API Reference
Configuring Assets (CDN URLs & cache namespace)
By default, the engine loads GLTF Draco/KTX2 decoders, OIDN denoiser weights, ONNX upscaler models, and the onnxruntime-web bundle from upstream CDNs. If you're self-hosting, embedding the engine alongside a different consumer of the same caches, or operating offline, override them once before constructing PathTracerApp:
import { configureAssets } from 'rayzee';
configureAssets({
// onnxruntime-web (loaded by AI upscaler worker via dynamic import)
ortRuntimeUrl: '/ort/ort.webgpu.bundle.min.mjs',
ortWasmPaths: '/ort/',
// GLTFLoader extension decoders
dracoDecoderPath: '/draco/',
ktx2TranscoderPath: '/basis/',
// Denoiser & upscaler weights
oidnWeightsBaseUrl: '/oidn-tzas/',
upscalerModelBaseUrl: '/upscaler-onnx/',
// OpenColorIO runtime (~6 MB of WebAssembly) — the engine never names the package, so a host
// that loads colour configs supplies it, bundled or served. Unset, colour management stays inert.
ocioRuntimeFactory: () => import('@bb-studio/ocio'), // or: ocioRuntimeUrl: '/vendor/ocio/index.js'
ocioWasmUrl: '/vendor/ocio/ocio-wasm.wasm', // optional override for the .wasm
// Names the engine's on-disk storage (an OPFS directory). Set a unique value if several
// apps embed the engine on the same origin, so their caches stay apart.
cacheNamespace: 'my-app',
// On-disk storage: 'auto' (default) where the browser has it, false for none.
storage: 'auto',
});
const engine = new PathTracerApp(canvas);
await engine.init();All keys are optional — only what you pass is overridden. Call getAssetConfig() to read the current values.
PathTracerApp
The main engine class. Extends RayzeeRenderer, the renderer core, which extends three.js's EventDispatcher. Related functionality is grouped into namespaced managers accessed via engine.cameraManager, engine.lightManager, etc., or as direct methods on the engine instance.
const engine = new PathTracerApp(canvas, options?)| Parameter | Type | Description |
|---|---|---|
| canvas | HTMLCanvasElement | Rendering target |
| canvas may be null | | Headless: the engine makes its own canvas and runs no render loop — see Running in Node |
| options.headless | boolean | Headless with a canvas of your own (default: true when canvas is null) |
| options.autoResize | boolean | Auto-resize on window resize (default: true; always off headless) |
| options.container | HTMLElement | Single DOM parent the engine mounts auxiliary elements into — HUD overlay (tile borders, helpers) and denoiser canvas. Defaults to canvas.parentNode. |
| options.strict | boolean | Throw an EngineIssueError where the engine would otherwise degrade and carry on (default: false). See Degradation contract. |
| options.maxSceneBytes | number | Raise or lower the CPU memory ceiling a scene may need before the engine refuses it (default 9,216 MB). See Memory monitoring. |
| options.hostMemoryGB | number | The host's memory, for runtimes without Chrome's navigator.deviceMemory (which then read as 4 GB and cap the render reserve at 2048). Sizes the reserve and the path pool. |
| options.storage | false \| 'auto' \| StorageManager | On-disk storage (default: configureAssets( { storage } ); off under strict unless you set it there or here). A manager you pass stays yours to dispose. See On-disk storage. |
| options.memorySpill | boolean | Experimental, default false: build a large static scene through disk — triangle records, BLAS nodes and the three.js geometry are written out as the build finishes with them — and raise the pbrt triangle and placement caps to 60M / 8M. See On-disk storage. |
The engine creates and mounts everything it needs (denoiser canvas, tile/HUD overlay) into a single parent on init(). Performance HUDs (e.g. stats-gl) are not bundled — listen to EngineEvents.FRAME and tick your own panel.
Lifecycle
await engine.init() // Initialize WebGPU renderer and pipeline
engine.animate() // Start the render loop
engine.pause() // Pause rendering
engine.resume() // Resume rendering
engine.reset() // Reset accumulation (restart from sample 0)
engine.reset(false, { motion: true }) // Same, when only placements or geometry moved: keeps OIDN's motion history
engine.dispose() // Clean up all resources
engine.wake() // Resume render loop if idleConstructing a new PathTracerApp on a canvas that already has an active instance auto-disposes the prior one — safe under React StrictMode and HMR even without explicit cleanup, though engine.dispose() remains the recommended teardown path.
Loading Assets
await engine.loadModel(url) // Load a GLB/GLTF by URL
await engine.loadFile(fileOrUrl) // Any supported file: GLB/GLTF/FBX/OBJ/STL/PLY/DAE/3MF/USD/USDZ, archives, HDR/EXR
await engine.loadObject3D(object3d, name?) // Load a Three.js Object3D directly (name is optional, defaults to 'object3d')
await engine.loadEnvironment(url) // Load HDR/EXR environment map
engine.cancelLoad() // Abort an in-flight download (network phase only; no-op once processing starts)On rayzee/core, formats other than glTF and .hdr need the rayzee/addons/formats add-on — see Add-ons.
loadModel / loadObject3D replace the current scene. To add or remove objects from a live scene without a full reload (and without reframing the camera):
const id = await engine.addModel(url, { name }) // Append a model, rebuild in place
const id = await engine.addModelFromObject3D(object3d, { name }) // Append a copy of a caller-owned Object3D (yours is untouched)
engine.getSceneObject(id) // Resolve an id to the rendered root (the copy)
await engine.removeSceneObject(id) // Remove by id — returns false if not found
engine.setSceneObjectVisibility(id, visible) // Toggle visibility with an O(1) BVH-leaf patch, no rebuildengine.sceneModel is the root of what is actually being rendered — for loadObject3D that is the engine's copy, and it is the object to mutate before refitBVH().
id is the appended root's Object3D.uuid, returned by addModel/addModelFromObject3D. For addModelFromObject3D the engine carries your object's uuid onto its copy, so the id matches the object you passed. The built-in ground plane is permanent and can't be removed.
Loading part of a scene archive
On rayzee/core, archives and pbrt need the rayzee/addons/archives add-on — see Renderer core.
A pbrt-v4 scene archive (.tar, .tar.gz, .zip) is usually a root .pbrt file that includes one
subtree per element, and the whole thing rarely fits in a browser tab — Moana is 29 GB unpacked.
The archive can be inspected without retaining any of it, then loaded one element at a time:
const { kind, root, elements, entryCount, totalBytes } = await engine.inspectArchive(file);
await engine.loadFile(file, { element: elements[0].path }); // one element
await engine.loadFile(file, { element: [ a.path, b.path ] }); // several togetherEverything above a chosen element comes along — the root scene file, the material library, an
ancestor's textures folder — and an Include pointing at an element you left out only warns,
which is what makes a partial load work. Selecting every element is a valid answer and loads the
whole scene.
Past 4 GB unpacked, a multi-element archive throws ARCHIVE_NEEDS_ELEMENT rather than taking
all of it. The error carries the element list, so a host can turn it into a picker:
try {
await engine.loadFile(file);
} catch (err) {
if (err.code === 'ARCHIVE_NEEDS_ELEMENT') showPicker(err.elements, err.root, err.totalBytes);
else throw err;
}Per-load options for pbrt archives:
| Option | Default | Effect |
|---|---|---|
| promptBytes | 4 GB | moves the line past which a multi-element archive asks for elements |
| maxTriangles | 45M (60M with memorySpill) | past it, placements are skipped and the build reports itself truncated |
| maxPlacements | 6M (8M with memorySpill) | the same, for placements |
| curveTolerance | 0.05 | how far a curve segment may stray, × the curve's half-width; curves become strips with adaptive segments. 0 gives the old uniform strips bit for bit |
| instanceIncludes | true | a file included again under the same material, with no side effects, is placed as an instance of its first reading instead of being read and stored again |
45M is the highest rung measured to survive without the spill; raising either cap is a deliberate
act on a fresh browser tab. With memorySpill, 80M stored triangles (4.35M placements) have loaded
and rendered; an 89M load ran out of memory while parsing, and WebGPU's 4 GB buffer limit stops the
triangle data at 89.5M in any case.
Settings
engine.settings.set('maxBounces', 8) // Set a single parameter
engine.settings.setMany({ // Set multiple parameters at once
maxBounces: 8,
maxSamples: 60,
exposure: 1.0
})
engine.settings.get('maxBounces') // Read a parameter
engine.settings.getAll() // Get all current settingsKey settings:
| Setting | Type | Default | Description |
|---|---|---|---|
| maxBounces | number | 3 | Max ray bounce depth |
| maxSamples | number | 60 | Max accumulated samples before stopping |
| exposure | number | 1.0 | Exposure value |
| saturation | number | 1.0 | Color saturation (1 = no grade) |
| enableEnvironment | boolean | true | Use environment lighting |
| environmentIntensity | number | 1.0 | Environment light strength |
| environmentRotation | number | 0 | Environment Y-rotation (degrees); 0 shows the HDRI as authored, as Blender's unmapped world does |
| areaLightIntensityScale | number | 0.1 | Power of a glTF model's placeholder area lights (RectAreaLight extras), read when the model loads, so set it first; 1 is the authored power |
| maxTextureSize | number | 4096 | Longest edge of a material texture, read when a model loads (clamped to the hardware ceiling); setMaxTextureSize() applies it to the current scene too |
| wavefrontSortMaterials | boolean | true | Sort rays by material before shading, above 8 materials; read when the shaders next build |
| showBackground | boolean | true | Show the environment as a visible backdrop for camera-miss rays (vs. a solid/transparent background) |
| samplingTechnique | number | 2 | Sampler: 0 PCG, 1 scrambled Halton, 2 Owen-scrambled Sobol |
| integrator | string | 'path' | 'path' | 'bidirectional' | 'vcm' (on the core, needs the bidirectional add-on). Bidirectional also traces light subpaths from every light — emissive surfaces, rect/disk, point, spot and directional lights, the sun and the environment — far faster for caustics and light through small openings, about 2× the cost per sample. On a lamp-lit interior with an HDRI it is ~20 % less noisy at equal time; a room lit only through a window stays better path traced. 'vcm' adds photon merging, for caustics seen in mirrors and through glass. Switching rebuilds the kernels |
| fireflyThreshold | number | 3.0 | Firefly clamping threshold |
| shadowTerminatorOffset | number | 0.1 | Cycles' Shadow Terminator → Geometry Offset: near the light's terminator on a smooth-shaded low-poly mesh, light and environment shadow rays start on the smooth surface the vertex normals describe, not the flat facet. Blender's default; 0 disables |
| transmissiveBounces | number | 5 | Max bounces for transmissive materials |
| maxSubsurfaceSteps | number | 8 | Max random-walk steps for subsurface scattering (raised to 64 by configureForMode('production')) |
| enableAlphaShadows | boolean | false | Alpha-tested shadow rays (enabled by configureForMode('production')) |
| enableDOF | boolean | false | Enable depth of field |
| dofMode | string | 'look' | 'look': the blur is set by dofBlur, the same at any scene scale; 'physical': a real lens set by aperture, focalLength and unitsPerMetre. |
| dofBlur | number | 0.05 | Look mode: how far a distant background blurs, as a fraction of the image height |
| focusDistance | number | 0.8 | DOF focus distance in scene units — depth along the view axis (the focal plane is flat) |
| aperture | number | 5.6 | Physical mode: f-stop |
| focalLength | number | 50 | Physical mode: focal length (mm) |
| unitsPerMetre | number | 1 | Physical mode: scene units per real metre, for files whose units are not metres. It carries over between model loads — reset it when the new file's units differ |
| transparentBackground | boolean | false | Transparent canvas background |
| interactionModeEnabled | boolean | true | Render at lower resolution while the camera moves, keeping the full bounce budget ("Fast Navigation" in the app) |
| interactionRenderScale | number | 0.5 | Per-axis render scale while the camera moves (0.5 = a quarter of the pixels); 1 turns the drop off. Ignored while OIDN is the live denoiser. PathTracerApp only |
| renderMode | number | 0 | Internal preview(0)/production(1) flag driving accumulation & ASVGF behavior — normally set via configureForMode(), not written directly |
| visMode | number | 0 | Debug visualization mode (0 = off) |
| environmentMode | string | 'hdri' | Sky mode: 'hdri' | 'procedural' | 'color' — not routed through engine.settings; use engine.environmentManager.setMode() instead |
| cameraProjection | string | 'perspective' | 'perspective' | 'orthographic' | 'equirectangular' — see Camera Projection |
| panoramaLonRange | [number, number] | [-180, 180] | Panorama longitude sweep, degrees, left→right |
| panoramaLatRange | [number, number] | [-90, 90] | Panorama latitude sweep, degrees, bottom→top |
| panoramaLevelHorizon | boolean | true | Yaw-only panorama basis, so orbit pitch/roll can't tilt the horizon |
| useAdaptiveSampling | boolean | true | Whole-frame early-stop once convergence reaches adaptiveStopFraction |
| noiseThreshold | number | 0.1 | √-luminance-normalized per-pixel noise below which a pixel counts as converged (the production preset uses 0.02) |
| adaptiveMinSamples | number | 8 | Minimum samples before adaptive sampling can trigger |
| adaptiveStopFraction | number | 0.90 | Fraction of pixels that must converge before the frame retires (the production preset uses 0.94) |
| usePixelFreeze | boolean | true | Per-pixel freeze (Tier-2): skip individually-converged pixels via active-list compaction |
| pixelFreezeThreshold | number | 0.02 | Relative-error threshold for a pixel to become a freeze candidate |
| pixelFreezeStability | number | 8 | Consecutive candidate frames required before a pixel freezes |
See ENGINE_DEFAULTS for the full list with default values — every key in it is a setting of the same name. The default look is AgX (DEFAULT_VIEW) at neutral saturation; tone mapping is chosen through Colour Management (engine.color.setActiveView( id )), not settings. On the core without the colour add-on, set renderer.renderer.toneMapping.
Rendering Modes
engine.configureForMode('production') // High quality (full-frame, 20 bounces, OIDN, controls disabled)
engine.configureForMode('interactive') // Real-time navigation (3 bounces, controls enabled)To pause rendering for image-viewing UI, set engine.pauseRendering = true and disable camera controls directly — the engine doesn't model viewport visibility.
Renderer core (rayzee/core)
PathTracerApp is built on RayzeeRenderer, the renderer without the viewer: a scene and camera in, path-traced samples accumulated, the image out. The denoisers, camera controls, gizmo, overlays, timeline and animation playback exist only in PathTracerApp; six capabilities come as add-ons — file formats beyond glTF and .hdr, the physical sky, scene archives, bidirectional path tracing, OpenColorIO colour and on-disk storage. Its entry point downloads about 40 % less (249 KB against 418 KB compressed). It takes the same constructor options except container, and renders the same pixels.
import { RayzeeRenderer } from 'rayzee/core';
const renderer = await new RayzeeRenderer(canvas).init();
await renderer.loadModel('/models/scene.glb');
renderer.camera.position.set(0, 2, 6); // the camera it renders from; reset() after moving it
renderer.camera.lookAt(0, 0, 0);
renderer.reset();
await renderer.renderFrames(256);
const image = await renderer.renderToBuffer({ colorSpace: 'srgb' });
renderer.dispose();Two runnable examples live in the repository: rayzee/examples/core-node.mjs (the core alone, in Node, writing a PNG)
and rayzee/examples/core-browser/ (the core plus the physical sky, accumulating on a canvas).
Add-ons
Six capabilities install on the core explicitly. PathTracerApp installs all six itself, so nothing in this
section applies to it.
| Add-on | Import | Install | When | Without it |
|---|---|---|---|---|
| File formats | fbxFormat, objFormat, stlFormat, plyFormat, colladaFormat, threeMFFormat, usdFormat, exrFormat or allFormats from rayzee/addons/formats | renderer.assetLoader.registerFormat(objFormat, exrFormat) | after init() | only glTF/GLB, .hdr and images load; any other file's error names the add-on |
| Physical sky | PhysicalSky from rayzee/addons/physical-sky | renderer.environmentManager.setProceduralSky(PhysicalSky) | after init() | 'procedural' mode records capability.missing (throws under strict) |
| Scene archives and pbrt | ArchiveImporter from rayzee/addons/archives | renderer.assetLoader.setArchiveImporter(new ArchiveImporter(renderer.assetLoader)) | after init() | .zip, .tar and .tgz are not supported formats, and the error names the add-on |
| Bidirectional and VCM | BidirectionalIntegrator from rayzee/addons/bidirectional | renderer.stages.pathTracer.registerIntegrator(['bidirectional', 'vcm'], pt => new BidirectionalIntegrator(pt)) | after init() | choosing either integrator records capability.missing (throws under strict) and keeps the current one |
| OpenColorIO colour | ColorManagement from rayzee/addons/color | renderer.setColorManagement(ColorManagement) | before or after init() | linear Rec.709 through three.js's own tone mappers; loadColorConfig() records capability.missing and rejects |
| On-disk storage | acquireSharedStorage from rayzee/addons/storage | renderer.setStorageOpener(acquireSharedStorage) | before init() | downloads land in memory and nothing is cached between visits |
init() creates the asset loader, the environment manager and the path tracer stage, which is why four of them come
after it. Storage is opened during init(), so its opener has to be set first.
All six, in that order:
import { RayzeeRenderer, configureAssets } from 'rayzee/core';
import { acquireSharedStorage } from 'rayzee/addons/storage';
import { ColorManagement } from 'rayzee/addons/color';
import { ArchiveImporter } from 'rayzee/addons/archives';
import { PhysicalSky } from 'rayzee/addons/physical-sky';
import { BidirectionalIntegrator } from 'rayzee/addons/bidirectional';
import { allFormats } from 'rayzee/addons/formats';
configureAssets({ ocioRuntimeFactory: () => import('@bb-studio/ocio') });
const renderer = new RayzeeRenderer(canvas);
renderer.setStorageOpener(acquireSharedStorage); // before init()
renderer.setColorManagement(ColorManagement);
await renderer.init();
renderer.assetLoader.registerFormat(...allFormats);
renderer.assetLoader.setArchiveImporter(new ArchiveImporter(renderer.assetLoader));
renderer.environmentManager.setProceduralSky(PhysicalSky);
renderer.stages.pathTracer.registerIntegrator(['bidirectional', 'vcm'], pt => new BidirectionalIntegrator(pt));Loading on first use. The physical sky and the archive importer can be installed as loaders instead, so their code
is fetched only the first time the sky is baked or an archive is read — PathTracerApp does this. The archive loader
takes the formats it reads, which rayzee/core exports, so the loader recognises an archive before its code exists:
import { ARCHIVE_FORMATS } from 'rayzee/core';
renderer.environmentManager.setProceduralSkyLoader(() => import('rayzee/addons/physical-sky').then((m) => m.PhysicalSky));
renderer.assetLoader.setArchiveImporterLoader(
() => import('rayzee/addons/archives').then((m) => new m.ArchiveImporter(renderer.assetLoader)),
ARCHIVE_FORMATS,
);Colour, storage and the integrators have no loader: colour and storage are used at startup, and an integrator applies the moment it is chosen (a lazy one would trace plain frames meanwhile and break reproducible renders).
File formats. The core reads glTF/GLB (Draco, KTX2 and meshopt decoders are fetched only for a file that uses
them), .hdr and LDR images. FBX, OBJ, STL, PLY, Collada, 3MF, USD/USDZ and EXR are formats to register — import only
those you read and a bundler leaves the rest out; each three.js loader is downloaded the first time its format is read:
import { objFormat, usdFormat, exrFormat } from 'rayzee/addons/formats';
renderer.assetLoader.registerFormat(objFormat, usdFormat, exrFormat);
await renderer.loadFile(objFile);A format of your own registers the same way. A model format's parse gets the file and resolves to the model; an
environment format's createLoader returns a three.js loader whose loadAsync resolves to a texture:
renderer.assetLoader.registerFormat({
name: 'Point cloud (XYZ)', label: 'XYZ', type: 'model', extensions: ['xyz'],
async parse(file, { filename }) {
return { model: buildPoints(await file.text(), filename) };
},
});Physical sky, for environmentMode: 'procedural':
renderer.environmentManager.setProceduralSky(PhysicalSky);
await renderer.environmentManager.setMode('procedural');Bidirectional path tracing and vertex merging, for integrator: 'bidirectional' | 'vcm':
renderer.stages.pathTracer.registerIntegrator(['bidirectional', 'vcm'], pt => new BidirectionalIntegrator(pt));
renderer.settings.set('integrator', 'bidirectional');Its controls are on the active integrator (the same on PathTracerApp):
const integrator = renderer.stages.pathTracer.activeIntegrator;
integrator.setMergeRadius(1); // 'vcm': gather radius in pixels
integrator.setMergeTrust(0.25); // 'vcm': how far merging is trusted against the other strategies
integrator.setLightGuiding(true); // learn where light paths from the sky and sun start
integrator.setBidirectionalStrategy('connect', { alone: true }); // keep one strategy, for verificationScene archives (.zip, .tar, .tar.gz) and the pbrt scenes in them:
renderer.assetLoader.setArchiveImporter(new ArchiveImporter(renderer.assetLoader));
await renderer.loadFile(archiveFile);OpenColorIO colour management — configs, their views and looks, working spaces and export spaces. The host supplies
the OCIO runtime (the engine never names the package). Without the add-on, pick a tone mapper on the three.js renderer
instead (renderer.renderer.toneMapping = AgXToneMapping):
configureAssets({ ocioRuntimeFactory: () => import('@bb-studio/ocio') });
renderer.setColorManagement(ColorManagement);
await renderer.loadColorConfig({ builtin: 'ocio://cg-config-v4.0.0_aces-v2.0_ocio-v2.5' });On-disk storage (the browser's origin private file system), for the download, environment and scene caches and
the memorySpill option. Asking for it without the add-on (storage: 'auto') records a capability.missing warning;
storage: false turns it off either way:
const renderer = new RayzeeRenderer(canvas);
renderer.setStorageOpener(acquireSharedStorage);
await renderer.init();Several renderers, core or full, can live in one page; they share only the colour management and the on-disk storage, which are page-wide.
Code of your own that adds a setting declares it on the renderer's settings, with its default, so it gets the same provenance, change events and session saving as the built-in ones:
renderer.settings.define('myGlowStrength', { default: 1, apply: (value) => glow.setStrength(value), reset: true });
renderer.settings.set('myGlowStrength', 2);engine.cameraManager
Camera switching, auto-focus, DOF, and direct Three.js access.
engine.cameraManager.active // The active camera: a PerspectiveCamera that can turn orthographic
engine.cameraManager.controls // The OrbitControls instance
engine.cameraManager.switchCamera(index) // Switch between scene cameras
engine.cameraManager.getNames() // List available cameras
engine.cameraManager.focusOn(center) // Focus orbit camera on a world-space point
engine.cameraManager.setAutoFocusMode(mode) // 'auto' | 'manual'
engine.cameraManager.setAFScreenPoint(x, y) // Set normalized AF screen point (0-1)
engine.cameraManager.setNavigationMode(mode) // 'orbit' | 'walk'
engine.cameraManager.walkControls.speed // Walk speed, scene units per second
engine.cameraManager.orthoHeight // Orthographic view height, scene units, wheel zoom included
engine.cameraManager.setOrthoHeight(height) // Set itWalk mode is first-person navigation: drag to look, W A S D or the arrow keys to walk level, E and Q to
rise and sink, Shift faster, Alt slower. A new model resets speed so the walk crosses it in about eight
seconds. Keys are ignored while focus is in a text field, list or menu, and when a focused control has
already used the key. The mode obeys controls.enabled, so anything that locks the orbit camera locks walking
too. Switching back to 'orbit' circles the surface at the centre of the view.
Camera Projection (Orthographic, 360° Panorama)
Three camera models live behind the cameraProjection setting. All are compiled into the same kernel, so switching writes a uniform and resets accumulation — it never recompiles WGSL.
Orthographic
engine.settings.set('cameraProjection', 'orthographic');
engine.cameraManager.setOrthoHeight(12); // the view's height in scene units
engine.addEventListener(EngineEvents.ORTHO_HEIGHT_UPDATED, ({ height }) => {}); // the wheel changed itRays are parallel and start on the camera's image plane, so nothing behind the camera is seen and nothing shrinks with distance. Switching keeps what the view shows at the orbit target: turning orthographic sizes the view from the orbit distance and field of view, and turning back moves the camera to match. The wheel then zooms by changing the view's size rather than moving the camera. A new model is framed the same way.
engine.cameraManager.active stays the same object — it switches its own projection and reports isOrthographicCamera — so picking, the transform gizmo and the overlays follow without anything being re-pointed. Imported orthographic cameras (glTF, and pbrt's Camera "orthographic") switch the projection to orthographic at their own size, and a camera left orthographic comes back so; any other camera switches it back to perspective. Every denoiser, auto-focus and depth of field keep working. An environment at infinity is seen from a single direction, so the background is one colour.
360° Panorama
engine.settings.set('cameraProjection', 'equirectangular');
// Optional: crop the sweep. Degrees, [min, max].
engine.settings.set('panoramaLonRange', [-90, 90]); // VR180
engine.settings.set('panoramaLatRange', [0, 90]); // upper hemisphere only
engine.settings.set('panoramaLevelHorizon', true); // default — orbit pitch won't tilt the panoramaThe mapping puts camera-forward at the image centre, the zenith at the top row, and yaw-right at increasing u. Full-sphere output is 2:1 — size the canvas accordingly (engine.setCanvasSize(w, w / 2)); the engine renders whatever aspect you give it and will stretch the sphere otherwise. A cropped range changes the natural aspect to match lonRange / latRange.
Depth of field still works: the lens plane is built from each ray's own frame, not the camera's, so bokeh stays round across the whole sweep.
Two features are incompatible with a non-frustum camera and the engine switches them off for you when panorama is enabled:
- ASVGF falls back to the
edgeawaredenoiser — ASVGF's motion vectors unproject through the projection matrix, which is meaningless when every pixel is its own direction. - Auto-focus pauses and focus holds its last distance — it raycasts via
Raycaster.setFromCamera, which only understands a frustum. It resumes when you leave the panorama.
Read the denoiser outcome back rather than duplicating the rule (engine.denoisingManager.denoiserStrategy); it is not restored automatically when you leave the panorama.
engine.lightManager
Light CRUD, visual helpers, and GPU sync.
engine.lightManager.add('PointLight') // Add a light (PointLight, SpotLight, DirectionalLight, RectAreaLight)
engine.lightManager.remove(uuid) // Remove by UUID
engine.lightManager.clear() // Remove all lights
engine.lightManager.getAll() // Get all light descriptors
engine.lightManager.setIntensity(uuid, 40) // Set one light's power and re-upload
engine.lightManager.getLight(uuid) // The traced three.js light, to edit other properties
engine.lightManager.sync() // Re-upload light data to GPU after editing a light
engine.lightManager.showHelpers(true) // Toggle visual helpersThe path tracer traces copies of a model's lights, made when the model loads. Changing a light
inside the loaded model does nothing; change the copy — getLight(uuid) with a UUID from getAll() —
and call sync(), or use setIntensity().
Light intensity follows Blender: radiant power in watts for point, spot and area lights, irradiance in W/m² for directional. Dividing power by area only means something in metres, so the engine assumes one world unit is one metre; scenes authored in cm or mm must carry that scale in their node transforms, as glTF exporters do. glTF RectAreaLightPlaceholder nodes author intensity as three.js radiance (their power field is intensity · width · height · π); the importer converts it to power through the light's world area so the authored radiance is reproduced exactly, then applies the profile's areaLightIntensityScale.
engine.animationManager
GLTF animation playback controls.
engine.animationManager.play(clipIndex) // Play an animation clip
engine.animationManager.pause() // Pause playback
engine.animationManager.resume() // Resume playback
engine.animationManager.stop() // Stop and reset
engine.animationManager.setSpeed(2) // Set playback speed multiplier
engine.animationManager.setLoop(true) // Enable/disable looping
engine.animationManager.clips // Get available animation clipsengine.timeline
Authored animation: keyframed tracks on one time axis, in seconds. The camera's is the first track; the timeline is where lights and objects will join it. A model's own clips stay with engine.animationManager, and saved cameras (engine.addCamera()) stay cameras — a keyframe is not a camera.
const camera = engine.timeline.camera // the camera's track
const key = camera.addKey() // key the current view, 2 s after the last key
camera.addKey(5) // …or at a time
camera.updateKey(key.id) // give a key the current view
camera.setTime(key.id, 3.5) // retime it; keys stay in time order
camera.remove(key.id)
camera.keys // [{ id, time, position, target, fov, orthoHeight }]
engine.timeline.duration // seconds to the last key of any track
engine.timeline.animates // a track has two keys or more
engine.timeline.seek(2.5) // put the scene where the timeline has it at 2.5 s
await engine.timeline.play() // run it in the viewport, controls locked
engine.timeline.stop() // or stop early
// Keys changed ({ track: 'camera' }) or playback started or stopped ({ track: undefined })
engine.addEventListener(EngineEvents.TIMELINE_CHANGED, ({ track }) => {})
// A video of the move through a still scene; pass clipIndex too to move the camera during a clip
await new VideoRenderManager(engine).renderAnimation({ timeline: engine.timeline, fps: 30, onFrame });Between keys the camera glides along a smooth curve through their positions while looking along another through their targets, so a subject every key looks at stays in frame. It leaves the first key and reaches the last at rest, runs straight through the keys between, and holds still outside them. FOV, or an orthographic view's height, blends too, in the projection in use. The curves are three.js keyframe tracks (InterpolateSmooth). With auto-focus on, focus is measured again on every video frame, and the view is put back when the render ends. A new model clears the keys, as it clears saved cameras.
Materials
Material property updates and texture transforms — accessed as direct methods on the engine.
engine.setMaterialProperty(index, property, value) // Update a material property
engine.setTextureTransform(index, name, transform) // Update texture transform
engine.reset() // Re-upload all material data to GPU
engine.stages.pathTracer.materialData.updateMaterial(index, mat) // Replace a material
await engine.rebuildMaterials(scene) // Full rebuild (after texture changes)
// Cap the longest edge of processed material textures (clamped to the hardware max).
// Larger = sharper textures, ~quadratic VRAM. Reprocesses the current scene by default;
// settings.set('maxTextureSize', n) stores it for the next load instead.
await engine.setMaxTextureSize(2048)
await engine.setMaxTextureSize(4096, { reprocess: false })
// Per-mesh visibility — recommended UUID-based API (handles lookup + sync internally)
engine.setMeshVisibilityByUuid(uuid, true) // explicit set
engine.setMeshVisibilityByUuid(uuid, prev => !prev) // toggle via updater fn
// Returns the new visibility state, or null if the mesh wasn't found.
// Lower-level — for callers that already have a meshIndex or have mutated object.visible directly
engine.setMeshVisibility(meshIndex, visible)
engine.updateAllMeshVisibility() // re-sync after manual object.visible mutations
// Read access to the active scene (returns the mesh-bearing scene)
engine.getScene()
// Where a packed value came from: 'material' | 'mapped' | 'default' | 'host'
engine.getMaterialPropertySource(index, 'ior')A property a three.js material lacks falls back to MATERIAL_DEFAULTS (exported) — MeshPhysicalMaterial's own values, so a glTF metallic material gets IOR 1.5, not a guess derived from its metalness. Weights and roughnesses (metalness, roughness, transmission, opacity, clearcoat, sheen, iridescence, …) are clamped to [0, 1] on upload and on setMaterialProperty.
Colour Management
On rayzee/core this is the rayzee/addons/color add-on — see Renderer core.
engine.color is an OpenColorIO pipeline: what textures and lights mean, what the render happens in, and what it is shown and saved as. It is inert until a config is loaded — the render stays linear Rec.709 and the view transforms are three.js's own seven, so a host that never loads one sees no change. The host supplies the runtime (ocioRuntimeFactory or ocioRuntimeUrl in configureAssets).
configureAssets({ ocioRuntimeFactory: () => import('@bb-studio/ocio') });
await engine.loadColorConfig({ builtin: 'ocio://cg-config-v4.0.0_aces-v2.0_ocio-v2.5' }); // a runtime built-in
await engine.loadColorConfig({ files, configPath: 'config.ocio', id: 'studio' }); // or a folder: [{ relativePath, data }]
engine.color.setView({ display: 'sRGB - Display', view: 'ACES 2.0 - SDR 100 nits (Rec.709)', look: null });
engine.color.setLook(look); // a look from status().config.looks, on the active view
engine.color.setActiveView(id); // any registered view, OCIO or built-in (three.js constant)
engine.color.setContext({ SHOT: '010' }); // $SHOT in the config resolves to this
engine.color.status(); // config, working space, active view, registered views
engine.color.setWorkingSpace('ACEScg'); // render in the config's space…
await engine.applyColorWorkingSpace(); // …which rebuilds textures, materials and the environment
await engine.setTextureColorSpace(texture, 'srgb'); // null (auto), 'srgb', 'linear' or a config space
const { data } = await engine.renderToBuffer({ colorSpace: 'ACES2065-1', source: 'display' }); // float delivery buffer
await engine.unloadColorConfig();- Load and unload through
engine.loadColorConfig()/unloadColorConfig()once a scene exists: they undo an adopted working space while the config that converted the environment is still loaded. - A view is baked to a log2 shaper and a 65³ table, interpolated tetrahedrally; the same table drives the canvas, the GPU and CPU readbacks and the menu (
listViewTransforms(),onRegistryChange()). An OCIO view returns display-encoded colour, so the engine switches the output pass to linear while one is active. - Baked views.
await engine.color.saveBakedView(id)writes a view's table to a file (157 KB gzip for 65³), andawait engine.color.loadBakedView(bytes, { expect })registers it with no runtime and no config — show the look on the first frame, load the config later. When that config loads, a baked view whose files match the fingerprint it was baked from is kept as is: no rebake, no new id, no restart. renderToBuffer({ colorSpace })takes'srgb'(display bytes through the active view),'linear'(the working-space accumulation) or a config colour space;source: 'display'reads what the viewport shows, denoised, instead of the raw accumulation.- Degradations (a view that cannot bake, a display the canvas cannot show) are recorded as warnings in the degradation contract.
engine.environmentManager
Environment maps, sky modes, and procedural generation.
engine.environmentManager.params // Current environment parameters
engine.environmentManager.texture // The loaded environment texture
await engine.loadEnvironment(url) // Load HDR/EXR environment map (method on engine)
await engine.environmentManager.setEnvironmentMap(tex) // Set a custom environment texture
await engine.environmentManager.setMode(mode) // 'hdri' | 'procedural' | 'color'
await engine.environmentManager.generateProcedural() // Physical sky: spectral, multiple scattering, analytic sun (core: needs rayzee/addons/physical-sky)
await engine.environmentManager.generateSolid() // Solid color sky
engine.environmentManager.markDirty() // Flag environment for GPU re-uploadThe physical sky (mode 'procedural') is set through params, then baked. It is baked and
importance-sampled entirely on the GPU (~1.6 ms per sun move), so bake on every slider event rather
than debouncing: requests made in one task become one bake, and the promise resolves once the
environment has caught up. The sun is drawn and sampled as a light of its own, not from the texture.
import { sunPosition, dayOfYearForMonth } from 'rayzee';
const p = engine.environmentManager.params;
const { azimuth, elevation } = sunPosition( { hours: 17.5, dayOfYear: dayOfYearForMonth( 6 ), latitude: 40 } );
const az = ( 180 - azimuth ) * Math.PI / 180, el = elevation * Math.PI / 180; // north along −Z, east along +X
p.skySunDirection.set( Math.cos( el ) * Math.sin( az ), Math.sin( el ), Math.cos( el ) * Math.cos( az ) );
p.skyTurbidity = 3; // haze, 1–10; also skyOzone (Dobson units), skyAirDensity (× Earth's),
// skyGroundAlbedo (Color), skyAltitude (m), skySunSize (°), skySunStrength
await engine.environmentManager.generateProcedural();engine.denoisingManager
Denoiser strategy, ASVGF, OIDN, upscaler, and auto-exposure.
// Strategy
engine.denoisingManager.setStrategy('asvgf', 'medium') // 'none' | 'asvgf' | 'nrd' | 'edgeaware' | 'oidn'
engine.denoisingManager.denoiserStrategy // read back the active strategy (derived from stage state)
engine.denoisingManager.setASVGFEnabled(true, 'medium')
engine.denoisingManager.applyASVGFPreset('high') // 'low' | 'medium' | 'high'
engine.denoisingManager.setAutoExposure(true)
// Fine-grained parameters
engine.denoisingManager.setASVGFParams({ temporalAlpha: 0.1, maxAccumFrames: 16 })
engine.denoisingManager.setEdgeAwareParams({ phiLuminance: 4.0, atrousIterations: 5 })
engine.denoisingManager.setAutoExposureParams({ keyValue: 0.18 })
// OIDN & Upscaler
engine.denoisingManager.setOIDNEnabled(true)
engine.denoisingManager.setOIDNQuality('high')
engine.denoisingManager.setStrategy('oidn') // OIDN owns the live view; see below
engine.denoisingManager.setTemporalHistory(false) // live OIDN without the motion history (default on)
engine.denoisingManager.continuousDenoiseInterval = 250 // cap refreshes at 4/sec (default 8 = uncapped)
engine.denoisingManager.setUpscalerEnabled(true)
engine.denoisingManager.setUpscalerScaleFactor(2) // 2 or 4
engine.denoisingManager.setUpscalerQuality('quality') // 'fast' | 'balanced' | 'quality'engine.interactionManager
Object picking and interaction modes.
engine.interactionManager.select(object) // Programmatically select an object
engine.interactionManager.deselect() // Deselect the current object
engine.interactionManager.toggleSelectMode() // Toggle object selection mode
engine.interactionManager.disableMode() // Disable selection mode and detach gizmo
engine.interactionManager.toggleFocusMode() // Toggle click-to-focus DOF
engine.interactionManager.on(type, handler) // Subscribe (returns unsubscribe function)engine.transformManager
Transform gizmo controls.
engine.transformManager.setMode('translate') // 'translate' | 'rotate' | 'scale'
engine.transformManager.setSpace('world') // 'world' | 'local'
engine.transformManager.controls // Access the underlying TransformControlsMoving and Deforming Objects
Three calls update a loaded scene without rebuilding it. They differ in how much work they do, and picking the wrong one is the usual source of trouble.
engine.updateMeshTransforms(meshIndices) // an object moved, rotated or scaled
engine.refitBLASes(meshIndices, positions) // specific objects' vertices changed
await engine.refitBVH(positions) // the whole scene is posed anew, e.g. animationupdateMeshTransforms is the one a gizmo drag wants. Triangles are stored in each object's own
space, so a rigid move only rewrites a matrix — no vertex pass, no geometry upload. Using
refitBLASes for a move instead rewrites vertices needlessly, and drags along any other object
sharing the same geometry.
All three take indices into engine.sceneMeshes, which is a depth-first walk of the rendered
scene and includes the engine's own hidden ground disk. Build your index list from that array, never
from your own model root, or the two orders silently disagree.
Positions are world space, 9 floats per triangle (ax,ay,az, bx,by,bz, cx,cy,cz), triangles in
index order. Two shapes are accepted:
// Preferred: a per-mesh callback, asked for one mesh at a time. You may hand back the same
// scratch buffer on every call.
await engine.refitBVH((meshIndex, triCount) => myPositionsFor(meshIndex));
// Also works: one array for every triangle in the scene, meshes in sceneMeshes order.
// 1,030 MB at 30M triangles, and will not allocate at that size — prefer the callback.
await engine.refitBVH(sceneWideFloat32Array);Both shapes are length-checked and throw on a mismatch. Before that check existed, a short buffer wrote NaN through every bounding box with no error and the scene simply vanished.
An object that shares its geometry with another cannot be deformed — writing its vertices would
move every copy. refitBLASes skips such a mesh and records a refit.shared_geometry issue.
Anything skinned or morphed is given triangles of its own at load, so this only fires when the
wrong mesh was handed over.
Degradation contract
The engine degrades rather than fails, which is right for a viewer and backwards for a batch renderer, so one option decides which you get:
const engine = new PathTracerApp(canvas, { strict: true }); // throw at the point of degradationLenient hosts read the log instead:
engine.issues // every recorded issue, newest last
engine.issueErrors // just the ones a strict host would have thrown on
engine.addEventListener(EngineEvents.ISSUE, ({ issue }) => report(issue));Each issue carries { code, message, detail, severity, at }. ISSUE_CODES is add-only API
surface — pin a version and branch on the strings; they are never renamed or repurposed.
| Code | Raised when |
|---|---|
| adapter.software | the GPU is a software rasteriser (SwiftShader, llvmpipe, lavapipe, WARP) |
| asset.unreachable / asset.ambiguous_entry | the asset could not be fetched, or an archive held several candidate models |
| asset.archive_too_large / asset.entry_too_large | an archive or one of its entries exceeded the byte budget |
| texture.build_failed / texture.processing_fallback / texture.limit_exceeded | a texture could not be built, fell back to a slower path, or exceeded the per-map-type cap |
| environment.load_failed | the environment map failed to load |
| setting.unknown_key | a setting name reached no stage — how a typo becomes a wrong image |
| render.size_declined / render.reserve_capped | the requested render size or reserve exceeded device limits |
| stage.render_failed | a pipeline stage threw (recorded once per stage and phase) |
| scene.memory_budget | the scene needs more CPU memory than is safe, or more than is possible |
| emissive.instances_collapsed | an emissive instanced mesh was too large to expand, so its copies light the scene as one |
| refit.shared_geometry | a deform was asked for on a mesh that shares its triangles, and was skipped |
| denoiser.unavailable | a requested denoise or upscale produced nothing — the denoiser was not built, OIDN was off while accumulating, or the pass needs a canvas in a document |
| output.source_fallback | renderToBuffer( { source: 'display' } ) found no denoised picture and returned the raw accumulation |
| output.tonemap_fallback | renderToBuffer's 'srgb' bytes were tone-mapped on the CPU, not the GPU — the picture is the same within a level, only slower (a warning: strict does not throw) |
| light.placeholder_skipped | a RectAreaLightPlaceholder node lacked userData.name or userData.type: 'RectAreaLight', so no light was made for it |
| capability.missing | a feature was asked for whose add-on is not installed on the core (procedural sky, an unregistered integrator, a colour config, storage) — names the add-on |
| color.config_load_failed / viewTransform.bake_failed / viewTransform.display_mismatch | a colour config failed to load (the previous one stays), a view could not be rebaked, or a view targets a display the canvas cannot show (a saved buffer is still right) — warnings |
| storage.unavailable / storage.quota_exceeded / storage.write_failed / storage.read_failed / storage.entry_corrupt / storage.cache_mismatch | on-disk storage is off or failed; the engine carries on in memory — warnings |
asset.unreachable also covers what the engine fetches for itself: OIDN weights, IES profiles and
gobos (detail.asset says which).
settings.getEffective() is the companion for the setting.unknown_key case: it returns every live
setting as { value, source, routed }, and routed: false means stored but reaching no stage.
Output Methods
Canvas output, screenshots, and scene statistics — accessed as direct methods on the engine.
engine.getCanvas() // Get the canvas with the final rendered image
const blob = await engine.screenshot() // Capture frame as Blob (default 'image/png')
const jpg = await engine.screenshot({ type: 'image/jpeg', quality: 0.9 })
engine.getStatistics() // Triangle count, mesh count, etc.
engine.setCanvasSize(1920, 1080) // Set explicit canvas dimensions
engine.onResize() // Trigger manual resize recalculation
engine.isComplete() // Check if rendering has converged
engine.getFrameCount() // Get the current accumulated frame count
engine.getMemoryInfo() // GPU memory snapshot: { current, peak, byCategory } in bytesscreenshot() returns a Blob for the host to save, upload, or display. To trigger a browser download:
const blob = await engine.screenshot();
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: 'render.png' });
a.click();
URL.revokeObjectURL(url);Render Resolution Reserve
Every compute StorageTexture and aux buffer is pre-allocated at one square dimension — the reserve — and setCanvasSize() refuses anything larger. The default is 2048, so 4K output needs the reserve raised first.
engine.setReservedRenderResolution(4096) // raise to 4K (longest edge)
engine.setReservedRenderResolution(2048, { allowLower: true }) // lower, paying a rebuild, to reclaim VRAM
engine.getReservedRenderResolution() // the reserve actually in forceThe request is device-capped: a 4096 reserve pins roughly 1.5 GB of MRT textures, so it is only granted on hosts reporting ≥ 8 GB and a ≥ 1 GB maxStorageBufferBindingSize; weaker devices clamp to 2048, recorded as render.reserve_capped. The memory figure is options.hostMemoryGB, else navigator.deviceMemory, else an assumed 4 — so outside Chrome, pass it. MAX_RESERVABLE_RENDER_SIZE (4096) is the ceiling on any request.
Raises are monotonic unless you pass allowLower — UI-driven callers ask for whatever the current view needs, and honouring every decrease made the reserve oscillate across preview↔render switches, paying a full kernel rebuild each time.
Callable at any point in the lifecycle:
- Before
init()— recorded and applied duringinit(), after the device exists but before the stages are constructed, so they allocate at the raised size directly. The device gate cannot run without a device, so the return value here is the request, not the verdict. - After
init()— applied immediately, re-initialising the reserved GPU storage in place.
Either way the verdict arrives as EngineEvents.RESERVED_RENDER_SIZE_CHANGED:
engine.addEventListener(EngineEvents.RESERVED_RENDER_SIZE_CHANGED, e => console.log('reserve:', e.size));
engine.setReservedRenderResolution(4096);
await engine.init();
console.log(engine.getReservedRenderResolution()); // 4096, or 2048 if the device declinedMemory Monitoring
Track GPU (VRAM) usage across the whole pipeline. Sizes are measured from live GPU resources (buffer byteLength + texture dimensions × format), so they are exact, not estimated.
const { current, peak, byCategory } = engine.getMemoryInfo(); // bytes
// byCategory: { rays, queues, gbuffer, accum, geometry, materials, environment, stages, denoiser, canvas }
engine.vram.resetPeak(); // reset the high-water mark to the current value
engine.vram.getReport(); // formatted one-line summary stringpeak is a high-water mark, reset when a final render begins (configureForMode('production')). The engine's VRAM is largely monotonic — the ray pool only grows and the per-stage storage textures are fixed-size — so peak equals current during a steady render and only exceeds it after memory is released (lower resolution, a smaller scene, or removing the HDRI). The stages + accum categories (fixed 2048² storage textures) dominate the baseline.
denoiser is what the engine allocates for OIDN — its three float inputs, its half-float output and
the motion history. canvas is one image per presented surface (a browser may keep one or two more)
plus the buffer three.js's output pass tone-maps through. Not counted: oidn-web's own network weights
and activations, which it reports by count, not by size; and the neural passes, which run on a
GPUDevice of their own.
The React app surfaces this as a Memory: … | Peak: … readout in the on-canvas stats overlay.
CPU memory
The wall a large scene hits is not VRAM, it is contiguous ArrayBuffer address space on the CPU —
and how much of it a browser can still hand out falls as the tab stays up, so the same scene can
load after a restart and fail after a long session.
const { preflight, allocatedBytes, peakLiveBytes, byPhase, samples } = engine.getHostMemoryInfo();
// null until a scene has been built⚠️ Do not use performance.memory.usedJSHeapSize for this. It does not count SharedArrayBuffer,
and the triangle and node stores are SAB-backed, so the browser's own reading under-reports a large
scene by gigabytes.
Before extraction the engine prices the scene and applies two lines, both recording
scene.memory_budget:
| Estimate | What happens | |---|---| | above ~7,040 MB | warns, and builds anyway | | above ~9,216 MB | throws — past this the renderer process is killed rather than throwing an error you could catch, so refusing early is the only useful answer |
Raise or lower the hard line with new PathTracerApp(canvas, { maxSceneBytes }). The estimate runs
low at the very top of its range, so the per-load maxTriangles cap (45M) is the more reliable
guard on a scene of that size. With memorySpill the estimate leaves out what the build keeps on
disk (the BVH, and triangle records past what a streamed build holds at once).
Logging
Leveled, namespaced console output, shared with the engine's Web Workers. The default level is info, which hides per-mesh and per-texture detail; drop to debug to see it.
import { Logger, createLogger, fmt, LOG_LEVELS } from 'rayzee';
Logger.setLevel('debug'); // 'silent' | 'error' | 'warn' | 'info' | 'debug'
Logger.getLevel();
Logger.isEnabled('debug'); // gate expensive message construction
Logger.only('bvh', 'gpu'); // restrict debug to these