Scene

A 3D world: a draw set, a Camera, lights and environment; opening it brings up the host's 3D engine, and only the active scene renders and receives pointer events. A screen of UI is a UIScreen (screen-router.md), a 2D game world a Scene2D; camera-feed AR is ARScene (the ar plugin), a headset VRScene (both subclasses).

  • show a 3D scene with a camera, a light and a box
  • set the sky colour, the ambient light and fog
  • add objects to the scene and take them out
  • open a 3D game from a menu screen and go back to the menu
  • lower the 3D resolution or anti-aliasing from a graphics-settings menu
  • save a picture of the 3D scene as a PNG
  • stop the first frame from stuttering while shaders compile
  • react to a tap anywhere on the 3D scene

Platforms: every host; on the web (Web Lite) fog, render scale, setDepthEffects / setLodBias and the warm-up do nothing, the sky is a flat colour and the ambient light the built-in probe; in headless tests nothing is drawn and capture() rejects; VRScene opens on no shipped host.

At a glance

TypeScript
const scene = new Scene({ skybox: '#202030', postProcessing: { 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')
})

Create a scene: new Scene(options)

TypeScript
new Scene(options?: {
  // light, sky, look
  ibl?: boolean | string           // image-based ambient light; default true — a string names this scene's own probe
  environmentIntensity?: number    // IBL intensity, lux; default 20000
  exposureCompensation?: number    // camera exposure, stops; default 0 (= scene.camera.exposureCompensation)
  skybox?: ColorInput | 'environment' | { texture: string }   // the background — see "Sky and ambient light"
  fog?: FogOptions                 // distance / height fog — see "Fog"
  postProcessing?: PostProcessingOptions   // the look: tone mapping, grading, LUT, bloom, vignette, AO, reflections, DoF — see "Post-processing"
  hdr?: { strength?: number; paperWhite?: number }  // the look on an HDR display — not implemented yet, see "HDR displays"
  // cost + quality
  antialias?: boolean | 2 | 4 | 'taa'   // MSAA; unset = host default (desktop 4×, mobile/web off), false = off, 'taa' = temporal AA
  taa?: { upscale?: number; feedback?: number; sharpness?: number; viewmodel?: number }  // with antialias: 'taa'
  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 — engine-wide
  maxTextureSize?: number          // texture size cap, px; 0 / unset = none — engine-wide
  stencil?: boolean                // keep a stencil buffer (for material stencil tests); default false
})

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.

Sky and ambient light

  • ibl: asset('./assets/ibl-interior.ktx') gives the scene its own probe. Without one the host looks for a project-wide ibl.ktx, which then lights every scene — swapping that file to suit an interior level silently relights the outdoor one. The file must be a KTX1 cubemap (below): the native engine does not reject another file, it stops the host.
  • skybox: a colour is a flat clear colour; { texture: asset('./assets/sky.ktx') } draws a KTX1 cubemap through the engine's own skybox pass (no sky dome to build); 'environment' reuses the IBL's cubemap — no second texture, but it is prefiltered, so the sky comes out soft. lecodes assets sky env.hdr turns one equirectangular HDR into both files: sky.ktx (sharp, for skybox) and ibl.ktx (the probe, for ibl) — they are not interchangeable.
  • The tone mapping operator is part of post-processing (postProcessing: { toneMapping: 'neutral' }).
  • exposureCompensation is the camera's exposure, next to the rest of the lighting so a level can carry its own. The camera is metered for bright sun (EV100 15): an interior lit at real levels needs +7…+9 — expose the camera rather than inflating the lamps.

Fog

TypeScript
type FogOptions = {
  color?: ColorInput         // a TINT on the ambient-lit fog; default white
  start?: number             // metres from the camera before it starts; default 0
  density?: number           // extinction per metre at `height`; default 0.01 (clearly visible over ~100 m)
  height?: number            // the fog's "sea level", world Y; default 0
  heightFalloff?: number     // 1/m: 0 = uniform (default), higher = a ground-hugging layer
  maxOpacity?: number        // 0–1 cap, keeps far shapes readable; default 1
  cutoff?: number            // metres after which fog stops applying; 0 = everywhere (default)
  fromEnvironment?: boolean  // colour from the IBL in the view direction, tinted by `color`; default false
}

color multiplies the environment's luminance: white fog sits at the ambient level and brightens with environmentIntensity, so the hex you use for skybox (an absolute colour) reads far darker here. By default the fog covers the skybox too, which blends the horizon; cutoff takes geometry beyond a distance back out.

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. Three dials, all 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.
  • antialias: 'taa' with taa: { upscale: 0.75 } — renders the 3D at that fraction (0.5–1) and lets temporal AA reconstruct the rest: most of the detail kept, where renderScale stretches. feedback is the history's share (default 0.12; toward 0.3 if fast motion ghosts), sharpness a post-TAA sharpen 0–1 (default 0.5 with upscale below 1, else 0), viewmodel the metres in front of the camera treated as fixed on screen (a first-person weapon would trail on every turn; default 0 = off). A host without TAA falls back to MSAA.

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. maxTextureSize works the same way (a KTX2 above it loses its top mips on load, a glTF image is downsampled). Both are Texture.anisotropy / Texture.maxSize at runtime.
  • 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

Not implemented

no host renders to an extended-range surface yet (the macOS layer is SDR too), so every host renders SDR. hdr — like its runtime form, device.hdr — clamps and stores the two values until a host does.

The two engine-wide look knobs it sets up front, for a screen with headroom above SDR white (where 1.0 stays the white of the UI and of a diffuse white surface, and the sun, emissives and speculars climb toward the display's peak):

  • 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. It scales the whole picture; highlights still climb above it.
Note

most options have a runtime form (next section). ibl does not — it is constructor-only.

Post-processing

The look — tone mapping, colour grading, a .cube LUT, bloom, vignette, ambient occlusion, screen-space reflections, depth of field — is one object, scene.postProcessing, given at construction as postProcessing and live afterwards. Its own page: post-processing.md.

TypeScript
const scene = new Scene({ postProcessing: { toneMapping: 'neutral', bloom: true, ambientOcclusion: { radius: 0.6 } } })
scene.postProcessing.contrast = 1.1                                   // one field, live
scene.postProcessing.apply({ toneMapping: 'filmic', vignette: true })  // several — the named ones only
scene.postProcessing.reset()                                          // the defaults

Graphics settings at runtime: setAntialias, setRenderOptions, setFog

TypeScript
scene.skybox = '#87ceeb'                                   // any skybox form (set only)
scene.environmentIntensity = 8000                          // lux, live (get/set)
scene.setFog(options: FogOptions | false): void
scene.postProcessing.bloom = true                          // every post-processing field is live (AO, DoF, reflections too) — see "Post-processing"
scene.setAntialias(mode: boolean | 2 | 4 | 'taa', scale = 4, taa?): void   // scale = MSAA samples for `true`
scene.setRenderOptions(renderScale: number, dynamicResolution = false): void
scene.setStencil(enabled: boolean): void
scene.setDepthEffects(enabled: boolean): void              // engine-wide
scene.setLodBias(bias: number): void                       // engine-wide
scene.setMaterialGlobalParameter(i, x, y, z, w): void      // scene-wide vec4 slot i, read by custom shaders
scene.occlusionMaterial: Material                          // read-only
  • environmentIntensity changes the probe's intensity, not the probe — cheap enough for a slider. lecodes lightmap bake reads the live value, so a scene dimmed here bakes dimmed.
  • setDepthEffects(false) drops the depth pre-pass that soft particles and projected decals read (~12 % of a fill-bound frame): particles go hard-edged, decals draw nothing.
  • setLodBias(2) makes every LOD level switch at half the distance (a model must look twice as big to keep its detail); 0.5 keeps full detail twice as far; 1 is the default. Only GLBs with _LOD<n> meshes have mesh levels to switch (Model).
  • occlusionMaterial is one unlit Material per scene, in the occluder blue, made on first read — set its uniforms like any other. It throws only on a host that carries no unlit material.

Every call is optional on the host: one that predates it leaves the scene as it opened (the environmentIntensity and setStencil setters warn once). anisotropy / maxTextureSize live on Texture, the HDR look on device.hdr.

Add and remove objects: scene.add / scene.remove

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

add puts each node — with everything under it — in this scene's draw set, and the scene then owns it: dropping your variable does not free it. Parenting carries membership: a child added under a node that is already in a scene joins that scene, so adding the root of a hierarchy is enough (see Node). remove takes the node and its subtree out; the node is yours again — it lives while you (or a parent) hold it, and is freed with its subtree once nothing does. Neither call frees anything by itself: node.destroy() does.

Opening & closing

TypeScript
scene.open(options?: { transition }): void      // become the current destination
scene.close(options?: { transition }): void     // stop showing it
scene.onOpen(cb) / scene.onClose(cb) / scene.onBack(cb): this
scene.destroy(): void                           // every system detaches, then close()
Scene.active                                    // static: the scene last open()ed and not close()d, or null

A scene is a destination like a UIScreen (a Presentable): open() replaces whatever is on screen — a screen or another scene — and Router.push(scene) puts it on the router's stack; both take a transition. The same goes the other way: UIScreen(...).open() after scene.open() takes the scene off screen (its onClose fires; the desktop host logs "3D scene closed"). A HUD over a scene is therefore always a UIWidget among the scene's widgets — scene.widgets(hud) — never a screen, however transparent (see UIWidget). The scene graph survives while covered; scene.open() shows it again and onOpen fires again.

Scene.active is cleared only by close() — a screen opened over the scene leaves it set; Presentable.current is what is actually on screen. destroy() frees no nodes: destroy() the ones you own. ARScene.open() and VRScene.open() are async — see ARScene and VRScene.

Systems and audio

TypeScript
scene.system(Ctor, options?)                    // attach (or reconfigure) a System → scene.<accessor>
scene.get(Ctor) / scene.has(Ctor) / scene.removeSystem(Ctor)
scene.audio                                     // the listener + scene-wide 3D-audio knobs

Scene-wide behaviour (input, HUD, the game mode) is a System; scene.audio is 3D audio's listener.

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.

Capture

TypeScript
scene.capture(): Promise<File>    // the scene's next frame as a PNG File

The composite of the scene's next frame — the scene, its overlays and its background (an ARScene's camera image), not the UI over them — as a PNG File to share or upload. It rejects when the scene is not the one on screen, or something else is shown before that frame.

Warm-up

TypeScript
scene.warmRender(): Promise<void>
scene.precompileShaders(): Promise<void>

warmRender renders the scene once, offscreen, 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.)

precompileShaders compiles every variant the scene's materials can need — sun, shadows, fog, skinning and the dynamic-light key: without it the first point light (a muzzle flash, an explosion) rebuilds every lit shader in view on that frame. Call it once the sun and fog are set, typically under a loading screen; materials created later are queued in the background. Resolves at once on a host without it.

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()        // ends the VR session

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
  • Post-processing — scene.postProcessing: tone mapping, grading, LUT, bloom, vignette, AO, reflections, DoF
  • Camera — scene.camera: fov, exposure, screen rays, follow
  • Light — Light.sun() for direct light + shadows over the IBL ambient
  • Node — transform, hierarchy, scene membership, events
  • Material — what occlusionMaterial and mesh materials expose
  • Systems — scene.system(Ctor)