Build an ANARI scene
Why use a retained rendering API?
A conventional luma.gl application typically creates geometries, shader modules, models, render pipelines, and render passes directly. That is ideal when an application needs detailed control over the GPU.
An ANARI-style application instead declares scene intent:
geometry + material → surface
surface + light → group
group + transform → instance
instances + lights → world
world + camera + renderer → frame
The renderer chooses model creation, shader selection, instance batching, light uniform updates, render passes, bloom, and compatible backend implementations. The application can update retained objects without manually rewriting GPU binding logic.
This is particularly useful when an application should expose a stable scene contract to multiple visualization tools, scene importers, or potential rendering backends.
Install the package
@luma.gl/scene is published as an experimental package:
yarn add @luma.gl/scene
At least one luma.gl backend is required. Use @luma.gl/webgpu for modern browsers and optional HDR presentation, and add @luma.gl/webgl when a WebGL 2 fallback is important.
Create a graphics device
Start with an ordinary luma.gl Device and wrap it in ANARIDevice:
import {ANARIDevice} from '@luma.gl/scene';
import {luma} from '@luma.gl/core';
import {webgpuAdapter} from '@luma.gl/webgpu';
import {webgl2Adapter} from '@luma.gl/webgl';
const canvas = document.querySelector('canvas');
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error('The application requires a canvas element');
}
const graphicsDevice = await luma.createDevice({
adapters: [webgpuAdapter, webgl2Adapter],
createCanvasContext: {canvas}
});
const anariDevice = new ANARIDevice(graphicsDevice);
Adapter order determines preference: this example uses WebGPU when available and falls back to WebGL 2.
ANARIDevice wraps the graphics device but does not own it. Your application remains responsible for graphics-device setup, canvas presentation, command submission, and final destruction.
Build your first scene
The following complete scene adds a metallic sphere, floor, directional light, animated point light, camera, renderer, and animation loop. It assumes a page containing <canvas></canvas>.
import {ANARIDevice} from '@luma.gl/scene';
import {luma} from '@luma.gl/core';
import {webgpuAdapter} from '@luma.gl/webgpu';
import {webgl2Adapter} from '@luma.gl/webgl';
import {Matrix4} from '@math.gl/core';
const canvas = document.querySelector('canvas');
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error('Add a canvas element before starting the renderer');
}
const graphicsDevice = await luma.createDevice({
adapters: [webgpuAdapter, webgl2Adapter],
createCanvasContext: {canvas}
});
const anariDevice = new ANARIDevice(graphicsDevice);
const sphereGeometry = anariDevice.newGeometry('sphere', {
radius: 0.9,
segments: 32
});
const sphereMaterial = anariDevice.newMaterial('physicallyBased', {
baseColor: [0.18, 0.52, 0.96],
metallic: 0.85,
roughness: 0.18,
clearcoat: 0.25
});
const sphereSurface = anariDevice.newSurface({
geometry: sphereGeometry,
material: sphereMaterial
});
const floorSurface = anariDevice.newSurface({
geometry: anariDevice.newGeometry('quad', {width: 12, height: 12}),
material: anariDevice.newMaterial('matte', {
color: [0.16, 0.18, 0.24]
})
});
const sphereGroup = anariDevice.newGroup({surface: [sphereSurface]});
const sphereInstance = anariDevice.newInstance({
group: sphereGroup,
transform: new Matrix4().translate([0, 1, 0])
});
const sunlight = anariDevice.newLight('directional', {
direction: [-0.6, -1, -0.4],
color: [1, 0.9, 0.76],
irradiance: 2.5
});
const orbitingLight = anariDevice.newLight('point', {
position: [3, 2, 0],
color: [1, 0.32, 0.12],
intensity: 28
});
const world = anariDevice.newWorld({
surface: [floorSurface],
instance: [sphereInstance],
light: [sunlight, orbitingLight]
});
const camera = anariDevice.newCamera('perspective', {
position: [0, 3, 8],
direction: [0, -2, -8],
fovy: Math.PI / 4
});
const renderer = anariDevice.newRenderer('default', {
background: [0.012, 0.016, 0.04, 1],
ambientRadiance: 0.12,
exposure: 1.5,
bloomIntensity: 0.45
});
const frame = anariDevice.newFrame({world, camera, renderer});
function render(milliseconds: number): void {
const seconds = milliseconds / 1000;
orbitingLight
.setParameter('position', [Math.cos(seconds) * 3, 2, Math.sin(seconds) * 3])
.commitParameters();
frame.render();
graphicsDevice.submit();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
The floor is attached directly to the world, so it uses the identity transform. The sphere is attached through a reusable group and transform instance.
Try the deferred renderer
Use newRenderer('deferred') when you want the ANARI scene rendered through the experimental WebGPU G-buffer and deferred lighting path:
const renderer = anariDevice.newRenderer('deferred', {
ambientRadiance: 0.08,
background: [0.006, 0.008, 0.018, 1]
});
The deferred renderer shares ANARI scene traversal, generated geometry, instance transforms, and PBR material textures with the default renderer, then resolves lighting through @luma.gl/experimental GBuffer and deferredLighting. This first path is intentionally limited to opaque material channels and direct lights; clustered lighting and screen-space effects remain separate follow-up work.
The compact G-buffer runs within the default WebGPU CORE limit of 32 color-attachment bytes per
sample. Its four targets retain HDR scene color (rgba16float), normal and roughness (rgba8unorm),
base color and metallic (rgba8unorm), and HDR emissive color with occlusion (rgba16float). Each
format costs eight render-target bytes under WebGPU accounting, for exactly 32 bytes total. ANARI's
previous velocity target contained only zeroes and was never consumed, so omitting it preserves
physically based direct lighting, HDR, and emissive response without requesting elevated device
limits. Temporal motion-vector effects remain future work.
Try the graph-based ray tracer
On WebGPU, switch the same retained scene to the software ray-tracing renderer:
const renderer = anariDevice.newRenderer('raytrace', {
samplesPerPixel: 1,
maxBounces: 1,
progressive: true,
shadows: true,
resolutionScale: 0.5,
minimumResolutionScale: 0.25,
adaptiveResolution: true,
targetFrameTimeMilliseconds: 33.3,
temporalReprojection: true,
shadowSamplesPerFrame: 1
});
frame.setParameter('renderer', renderer).commitParameters();
The ANARI adapter passes committed scene descriptors to RayTracingSceneRenderer from
@luma.gl/experimental. Its WebGPU compute graph derives world-space bounds for transformed
analytic spheres and mesh instances, Morton-sorts active object/instance leaves into an explicit
retained permutation, builds and refits a graph-owned complete-binary TLAS, and traverses that
hierarchy for nearest-hit primary rays and early-exit shadow rays. Transform-only animation gathers
updated bounds through the retained permutation and refits the TLAS without sorting; topology
changes and periodic spatial refreshes rebuild the Morton order. A topology-only graph also
Morton-sorts each mesh's triangles into GPU-built BLASes, which transform-only animation reuses.
Small mesh permutations and hierarchies sharing packed scene storage are grouped into reusable
segmented sort and BVH dispatches instead of opening separate sorting and hierarchy dispatches for
every mesh.
The instance-bounds pass reads each mesh BLAS root directly, producing a tight transformed local
AABB instead of expanding elongated meshes to their enclosing sphere. Small sorts and hierarchy
builds execute inside one workgroup; larger sorts use stable four-bit radix passes, and consecutive
compute nodes share a command-encoder compute pass when per-node GPU timestamps are not requested.
The same GPUCommandGraph evaluates direct lights with scalar metallic/roughness GGX shading,
progressively accumulates unchanged primary-ray samples, and presents into either the canvas or a
caller-owned offscreen framebuffer. The fullscreen presentation matches the actual target format,
selected tone-mapping mode, and linear/sRGB output encoding, preserving HDR for floating-point
targets. Generated quads, cylinders, and cones use their existing triangle geometry.
Ray tracing starts at half the display width and height, reducing its initial pixel workload to one
quarter of full resolution. Adaptive quality can lower that scale to 0.25, interleave sampled
pixels across animation frames, and rotate one shadowed direct light per frame to approach the
default 33.3 millisecond frame budget. The fullscreen resolve upsamples the retained HDR image.
Temporal reprojection follows camera and stable instance motion while rejecting incompatible depth,
normal, and color history; camera cuts, topology changes, light-count changes, and resolution
changes reset invalid history. Retained color and surface-metadata texture pairs exchange their
previous/current graph roles after each successful encoding, eliminating the former four full-image
history copies. Interleaved frames carry only their untouched pixels through a small coalesced
compute dispatch; full-coverage frames perform no history carry. Set shadowSamplesPerFrame: 0 to
evaluate every direct light in one frame. Adaptive timing uses smoothed animation-frame intervals
and does not require GPU timestamp queries. Acceleration updates run only when retained transforms
or geometry change, so camera-only and lighting-only frames do not rebuild the TLAS. Transform-only
frames use the retained-permutation gather/refit path; topology changes and periodic refreshes run
the Morton sort path. The ANARI adapter caches normalized surfaces, materials, lights, and analytic
primitives by committed world identity. Categorized topology, transform, material, and light
revisions let camera-only frames reuse those descriptors without serializing the entire scene;
committed instance changes additionally expose their exact stable placement identities.
The Morton-sorted TLAS indexes objects and instances; surviving meshes traverse GPU-built,
Morton-sorted per-mesh triangle BLASes. The ray-tracing pass uses exactly eight storage buffers,
while every TLAS or BLAS construction pass stays within the default WebGPU CORE limit of eight
storage buffers without elevated device features. The hierarchy topology is not SAH-optimized or a
Karras-style LBVH. This is software ray tracing, not hardware ray tracing or full path tracing.
Skeletal skinning, morph-target deformation, material textures, alpha/transmission, more advanced
PBR material extensions, indirect bounces, denoising, and volume objects remain unsupported by
this renderer.
maxBounces is reserved for future multi-bounce transport.
Understand staging and commits
Every retained object has pending and committed parameters. Initial constructor parameters are committed automatically, but later changes are invisible until committed:
const material = anariDevice.newMaterial('physicallyBased', {roughness: 0.6});
material.setParameter('roughness', 0.1);
material.getParameter('roughness'); // Still 0.6.
material.commitParameters();
material.getParameter('roughness'); // Now 0.1.
Batch related changes before committing:
material
.setParameters({
baseColor: [1, 0.68, 0.18],
metallic: 0.95,
roughness: 0.12
})
.commitParameters();
Removing a parameter also requires a commit:
material.unsetParameter('clearcoat').commitParameters();
Commit the object you actually changed. Updating a light requires committing the light; replacing a frame's renderer requires committing the frame:
orbitingLight.setParameter('intensity', 45).commitParameters();
frame.setParameter('renderer', debugRenderer).commitParameters();
Each commit increments the object's version. Geometry versions invalidate cached GPU geometry; material and light values are reread during rendering.
Create geometry
Procedural geometry subtypes are sphere, cylinder, cone, and quad:
const sphere = anariDevice.newGeometry('sphere', {radius: 1, segments: 32});
const cylinder = anariDevice.newGeometry('cylinder', {radius: 0.4, height: 2});
const cone = anariDevice.newGeometry('cone', {radius: 0.7, height: 1.4});
const ground = anariDevice.newGeometry('quad', {width: 20, height: 20});
For an explicit triangle mesh, provide packed positions and optionally normals, two texture coordinate sets, tangents, RGBA vertex colors, joint attributes, morph targets, and indices:
const triangleMesh = anariDevice.newGeometry('triangle', {
'vertex.position': new Float32Array([
-1, 0, 0,
1, 0, 0,
0, 1, 0
]),
'vertex.normal': new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
'vertex.attribute1': new Float32Array([0, 0, 1, 0, 0.5, 1]),
'vertex.attribute2': new Float32Array([0.25, 0, 0.75, 0, 0.5, 1]),
'primitive.index': new Uint16Array([0, 1, 2])
});
vertex.attribute1 and vertex.attribute2 map to glTF TEXCOORD_0 and TEXCOORD_1.
vertex.attribute0 retains RGB or RGBA vertex colors; vertex.tangent preserves tangent
handedness. Joint indices and normalized floating-point weights use vertex.joint and
vertex.weight. Morph targets contain optional POSITION, NORMAL, and TANGENT deltas;
changing morphWeights updates the existing packed GPU vertex buffer without rebuilding the model.
ANARIArray can wrap typed arrays without copying:
const positions = new Float32Array([-1, 0, 0, 1, 0, 0, 0, 1, 0]);
const positionArray = anariDevice.newArray({
data: positions,
elementType: 'float32x3'
});
const geometry = anariDevice.newGeometry('triangle', {
'vertex.position': positionArray
});
When changing array contents after the geometry has already rendered, commit the geometry to rebuild its cached GPU representation.
Build reusable surfaces and instances
A surface pairs one geometry with one material:
const surface = anariDevice.newSurface({
geometry: sphereGeometry,
material: sphereMaterial
});
Place the same surface many times by sharing a group:
const group = anariDevice.newGroup({surface: [surface]});
const instances = [];
for (let index = 0; index < 100; index++) {
const horizontal = (index % 10) * 2 - 9;
const depth = Math.floor(index / 10) * 2 - 9;
instances.push(
anariDevice.newInstance({
group,
transform: new Matrix4().translate([horizontal, 1, depth])
})
);
}
const world = anariDevice.newWorld({instance: instances, light: [sunlight]});
The runtime groups placements by surface object identity. One shared surface in 100 instances normally produces one luma.gl Model, one instanced draw, and 100 instance placements.
Avoid constructing a new ANARISurface for every placement when geometry and material can be shared. Distinct surface identities produce distinct compiled models and draw calls.
Transforms are 16-element column-major matrices. @math.gl/core's Matrix4 can compose translation, rotation, and scale:
const transform = new Matrix4()
.translate([4, 2, -3])
.rotateY(Math.PI / 4)
.scale([1, 2, 1]);
Use physically based materials
physicallyBased exposes metallic/roughness shading, emission, clearcoat, sheen, specular,
transmission/volume parameters, iridescence, anisotropy, and explicit alpha controls:
const polishedMetal = anariDevice.newMaterial('physicallyBased', {
baseColor: [0.92, 0.72, 0.24],
metallic: 1,
roughness: 0.1,
clearcoat: 0.3
});
const emissivePanel = anariDevice.newMaterial('physicallyBased', {
baseColor: [0.08, 0.1, 0.2],
emissive: [0.25, 0.7, 1],
emissiveStrength: 6,
roughness: 0.32
});
const matteWall = anariDevice.newMaterial('matte', {
color: [0.28, 0.3, 0.35]
});
const maskedLeaves = anariDevice.newMaterial('physicallyBased', {
alphaMode: 'mask',
alphaCutoff: 0.4,
doubleSided: true
});
All 21 supported glTF PBR texture slots map to retained image samplers. Base-color, emissive, specular-color, and sheen-color maps use sRGB inputs; normal, metallic/roughness, occlusion, and other data maps remain linear. Samplers preserve authored wrapping, filtering, mipmap selection, UV set, and texture transforms.
Transmissive physical materials retain their authored opaque alpha mode. When a surface has a
nonzero transmission factor, the shared forward renderer automatically captures the opaque
background and refracts that scene color using its index of refraction, thickness, roughness,
Fresnel response, and volume attenuation:
const glass = anariDevice.newMaterial('physicallyBased', {
alphaMode: 'opaque',
transmission: 1,
roughness: 0.08,
thickness: 0.4,
attenuationDistance: 2,
attenuationColor: [0.85, 0.96, 1],
indexOfRefraction: 1.5
});
The capture includes opaque, non-transmissive scene surfaces; layered glass is not recursively resolved. Renderer parameters can also receive the caller-owned prepared PBR lighting environment for roughness-aware diffuse/specular image-based lighting.
An emissive surface glows but does not illuminate neighboring surfaces by itself. Add a point or spot light when it should act as a visible light source.
Add and animate lights
World lights use world-space positions and directions:
const ambient = anariDevice.newLight('ambient', {radiance: 0.16});
const directional = anariDevice.newLight('directional', {
direction: [-1, -1, -0.4],
irradiance: 1.8
});
const point = anariDevice.newLight('point', {
position: [3, 2, 0],
color: [1, 0.25, 0.1],
intensity: 40
});
const spot = anariDevice.newLight('spot', {
position: [0, 6, 2],
direction: [0, -1, -0.25],
openingAngle: Math.PI / 6,
falloffAngle: Math.PI / 9,
intensity: 30
});
world.setParameter('light', [ambient, directional, point, spot]).commitParameters();
Animate the same retained light instead of replacing the world every frame:
function animate(seconds: number): void {
point
.setParameters({
position: [Math.cos(seconds) * 4, 2, Math.sin(seconds) * 4],
intensity: 30 + Math.sin(seconds * 2) * 8
})
.commitParameters();
}
The showcase pairs animated point lights with small emissive spheres so viewers can see where each light originates. Emissive geometry and light illumination are separate retained objects.
Select cameras and renderers
Perspective and orthographic cameras share position, direction, near, far, and aspect settings:
const perspective = anariDevice.newCamera('perspective', {
position: [0, 3, 10],
direction: [0, -2, -10],
fovy: Math.PI / 3
});
const orthographic = anariDevice.newCamera('orthographic', {
position: [0, 8, 12],
direction: [0, -7, -12],
height: 12
});
The renderer controls scene presentation:
const beauty = anariDevice.newRenderer('default', {
exposure: 1.5,
bloomIntensity: 0.75,
bloomThreshold: 0.65,
bloomRadius: 8,
fogColor: [0.02, 0.03, 0.07],
fogDensity: 0.00035
});
const normals = anariDevice.newRenderer('debugNormals');
const depth = anariDevice.newRenderer('debugDepth');
const deferred = anariDevice.newRenderer('deferred');
const raytrace = anariDevice.newRenderer('raytrace', {shadows: true});
Switch renderers by committing the frame:
frame.setParameter('renderer', normals).commitParameters();
Debug renderers automatically skip bloom. Switch to deferred for the WebGPU G-buffer path,
raytrace for WebGPU software ray tracing, or default for the portable forward renderer with
bloom.
Renderer presentation controls are ordinary committed ANARI parameters and are also accepted in JSON renderer descriptions:
beauty
.setParameters({toneMapMode: 2, outputColorSpace: 'srgb'})
.commitParameters();
toneMapMode selects no tone mapping (0), Reinhard (1), Khronos PBR Neutral (2), or ACES
(3). outputColorSpace selects 'linear' or 'srgb'. Omit either setting to retain automatic,
target-aware defaults: floating-point targets default to no tone mapping and linear output,
integer targets default to Khronos PBR Neutral, and hardware-sRGB attachments avoid an additional
software sRGB transfer.
Handle resizing
If frame.size is omitted, the renderer derives its size from the graphics device's current drawing buffer:
const frame = anariDevice.newFrame({world, camera, renderer});
When an integration provides drawing-buffer dimensions explicitly, update the frame during resize:
function resize(width: number, height: number): void {
frame.setParameter('size', [width, height]).commitParameters();
}
Bloom framebuffers are recreated when the requested frame size changes. Camera aspect defaults to width / height unless an explicit camera aspect is supplied.
HDR and backend selection
HDR requires all three pieces:
- An HDR-capable browser/display combination.
- A WebGPU canvas configured with a floating-point format, Display P3, and extended tone mapping.
- A rendering path that preserves values above SDR white.
import {luma} from '@luma.gl/core';
import {webgpuAdapter} from '@luma.gl/webgpu';
import {webgl2Adapter} from '@luma.gl/webgl';
const supportsHighDynamicRange = window.matchMedia('(dynamic-range: high)').matches;
const graphicsDevice = await luma.createDevice({
adapters: supportsHighDynamicRange
? [webgpuAdapter]
: [webgpuAdapter, webgl2Adapter],
createCanvasContext: supportsHighDynamicRange
? {
canvas,
colorFormat: 'rgba16float',
colorSpace: 'display-p3',
toneMapping: 'extended'
}
: {canvas}
});
The ANARI renderer detects graphicsDevice.preferredColorFormat === 'rgba16float' and retains radiance above SDR white instead of applying only the standard SDR compression curve. Bloom intermediates also use the preferred device color format.
(dynamic-range: high) indicates display support, not a guarantee that every browser accepts every HDR canvas option. Production applications may need capability checks, error handling, or a retry with an SDR canvas configuration.
WebGL 2 uses the portable SDR rendering path. The showcase accepts ?backend=webgl to force that fallback for comparison.
The user-visible HDR effect depends on the monitor, browser, operating-system display settings, surrounding page brightness, and whether scene lighting actually exceeds SDR white.
Integrate with AnimationLoop
The repository showcase uses AnimationLoopTemplate to coordinate drawing, resizing, and cleanup:
import {ANARIDevice} from '@luma.gl/scene';
import {
AnimationLoopTemplate,
makeAnimationLoop,
type AnimationProps
} from '@luma.gl/engine';
class SceneLoop extends AnimationLoopTemplate {
readonly anariDevice: ANARIDevice;
readonly frame;
readonly orbitingLight;
constructor({device}: AnimationProps) {
super();
this.anariDevice = new ANARIDevice(device);
const geometry = this.anariDevice.newGeometry('sphere');
const material = this.anariDevice.newMaterial('physicallyBased', {
baseColor: [0.22, 0.5, 0.9],
metallic: 0.8,
roughness: 0.2
});
const surface = this.anariDevice.newSurface({geometry, material});
this.orbitingLight = this.anariDevice.newLight('point', {
position: [3, 2, 0],
intensity: 35
});
const world = this.anariDevice.newWorld({
surface: [surface],
light: [this.orbitingLight]
});
const camera = this.anariDevice.newCamera('perspective', {
position: [0, 1.5, 6],
direction: [0, -0.5, -6]
});
const renderer = this.anariDevice.newRenderer('default', {
bloomIntensity: 0.5
});
this.frame = this.anariDevice.newFrame({world, camera, renderer});
}
override onRender({width, height, time}: AnimationProps): void {
const seconds = time / 1000;
this.orbitingLight
.setParameter('position', [Math.cos(seconds) * 3, 2, Math.sin(seconds) * 3])
.commitParameters();
this.frame.setParameter('size', [width, height]).commitParameters();
this.frame.render();
}
override onFinalize(): void {
this.frame.destroy();
this.anariDevice.destroy();
}
}
makeAnimationLoop(SceneLoop, {device: graphicsDevice}).start();
Avoid committing frame size every frame in a performance-sensitive application; compare the previous dimensions first, as the showcase does.
Inspect rendering statistics
const statistics = frame.render();
console.log({
distinctSurfaces: statistics.surfaceCount,
visiblePlacements: statistics.instanceCount,
drawCalls: statistics.drawCount,
renderedTriangles: statistics.triangleCount,
rayTracing: statistics.rayTracing
});
Use these numbers to verify batching behavior. If instanceCount is high but drawCount is similarly high, check whether each placement accidentally creates its own surface instead of reusing a retained surface.
Ray-traced frames additionally report their internal resolution and effective scale, sampled-pixel
coverage, smoothed frame time, and accumulated sample count. Other renderer subtypes omit
statistics.rayTracing.
When present, statistics.rayTracing.graph exposes the actual logical node count, physical compute
pass count, coalesced compute-node count, and synchronous CPU encoding time. Its trace stage is
present on every ray-traced frame; topology, acceleration, and refit appear only when the
corresponding work runs. acceleration and refit are mutually exclusive. These counters do not
request GPU timestamp queries, read resources back, or submit an additional command buffer.
The renderer also supports capability discovery:
anariDevice.getObjectSubtypes('geometry');
anariDevice.getObjectSubtypes('renderer');
anariDevice.getObjectInfo('material');
anariDevice.extensions;