rayzee
v6.2.0
Published
Real-time WebGPU path tracing engine built on Three.js
Maintainers
Readme
Rayzee Engine
A real-time WebGPU path tracing engine built on Three.js. Framework-agnostic — use it with React, Vue, vanilla JS, or any other setup.
Installation
npm install rayzee threethree (>=0.183.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('bounces', 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 takes ownership — pass a clone if the host still renders it
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:
loadObject3Dtakes ownership of the passedObject3D(sets it as the active model, disposes the previous one). If your host app continues to render the same scene graph, passscene.clone(true)— deep-cloning shares geometry/texture data, so memory cost is small. Clone once on first toggle, not on every switch.- 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 STBN blue-noise atlases, 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({
// STBN atlases (PNG, decoded as Float textures)
stbnScalarAtlas: '/assets/stbn_scalar_atlas.png',
stbnVec2Atlas: '/assets/stbn_vec2_atlas.png',
// 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/',
// Prefix for engine-managed IndexedDB stores. Set to a unique value if multiple
// apps embed the engine on the same origin to avoid cache collisions.
cacheNamespace: 'my-app',
});
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 Three.js 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 |
| options.autoResize | boolean | Auto-resize on window resize (default: true) |
| options.container | HTMLElement | Single DOM parent the engine mounts auxiliary elements into — HUD overlay (tile borders, helpers) and denoiser canvas. Defaults to canvas.parentNode. |
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.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 GLB/GLTF/FBX/OBJ/STL/PLY/DAE/3MF/USDZ/ZIP
await engine.loadObject3D(object3d) // Load a Three.js Object3D directly
await engine.loadEnvironment(url) // Load HDR/EXR environment mapSettings
engine.settings.set('bounces', 8) // Set a single parameter
engine.settings.setMany({ // Set multiple parameters at once
bounces: 8,
samplesPerPixel: 1,
exposure: 1.0
})
engine.settings.get('bounces') // Read a parameter
engine.settings.getAll() // Get all current settingsKey settings:
| Setting | Type | Default | Description |
|---|---|---|---|
| bounces | number | 3 | Max ray bounce depth |
| samplesPerPixel | number | 1 | Samples per pixel per frame |
| maxSamples | number | 60 | Max accumulated samples before stopping |
| exposure | number | 1.0 | Exposure value |
| saturation | number | 1.2 | Color saturation |
| enableEnvironment | boolean | true | Use environment lighting |
| environmentIntensity | number | 1.0 | Environment light strength |
| environmentRotation | number | 270 | Environment Y-rotation (degrees) |
| fireflyThreshold | number | 3.0 | Firefly clamping threshold |
| transmissiveBounces | number | 5 | Max bounces for transmissive materials |
| enableDOF | boolean | false | Enable depth of field |
| focusDistance | number | 0.8 | DOF focus distance |
| aperture | number | 5.6 | DOF aperture (f-stop) |
| focalLength | number | 50 | DOF focal length (mm) |
| adaptiveSampling | boolean | false | Variance-guided sample distribution |
| transparentBackground | boolean | false | Transparent canvas background |
| interactionModeEnabled | boolean | true | Lower quality during camera movement for smoother navigation |
| debugMode | number | 0 | Debug visualization mode (0 = off) |
| environmentMode | string | 'hdri' | Sky mode: 'hdri' | 'procedural' | 'gradient' | 'color' |
See ENGINE_DEFAULTS for the full list with default values.
Rendering Modes
engine.configureForMode('production') // High quality (tiled, 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.
engine.cameraManager
Camera switching, auto-focus, DOF, and direct Three.js access.
engine.cameraManager.active // The active PerspectiveCamera
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.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.sync() // Re-upload light data to GPU
engine.lightManager.showHelpers(true) // Toggle visual helpersengine.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 clipsMaterials
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)
// 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()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' | 'gradient' | 'color'
await engine.environmentManager.generateProcedural() // Preetham-model sky
await engine.environmentManager.generateGradient() // Gradient sky
await engine.environmentManager.generateSolid() // Solid color sky
engine.environmentManager.markDirty() // Flag environment for GPU re-uploadengine.denoisingManager
Denoiser strategy, ASVGF, OIDN, upscaler, adaptive sampling, and auto-exposure.
// Strategy
engine.denoisingManager.setStrategy('asvgf', 'medium') // 'none' | 'asvgf' | 'ssrc' | 'edgeaware'
engine.denoisingManager.setASVGFEnabled(true, 'medium')
engine.denoisingManager.applyASVGFPreset('high') // 'low' | 'medium' | 'high'
engine.denoisingManager.setAutoExposure(true)
engine.denoisingManager.setAdaptiveSampling(true)
// Fine-grained parameters
engine.denoisingManager.setASVGFParams({ temporalAlpha: 0.1, phiColor: 10 })
engine.denoisingManager.setSSRCParams({ temporalAlpha: 0.1, spatialRadius: 3 })
engine.denoisingManager.setEdgeAwareParams({ pixelEdgeSharpness: 1.0 })
engine.denoisingManager.setAutoExposureParams({ keyValue: 0.18 })
engine.denoisingManager.setAdaptiveSamplingParams({ varianceThreshold: 0.01 })
// OIDN & Upscaler
engine.denoisingManager.setOIDNEnabled(true)
engine.denoisingManager.setOIDNQuality('high')
engine.denoisingManager.setUpscalerEnabled(true)
engine.denoisingManager.setUpscalerScaleFactor(2)
engine.denoisingManager.setUpscalerQuality('high')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 TransformControlsOutput 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 countscreenshot() 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);Events
Subscribe to engine lifecycle events via addEventListener:
import { EngineEvents } from 'rayzee';
engine.addEventListener(EngineEvents.RENDER_COMPLETE, (e) => {
console.log('Render complete');
});| Event | Fired when |
|---|---|
| RENDER_COMPLETE | Rendering has converged |
| RENDER_RESET | Accumulation buffer is reset |
| FRAME | Fires once per animate() tick — hook external instrumentation (stats panels, telemetry) here |
| DENOISING_START / DENOISING_END | Denoiser runs |
| UPSCALING_START / UPSCALING_PROGRESS / UPSCALING_END | AI upscaler runs |
| LOADING_UPDATE / LOADING_RESET | Asset loading progress |
| STATS_UPDATE | Performance stats updated |
| OBJECT_SELECTED / OBJECT_DESELECTED | Object selection changes |
| OBJECT_DOUBLE_CLICKED | Object double-clicked |
| OBJECT_TRANSFORM_START / OBJECT_TRANSFORM_END | Transform gizmo drag |
| TRANSFORM_MODE_CHANGED | Gizmo mode changed |
| SELECT_MODE_CHANGED | Selection mode toggled |
| SETTING_CHANGED | A render setting is modified |
| AUTO_FOCUS_UPDATED | Auto-focus recalculated |
| AUTO_EXPOSURE_UPDATED | Auto-exposure recalculated |
| AF_POINT_PLACED | Focus point placed on screen |
| ANIMATION_STARTED / ANIMATION_PAUSED / ANIMATION_STOPPED / ANIMATION_FINISHED | Animation lifecycle |
| VIDEO_RENDER_PROGRESS / VIDEO_RENDER_COMPLETE | Video export progress |
| DISPOSE | Engine is being disposed (fires before teardown begins, so listeners can release their own references) |
Advanced: Custom Pipeline Stages
Build custom rendering stages by extending RenderStage:
import { RenderStage } from 'rayzee';
class MyCustomStage extends RenderStage {
constructor() {
super('my-stage');
}
render(context, writeBuffer) {
const input = context.getTexture('pathtracer:color');
// ... process input, write output
context.setTexture('my-stage:output', this.outputTexture);
}
}All Exports
// Core
import { PathTracerApp, EngineEvents } from 'rayzee';
// Configuration & presets
import {
ENGINE_DEFAULTS,
ASVGF_QUALITY_PRESETS,
CAMERA_PRESETS,
CAMERA_RANGES,
SKY_PRESETS,
AUTO_FOCUS_MODES,
AF_DEFAULTS,
TRIANGLE_DATA_LAYOUT,
BVH_LEAF_MARKERS,
TEXTURE_CONSTANTS,
DEFAULT_TEXTURE_MATRIX,
MEMORY_CONSTANTS,
PRODUCTION_RENDER_CONFIG,
INTERACTIVE_RENDER_CONFIG,
} from 'rayzee';
// Asset URL / cache namespace overrides
import { configureAssets, getAssetConfig } from 'rayzee';
// Advanced: managers & pipeline
import {
RenderSettings,
CameraManager,
LightManager,
DenoisingManager,
OverlayManager,
AnimationManager,
TransformManager,
VideoRenderManager,
InteractionManager,
RenderPipeline,
RenderStage,
StageExecutionMode,
PipelineContext,
} from 'rayzee';Browser Requirements
- WebGPU support (Chrome 113+, Edge 113+, Safari 18+, Firefox 141+)
- Secure context (HTTPS or localhost)
Optional Dependencies
| Package | Purpose | Install needed? |
|---|---|---|
| oidn-web | Intel Open Image Denoise for high-quality final renders | Yes — npm install oidn-web |
| onnxruntime-web | AI-powered upscaling | No — loaded from CDN at runtime |
Enabling OIDN (Intel Open Image Denoise)
OIDN provides high-quality AI denoising for final renders. It runs automatically after the render converges (reaches maxSamples).
Install the package
npm install oidn-webEnable in your app
// After engine.init() completes engine.denoisingManager.setOIDNEnabled(true); engine.denoisingManager.setOIDNQuality('balance'); // 'fast' | 'balance' | 'high'Listen for progress (optional)
engine.addEventListener(EngineEvents.DENOISING_START, () => { console.log('Denoising started'); }); engine.addEventListener(EngineEvents.DENOISING_END, () => { console.log('Denoising complete'); });
| Quality | Model size | Speed | Best for |
|---|---|---|---|
| 'fast' | ~20 MB | Fastest | Quick previews |
| 'balance' | ~50 MB | Moderate | General use (default) |
| 'high' | ~100 MB | Slowest | Final quality renders |
Note: The neural network model is downloaded on first use. Subsequent runs use the browser cache. OIDN also works with
configureForMode('production'), which enables it automatically alongside high-quality render settings.
Enabling the AI Upscaler
The upscaler runs ONNX super-resolution models via onnxruntime-web. Unlike OIDN, onnxruntime-web is lazily fetched from a CDN inside a Web Worker — no npm install or import map entry is needed.
engine.denoisingManager.setUpscalerEnabled(true);
engine.denoisingManager.setUpscalerQuality('fast'); // 'fast' | 'balanced' | 'quality'
engine.denoisingManager.setUpscalerScaleFactor(2); // 2 | 4
engine.addEventListener(EngineEvents.UPSCALING_START, () => console.log('Upscaling started'));
engine.addEventListener(EngineEvents.UPSCALING_PROGRESS, (e) => console.log('Upscaling', e));
engine.addEventListener(EngineEvents.UPSCALING_END, () => console.log('Upscaling complete'));| Quality | Model | 2× size | 4× size |
|---|---|---|---|
| 'fast' | SPAN | 1.6 MB | 1.6 MB |
| 'balanced' | SRVGGNetCompact | 2.4 MB | 4.9 MB |
| 'quality' | RRDBNet / MoSR | 67 MB | 16.5 MB |
Chaining with OIDN: Upscaling and OIDN can run together — on render completion, OIDN runs first, then its denoised output is fed into the upscaler. Enable both; no manual coordination required.
Troubleshooting
OIDN: Cannot find module './tza' (webpack)
The oidn-web package uses dynamic imports that webpack cannot resolve. This does not affect Vite or other ESM-native bundlers. Add oidn-web to your webpack externals:
// webpack.config.js
module.exports = {
externals: {
'oidn-web': 'oidn-web'
}
};Then load it via a script tag or import map instead:
<script type="importmap">
{
"imports": {
"oidn-web": "https://cdn.jsdelivr.net/npm/[email protected]/dist/oidn.js"
}
}
</script>OIDN: TypeError: t.alea is not a function or Argument 'x' passed to 'conv2d' must be a Tensor ... got 'L'
You're loading oidn-web via jsdelivr.net/npm/oidn-web/+esm or esm.sh/oidn-web. Don't — use the self-bundled /dist/oidn.js path instead:
- "oidn-web": "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
+ "oidn-web": "https://cdn.jsdelivr.net/npm/[email protected]/dist/oidn.js"Why: oidn-web transitively depends on @tensorflow/tfjs-core, which does import * as t from "seedrandom" and calls t.alea(...). jsDelivr's /+esm CJS→ESM shim of seedrandom emits only export default (no named exports), so t.alea is undefined. Swapping to esm.sh trades that for a different issue: deep-path imports produce multiple tfjs-core instances, so a Tensor made in one module fails instanceof checks in another (got 'L'). The /dist/oidn.js file in the npm package is a single pre-bundled ESM with all of tfjs inlined — no external imports, one tfjs instance, same exports. Use it.
Black screen / "WebGPU not supported" Your browser may not support WebGPU. Use Chrome 113+, Edge 113+, Safari 18+, or Firefox 141+. Ensure you're on HTTPS or localhost.
Models not loading
If serving locally, place files in your public/ folder and reference them with absolute paths (e.g., /scene.glb). For remote files, ensure the server allows CORS.
License
MIT
