Scene
A 3D world: a draw set, a Camera, lights and environment. Opening a scene brings up
the host's 3D engine — only the active scene renders and receives pointer events. For a
camera-feed AR session, use ARScene (a subclass).
At a glance
const scene = new Scene({ skybox: '#202030', bloom: true })
scene.add(
Light.sun({ shadowsQuality: 2 }),
Mesh.box({ material: Material.lit({ color: '#e33' }), position: [0, 0.5, -2] }),
)
scene.camera.position = [0, 1.5, 2]
scene.open()
scene.addEventListener('click', ev => {
console.log('hit', ev.target?.name ?? 'nothing')
})Creating
new Scene(options?: {
ibl?: boolean // image-based ambient lighting; default true
environmentIntensity?: number // IBL intensity; default 20000
bloom?: boolean // bloom post-processing; default false
bloomIntensity?: number // bloom strength; default 0.2 (only with bloom: true)
skybox?: ColorInput // solid background / clear color
antialias?: boolean | 2 | 4 // MSAA; unset = host default (desktop 4×, mobile/web off), false = off
renderScale?: number // 3D at 0.25–1 of the viewport, upscaled; UI stays native
dynamicResolution?: boolean | { min?: number } // engine shrinks 3D buffers only when over budget
anisotropy?: number // anisotropic texture filtering, 1 (off) … 16; default 2
hdr?: { strength?: number; paperWhite?: number } // the look on an HDR display; see "HDR displays"
})Render cost on big screens
The 3D cost is almost entirely per pixel (MSAA, shadows, post-processing), so a 4K fullscreen window is ~4× a 1080p one. Two dials, both leave the UI crisp at native resolution:
renderScale: 0.75— a fixed cut (~45 % fewer 3D pixels;0.5= a quarter of the work). Predictable, test-friendly;scene.setRenderOptions(scale)changes it at runtime (a settings menu).dynamicResolution: true— only kicks in when frames run late, sharpened upscale, down tomin(default 0.5). Frame-time dependent, so headless renders and flow tests never enable it.
Texture filtering
anisotropy (1 … 16, default 2) buys detail on surfaces seen at a grazing angle — a
first-person weapon, a floor, a wall. There the sample footprint is far longer in one axis than the
other, and isotropic filtering has to pick a mip for the long one, several levels coarser than the
short axis deserves; markings and normal-map detail dissolve on exactly the surfaces a player looks
at most. Measured on a weapon viewed from the side (local contrast): 8.18 at 1× → 10.02 at
2× → 10.84 at 4× → 11.29 at 8×, and 16× adds 0.2 % on top of 8×. The default is 2: it
takes 59 % of the whole gain at the smallest step in memory bandwidth, which is the axis a mobile
GPU is short of. Raise it when you have the headroom.
Two things to know, because it does not behave like the options above:
- It is an engine-wide default, not a property of this scene. A sampler is baked when its
texture is bound, so the value reaches models loaded after it — every model in a scene file,
since
envis applied before the nodes build — and leaves already-loaded ones alone. - A host may pin it. On desktop,
CREATOR_TEXTURE_ANISOTROPY=Noverrides the app and wins, so a machine can be tuned without touching the project.
Turn antialias down before anything else on a weak GPU — 4× MSAA is the biggest single
fill-rate item after resolution. Per-machine caps (a 4K monitor on an integrated GPU) belong in
app.json (desktop.maxRenderSize: [1920, 1080] / desktop.renderScale), not in the scene.
HDR displays
On a screen with headroom above SDR white (Apple's XDR panels; the macOS host today) the engine
renders past 1.0: 1.0 stays the white of the UI and of a diffuse white surface, and the sun,
emissives and speculars climb up to the display's peak. Nothing in the scene changes for that —
the tone mapper you chose is kept below the fade and stretched above it — so an SDR-authored scene
is the same scene, unclipped. hdr sets the two engine-wide look knobs up front (device.hdr is
the runtime form, and the place to ask whether the display has any headroom at all):
strength(0..1, default 0.35) — how much of the picture reaches for the peak. 0 touches only what SDR clipped; 1 lifts nearly everything above black.paperWhite(1..8, default 1) — where white lands, as a multiple of SDR white: the "HDR brightness" of a console calibration screen, where paper white sits at ~200 nits against SDR's ~100. It scales the whole picture; highlights still climb above it.
Measured on a sunlit exterior (fps-demo's yard, camera on the sun, an XDR panel at headroom 5.66, engine float readback — 1.0 = SDR white):
| frame median | sky near the sun (p90) | sun peak | |
|---|---|---|---|
| SDR | 0.46 | 0.60 | 1.00 (clipped) |
HDR, strength: 0 |
0.46 | 0.61 | 5.55 |
HDR, strength: 1 |
0.63 | 1.29 | 5.57 |
HDR, strength: 1, paperWhite: 1.5 |
0.93 | 1.80 | 5.59 |
HDR, strength: 1, paperWhite: 2 |
1.21 | 2.18 | 5.59 |
At strength: 0 90 % of the frame is bit-identical to SDR — one bright spot in an otherwise
untouched picture, which reads as flat, and as dimmer once the panel re-levels for HDR. That is
the faithful setting; it is not the one a player calls "HDR". strength alone caps out around
1.4× SDR on the mid-tones; paperWhite is what moves the whole frame, and the sun keeps its peak
throughout. A sunlit exterior takes { strength: 1, paperWhite: 1.5 } (2× SDR on the mid-tones,
3× on the sky); a dim interior wants less of both. Particle emissive is the
content-side lever: it is in post-exposure units (1 = white on screen) and clips on SDR above that,
so raise it when device.hdr.available.
Lighting is explicit: IBL gives every scene ambient light by default (turn ibl: false if you
light it entirely yourself); add a Light.sun() for direct light and shadows.
options are constructor-only except the background (scene.skybox = … setter),
antialiasing (setAntialias), render resolution (setRenderOptions) and the HDR look
(device.hdr.strength / paperWhite). There is no runtime toggle for ibl or bloom.
Adding & removing nodes
scene.add(...nodes: Node[]): this
scene.remove(...nodes: Node[]): thisRegisters nodes with this scene's draw set. This is distinct from parenting — node.add(child)
builds the hierarchy (see Node); scene.add(node) puts it in the world.
Opening & closing
scene.open(): void // become the active scene (closes any other)
scene.close(): void // tear down the 3D view
Scene.active // static: the currently active Scene, or nullOnly one scene is active at a time; open() on a second scene replaces the first. The same goes
for a UIScreen: a screen is a destination too, so UIScreen(...).open() after scene.open()
closes the scene (the host logs "3D scene closed"). A HUD over a scene is therefore always a
UIWidget attached to it — UIWidget(...).attachTo(scene); hud.show() — never a screen, however
transparent (see UIWidget). After
close(), Scene.active is null (if this was the active one). ARScene.open() is async —
see ARScene.
Overlays
scene.createOverlay(options?: SceneOptions): SceneA second scene drawn on top of this one, sharing its camera — a 3D HUD layer that always
renders over the parent's content. Takes the same options as the constructor; add nodes to it like
any scene. It renders with its parent — don't open() it separately.
warmRender
scene.warmRender(): Promise<void>Renders the scene once so shaders compile and resources upload before it's shown. If the first
visible frame hitches, await scene.warmRender() during a loading screen, then open().
(ARScene.open() does this automatically.)
Rendering knobs
scene.skybox = '#87ceeb' // background color (set only)
scene.setAntialias(enabled: boolean, scale = 4): void // MSAA on/off + sample count
scene.occlusionMaterial: Material // the scene's occlusion material (read-only)
scene.setMaterialGlobalParameter(i, x, y, z, w): void // scene-wide vec4 slot i, read by custom shadersocclusionMaterial is a Material instance — set its uniforms like any other.
occlusionMaterial is host-gated: the web viewer does not implement it (accessing it
throws). Use it only on AR-capable native hosts.
Scene-level pointer events
scene.addEventListener('click', (ev: ClickEvent<Node | null>) => void): void
scene.addEventListener('touchstart', (ev: TouchStartEvent<Node | null>) => void): void
scene.removeEventListener(channel, callback): voidScene listeners fire for every tap on the active scene; ev.target is the hit Node
(one with a Shape pick body) or null for a miss. Individual nodes can also listen — see
Node events and Pointer events & gestures.
VRScene
const scene = new VRScene(options?: SceneOptions)
await scene.open() // rejects on hosts without a VR runtime ("VR is not supported on this device")
scene.close()A Scene subclass rendered in a VR headset (OpenXR — e.g. Meta Quest). The headset drives the
camera: both eyes render from the tracked head pose every frame, so scene.camera
position / lookAt have no effect while the scene is open. The world origin is at floor level
(stage space) — author content with the ground at y = 0 and the user standing at the origin.
Everything else (nodes, lights, materials, aspects) works exactly as in a regular Scene.