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
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)
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-wideibl.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.hdrturns one equirectangular HDR into both files:sky.ktx(sharp, forskybox) andibl.ktx(the probe, foribl) — they are not interchangeable.- The tone mapping operator is part of post-processing
(
postProcessing: { toneMapping: 'neutral' }). exposureCompensationis 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
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 tomin(default 0.5). Frame-time dependent, so headless renders and flow tests never enable it.antialias: 'taa'withtaa: { upscale: 0.75 }— renders the 3D at that fraction (0.5–1) and lets temporal AA reconstruct the rest: most of the detail kept, whererenderScalestretches.feedbackis the history's share (default 0.12; toward 0.3 if fast motion ghosts),sharpnessa post-TAA sharpen 0–1 (default 0.5 withupscalebelow 1, else 0),viewmodelthe 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
envis applied before the nodes build — and leaves already-loaded ones alone.maxTextureSizeworks the same way (a KTX2 above it loses its top mips on load, a glTF image is downsampled). Both areTexture.anisotropy/Texture.maxSizeat runtime. - 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
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.
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.
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 defaultsGraphics settings at runtime: setAntialias, setRenderOptions, setFog
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-onlyenvironmentIntensitychanges the probe's intensity, not the probe — cheap enough for a slider.lecodes lightmap bakereads 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.5keeps full detail twice as far; 1 is the default. Only GLBs with_LOD<n>meshes have mesh levels to switch (Model).occlusionMaterialis one unlitMaterialper scene, in the occluder blue, made on first read — set its uniforms like any other. It throws only on a host that carries nounlitmaterial.
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
scene.add(...nodes: Node[]): this
scene.remove(...nodes: Node[]): thisadd 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
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 nullA 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
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 knobsScene-wide behaviour (input, HUD, the game mode) is a System; scene.audio
is 3D audio's listener.
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.
Capture
scene.capture(): Promise<File> // the scene's next frame as a PNG FileThe 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
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
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() // ends the VR sessionA 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
occlusionMaterialand mesh materials expose - Systems —
scene.system(Ctor)