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

TypeScript
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

TypeScript
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 to min (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 env is applied before the nodes build — and leaves already-loaded ones alone.
  • A host may pin it. On desktop, CREATOR_TEXTURE_ANISOTROPY=N overrides 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.

Note

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

TypeScript
scene.add(...nodes: Node[]): this
scene.remove(...nodes: Node[]): this

Registers 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

TypeScript
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 null

Only 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

TypeScript
scene.createOverlay(options?: SceneOptions): Scene

A 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

TypeScript
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

TypeScript
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 shaders

occlusionMaterial is a Material instance — set its uniforms like any other.

Note

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

TypeScript
scene.addEventListener('click', (ev: ClickEvent<Node | null>) => void): void
scene.addEventListener('touchstart', (ev: TouchStartEvent<Node | null>) => void): void
scene.removeEventListener(channel, callback): void

Scene 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

TypeScript
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.

See also

  • ARScene — AR session: async open, anchors, placement controls
  • Camera — scene.camera: fov, screen rays
  • Light — Light.sun() for direct light + shadows over the IBL ambient
  • Node — transform, hierarchy, events
  • Material — what occlusionMaterial and mesh materials expose