Scene files — defineScene
A scene as data: one defineScene({...}) call, conventionally the default export of a
.scene.ts file. The argument is a plain literal — nodes keyed by name (unique among siblings;
the runtime addresses them by /-joined absolute path), each with one source block
(mesh / model / light, or none = group node), a transform, an optional material,
aspects: [use(Ctor, props), …] and children. It lowers to ordinary SDK calls
(Mesh factories, node.aspect(), scene.add),
so a scene file runs on every platform like any other code. The le.codes visual scene editor reads
and writes these files; they're equally fine to write by hand.
At a glance
// city.scene.ts
import { Spinner } from './spinner'
export default defineScene({
env: { skybox: '#10131a', bloom: true },
camera: { position: [0, 6, 12], target: [0, 0, 0] },
nodes: {
ground: {
mesh: { kind: 'box', size: [20, 1, 20] },
material: { lit: { color: '#444444' } },
position: [0, -0.5, 0],
aspects: [use(Shape, { box: [10, 0.5, 10] }), use(Physics, { motion: 'static' })],
},
hero: {
model: asset('./hero.glb'),
position: [2, 0, 0],
aspects: [use(Spinner, { speed: 2 })],
children: {
halo: { mesh: { kind: 'sphere', radius: 0.2 }, position: [0, 2, 0] },
},
},
sun: { light: { kind: 'sun', shadowsQuality: 2 } },
},
})// main.ts
import city from './city.scene'
const { scene, nodes, get } = await city.open()
nodes.hero.anim.play('idle') // nodes[path] — a root node's path is its bare name
nodes['hero/halo'].visible = false // nested nodes are addressed by their full path
get('hero/halo') // dynamic lookup — Node | null for unknown pathsThe handle
defineScene returns a SceneHandle:
| Member | What it does |
|---|---|
load() |
Instantiate the scene (async — models fetch). Idempotent: one instance per handle. |
open() |
load() + make the scene active. Returns the same { scene, nodes, get }. |
instantiate({ scene, parent?, name? }) |
Build the file as a reusable subtree inside an existing scene — as many times as you like (below). |
def |
The literal you passed in. |
nodes is keyed by absolute path — /-joined names from the scene root ('hero/halo'); a
root node's path is just its name. Each entry is typed by its source: mesh → Mesh,
model → Model, light → Light, none → Node — with the
use(...)d aspects reflected in the type. Names must be unique among siblings only (no / or
: in a name) — duplicating or reparenting a group never forces renames inside it. get(path) is
the dynamic accessor (typed for the scene's literal paths, Node | null for arbitrary strings).
Don't rely on the record's key order — it follows build completion, not file order.
Node entries
| Key | Meaning |
|---|---|
mesh |
{ kind: 'box' | 'sphere' | 'cylinder' | 'plane', …geometry options } |
model |
GLB url — asset('./hero.glb') |
light |
{ kind: 'sun', …SunOptions } |
make |
A code-built subtree — make(factoryFn, { …literal args }) (below) |
prefab |
Another scene file used as a reusable composition — its imported handle (below) |
camera |
{} — this node IS the scene camera (below) |
material |
For a mesh: { lit: { color?, map?, roughness?, metallic? } }, { unlit: { color?, map? } }, { shadow: color }, { shader: asset('./x.mat'), params: { … } } (a custom Filament shader + its parameter values), a material asset (import gold from './materials/gold.material' → material: gold), or a shared Material instance. map and sampler params take asset('./x.png'); a colour is a '#rrggbb' string, a vector an array. See Material assets |
position, eulerAngles, scale, visible |
Transform / visibility |
locked |
Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect |
castShadows, receiveShadows |
Mesh render flags |
lightmap |
Baked lighting (a scene with env.lightmap): override the static verdict — by default a model/mesh node is static unless a Physics aspect moves it (below) |
foliage |
A model node: vegetation — true loads it through the foliage tier (wind, touch bending, per-copy tint), { fade: [start, end] } also thins it out over that distance range (m). The fade is per asset: every node of the same GLB shares it. See foliage.md |
aspects |
[use(Ctor, props), …] — attach order matters (e.g. Shape before Physics) |
children |
Nested node entries, parented under this node |
overrides |
Model/prefab sources: transform overrides for the internal nodes (below) |
mount |
On a child of a model/prefab node: parent it to that internal part (below) |
At most one source block per node. Every def node joins the scene's draw set (membership and parenting are separate — see node.md).
Baked lighting (env.lightmap)
env: {
skybox: { texture: SKY },
lightmap: {
data: asset('../assets/lightmap/lightmap.bake'),
texture: asset('../assets/lightmap/lightmap.ktx2'),
// sunStrength: 1, ambientScale: 1 — Lightmap.load's options
},
},With env.lightmap set the level is a baked one (lightmap.md) and the statics are
derived, not declared: every model/mesh node is a lightmap static — a receiver and an
occluder in the bake, no real-time shadow casting once the bake applies — unless a Physics
aspect moves it (dynamic, Physics' default, or kinematic). A physics-less decoration is
static; a barrel with use(Physics, { motion: 'dynamic' }) keeps its real-time shadow. lightmap: true | false on a node overrides the rule for the exceptions (a code-animated prop without
physics → false; a kinematic platform that never actually moves → true). Prefab subtrees
inherit their wrapper's verdict; make() subtrees are code and register themselves with
Lightmap.add.
Keys in lightmap.bake are node paths (perimeter/metalfence1), so a rename or a move means a
rebake (lecodes lightmap bake — the bake runs where the runtime would apply it: when load()
has built every node). Edit mode ignores the block (the editor renders real-time shadows); its
inspector shows the verdict as a "Baked lighting" switch on model/mesh nodes and writes the
lightmap key only when you flip it away from the rule.
Vegetation (env.foliage)
env: {
foliage: { wind: { direction: [1, 0.3], strength: 0.07, speed: 1.2, gust: 0.6 }, touch: 0.6, variation: 0.35 },
},
nodes: {
grass: { model: asset('./grass.glb'), foliage: true },
},env.foliage is Foliage.configure's options, applied before the models load; a node's
foliage: true picks the tier for that model (all its copies). See foliage.md.
Material assets
A material can be its own file — defineMaterial({ … }) in a *.material.ts — and be imported
by any scene (or by code: await gold.load() gives the Material). It is ONE shared instance:
every node using the asset renders the same material, and editing the asset changes all of them
(two looks are two assets). The scene editor lists material assets in its asset drawer, edits
their parameters live, and offers "Save as asset…" on any inline material.
// materials/holo-glass.material.ts
export default defineMaterial({
shader: asset('../assets/shaders/lens.mat'),
params: {
tint: '#1d2b26',
opacity: 0.22,
mask: asset('../assets/weapons/reticle.png'), // a sampler parameter takes an image asset
},
})
// materials/gold.material.ts — a built-in kind, same options as the inline form
export default defineMaterial({ lit: { color: '#c9a227', roughness: 0.35, metallic: 1 } })
// range.scene.ts
import holoGlass from './materials/holo-glass.material'
export default defineScene({ nodes: { lens: { mesh: { kind: 'plane' }, material: holoGlass } } })Custom shaders need no metadata at runtime: a string parameter is a colour when it starts with
#, a texture URL otherwise. The editor's parameter rows (widgets, ranges, defaults, tooltips)
come from annotations in the .mat itself — see Material › Editor annotations.
overrides — posing a GLB's internal nodes
A model node may reposition the nodes inside its GLB without touching the file. Keys are part
paths — /-joined node names from the model root down; each value takes position,
eulerAngles, scale, visible, and materials (the part's primitive slots by index — a
material asset, an inline def or a custom shader; slots left out keep the glTF material):
robot: {
model: asset('./robot.glb'),
position: [2, 0, 0],
overrides: {
'Body/Head': { eulerAngles: [0, 30, 0] },
'Body/ArmL': { position: [-0.7, 0.2, 0], visible: false },
'Body/Visor': { materials: { 0: holoGlass } }, // slot 0 of the visor mesh → a material asset
},
},A segment is the child's name; duplicates among siblings disambiguate as name[i], unnamed nodes
as [i] (index within that same-named group). These part paths are the asset-internal half of
an address — the editor joins them to a def node's path with :: (city/lamp::Body/Head). The
scene editor writes them for you — expand a model node in its tree and drag a part. Paths that no
longer resolve (the GLB changed) are skipped silently. Overrides apply once at load, after the
model instantiates; a playing animation clip will overwrite the transforms of the joints it drives.
mount — attaching a node to an internal part
A child of a model/prefab node can parent itself to one of the asset's internal nodes instead
of the asset root — a flashlight in a hand, a marker on a bone. mount takes the same part-path
grammar as overrides keys:
robot: {
model: asset('./robot.glb'),
children: {
flashlight: { mesh: { kind: 'cylinder', radius: 0.05 }, mount: 'Body/ArmR', position: [0, 0.2, 0] },
},
},The def stays an ordinary child of the model node — its path (robot/flashlight), refs, and
editing all work unchanged; only the runtime parent differs. The transform is local to the
part, so the node follows the part through overrides and animation. A path that no longer
resolves falls back to the asset root with a console warning. In the scene editor, drag a node
onto a part row to mount it; drag it onto the model (or anywhere else) to unmount.
Camera nodes
camera: {} makes a node the scene camera: when the scene runs, the view follows the node's world
transform every frame (position + orientation; the camera looks down the node's local −Z).
Because it's an ordinary node, everything composes — parent it, animate it with the
scenario aspects (FollowPath on a camera node is a flythrough), or drive
it from your own aspect:
camera: { camera: {}, position: [0, 5, 10], eulerAngles: [-26, 0, 0] },The block also carries the lens — fov (vertical, degrees, default 60), near (0.01) and far
(1000, the view range). Omitted keys keep the host default, and unlike the pose the projection
applies in the editor too (it's a property of the scene, not of your viewpoint):
camera: { camera: { fov: 45, far: 5000 }, position: [0, 5, 10] },The first camera node in file order wins; camera nodes inside a prefab are ignored (a prefab's
own camera:/env: blocks already are). The older top-level
camera: { position, target, fov?, near?, far? } block still works as a fallback when no camera
node exists — it seeds the view once and nothing tracks it afterwards. In the visual editor a camera node shows as a frustum marker (an anchored
editor gizmo — click it to select the node), the editor's own orbit camera stays independent, the Set from current view inspector button poses the node from
your viewpoint, and the last camera node can't be deleted.
Empties (plain group nodes)
A node with no source block is a group — an invisible transform. The editor calls these
Empty and draws a small axis-cross marker for them while editing — an anchored editor gizmo
(see below): overlay lines that follow the node, pick it on click and take the selection colour,
never part of the scene (nothing in the Rendered view, nothing at runtime). Empties are the working material of no-code scenes: MoveTo
targets, FollowPath waypoints (children of a path empty), LookAt subjects, and plain folders
for organizing children.
The generated-file header
Every write from the visual editor starts the file with a two-line comment saying the file is editor-managed. Hand edits remain first-class — values outside the literal grammar are preserved verbatim and shown read-only in the inspector — but formatting, key order, and number rounding (4 decimals) are normalized on the next editor write. Hand-written scene files gain the header the first time the editor writes them.
make(fn, args) — code-built nodes
When a subtree is naturally the output of a function — a fence line, a spiral stair, a procedural cluster — reference the factory instead of hand-writing nodes:
// props.ts — a plain function returning a Node
export const buildFence = (args: { posts?: number, gap?: number }): Node => { … }
// city.scene.ts
fence: {
make: make(buildFence, { posts: 6, gap: 1.2 }),
position: [-5.5, 0, 3.5],
},The factory runs after every scene node exists (so ref() args resolve, forward references
included) and may be async; its returned subtree mounts under the def node. The split of ownership
is the point:
- The def owns the transform. Moving/rotating the node is an ordinary transform edit — the factory is never re-called for it.
- The args own the content. Args must be literal data (the same grammar as aspect props,
ref()included); in the scene editor each arg is an inspector field, and editing one re-calls the factory live — no compile, since the function is already in the running bundle. Aref()arg also re-calls when the referenced node changes.
Keep factories pure builders: same args → same subtree, no side effects outside the returned
nodes. make mirrors use(Ctor, props) deliberately — callee by identifier, literal props —
which is what keeps the call editable; a call written any other way (computed args, inline
function) is preserved verbatim but shows as "set in code".
prefab — scene-in-scene
A .scene.ts file already is a data-described composition — so scene files can instance each
other. Import another scene's handle and use it as a source block:
import streetlamp from './streetlamp.scene'
lamp: {
prefab: streetlamp,
position: [4, 0, 2],
overrides: { head: { visible: false } },
},Each instance builds the prefab's nodes fresh under a plain wrapper node — instances are independent, and editing the prefab file changes every instance on the next load. Semantics:
- The wrapper is an ordinary node — transform,
visible,locked,aspects,childrenall work; the instance's internals live under it. ref()s inside a prefab resolve file-locally, per instance — a prefab's aspects andmake()factories see that instance's own nodes, never the instancing scene's. The prefab's internals don't appear in the instancing handle's path-keyednodesmap.overridesposes the internals exactly like a GLB's — the same part-path grammar, addressed by the prefab's node names ('pole/bulb'); nested models inside the prefab drill further. In the scene editor, expand a prefab node in the tree and drag a part — the edit persists as an override.- The prefab's
env/cameraare ignored when instanced — the instancing file owns the scene. - Prefabs can nest; an import cycle (a scene instancing itself, directly or transitively) is detected at load and that instance is skipped with a console error.
instantiate — a scene file as a reusable object (scene rigs)
load()/open() treat a file as the scene. instantiate treats it as an object: a weapon
under a hand bone, a streetlamp per street corner, a vehicle with its seats and lights — built as
many times as needed, inside whatever scene is running:
import rifleScene from './scenes/rifle.scene'
const rifle = await rifleScene.instantiate({ scene, parent: arms.bone('ik_hand_gun') })
rifle.root // the wrapper Node — position / hide / reparent it
rifle.nodes.weapon.anim.playLoop('Idle') // typed like load().nodes
rifle.get('weapon/aim') // Node | null
rifle.dispose() // remove + destroy the subtree- The file's nodes build under a fresh wrapper (
root) parented toparent; transforms are local to it, so author the file with the wrapper as the attachment point (a weapon: the model at identity, the scene's origin = the hand socket). env/cameraare ignored (as for aprefab:);ref()s resolve per instance; aspects attach per instance.
The contract pattern. Code should never carry an object's geometry (where the sight is, where
the muzzle is, how an attachment sits on its bone). Name what the code needs in an aspect with
Node | null fields and let the scene file fill them with ref() — the aspect is then typed in
code, visible in the inspector (pick dropdowns), rename-safe, and can draw what it means as an
editor gizmo:
// weaponRig.ts — code declares what it needs
export class WeaponRig extends Aspect<'weaponRig', Node> {
/** the sight: −Z down the sight line, +Y up through the sights */
aim: Node | null = null
/** the barrel's mouth: −Z the bullet direction */
muzzle: Node | null = null
eyeRelief = 0.3
static editor = { rebuild: true } // runs in the editor: draws the line being placed
rebuild() {
if (!this.aim) return
const m = this.aim.worldMatrix, p = m.position, fwd = m.basisZ.normalize().scale(-1)
Gizmos.line(p, p.scaleAndAdd(fwd, 0.5), { color: '#ff4a4a' })
}
onAttach() {}
}
// rifle.scene.ts — the editor writes this; the developer drags the empties
weapon: {
model: asset('../../assets/weapons/tr15.glb'),
aspects: [use(WeaponRig, { aim: ref('aim'), muzzle: ref('muzzle'), eyeRelief: 0.28 })],
children: {
holo: { model: asset('../../assets/weapons/tr15_xps2.glb'), mount: 'SKM_TR-15/Body/Attach_AR15_XPS2', position: [0, 0, -0.028], eulerAngles: [90, 0, 0] },
aim: { mount: 'SKM_TR-15/Body', position: [-0.014, 0.113, -0.105], eulerAngles: [90, -180, 0] },
muzzle: { mount: 'SKM_TR-15/Body/Attach_AR15_Silencer', position: [0, 0.12, 0], eulerAngles: [90, 0, 0] },
},
},
// the consumer
const rig = rifle.nodes.weapon.get(WeaponRig)!
if (!rig.aim) throw new Error('rifle.scene.ts: place the aim empty')
rig.aim.worldMatrix // read every frame — rigid under its bone, follows the takesEmpties mounted on bones follow the animation; a generator's __generated container is a child
of the host node, so code looking for a GLB's own first child must skip it (children.find(c => c.name !== '__generated')). A missing field is null — check once at rig build with a message
that names the file.
use(Ctor, props)
References an aspect by class — the import is the registration, and props is typechecked
against the aspect's fields exactly like node.aspect(Ctor, props). Behavior
never lives in the scene file: write a custom aspect in a normal .ts file and attach it with
use(...).
ref(path) — node references in aspect props
An aspect prop can point at another scene node:
class Road extends Aspect<'road'> {
from: Node | null = null
to: Node | null = null
}
// in the scene file — `road` may reference nodes declared below it
road: { aspects: [use(Road, { from: ref('pointA'), to: ref('pointB') })] },
pointA: { position: [1, 0, 0] },
pointB: { position: [5, 0, 0] },Resolution is scoped upward from the node that carries the ref, like variable scoping: the
host's own children are tried first, then its siblings, then each ancestor's scope up to the scene
root. So a duplicated group's internal refs bind to its own copies — ref('pt1') inside in1
finds in1/pt1, not in0/pt1. A multi-segment path (ref('lane/pt1')) descends from whichever
scope contains its first segment — and that binding is final: a closer scope that has the first
segment but not the rest yields null rather than retrying further out (lexical shadowing). To
reach into a sibling's subtree, write the path through the sibling (ref('path1/p1')).
Nodes are instantiated first and aspects attach after, so declaration order doesn't matter.
ref() markers resolve to the live nodes at attach (top-level props and one array level deep —
waypoints: [ref('a'), ref('b')] works); an unknown path resolves to null, so type such fields
Node | null. Refs address def nodes only — no :: or name[i] asset-internal segments. Scene
files only — hand-written code passes nodes directly: node.aspect(Road, { from: nodes.pointA }).
The scene editor shows ref fields as a path dropdown with a pick-in-viewport button (a
Node | null type annotation is enough; editor: 'node' in static fields also works) and writes
the shortest ref that resolves from the host.
Inspector metadata (static fields)
The visual editor infers an editor widget for each public field from its default value (number,
boolean switch, '#rrggbb' color, [x, y, z] vec3, string). An optional static fields on the
aspect class refines that — ranges, labels, dropdown options, or hiding a field:
class Spinner extends Aspect<'spinner'> {
speed = 1
mode: 'local' | 'world' = 'local'
static fields: FieldMeta<Spinner> = {
speed: { min: 0, max: 20, step: 0.1 },
mode: { options: ['local', 'world'] },
}
}Generator aspects (static editor)
An aspect that derives scene content from its props — a road between two nodes, a fence along a path — can opt into running while the scene is edited:
class Road extends Aspect<'road'> {
from: Node | null = null
to: Node | null = null
width = 2
static editor = { rebuild: true }
onAttach() { this.rebuild() }
rebuild() {
this.generated.clear() // idempotent: clear, then create
if (!this.from || !this.to) return
// …spawn meshes with this.generated.add(mesh)…
}
}The contract:
rebuild()regenerates the output underthis.generated— a scene-added container child provided beforeonAttach/rebuildrun (nodesadd()ed to it join the draw set automatically;clear()destroys the previous output). Provided for scene-file attachments; hand-attached aspects don't get one.- Play mode: nothing special —
onAttachruns normally and callsrebuild()itself. - Edit mode: the class is instantiated for real (unlike ordinary aspects, which stay inert
data) —
node/generatedset,ref()props resolved,rebuild()called — but neveronAttach(no physics, timers, or loops while editing). The editor then re-runsrebuild()whenever a prop changes in the inspector or a node referenced by aref()field moves — dragpointAand the road follows. A throwingrebuild()logs and skips; it can't take the editor down. - Generated output is derived, never saved: it doesn't appear in the scene file or the editor's node tree; the file stores the recipe (the aspect entry), the viewport shows the result.
Editor gizmos (Gizmos)
A generator that wants to annotate rather than build — a path line, a sight line, a marker —
draws through the global Gizmos inside rebuild():
rebuild() {
if (!this.from || !this.to) return
Gizmos.line(this.from.worldPosition, this.to.worldPosition, { color: '#5b8ef0' })
Gizmos.cross(this.to.worldPosition, 0.1)
Gizmos.polyline(points, { closed: true }) // consecutive segments; closed joins last → first
}Points are world space by default; styles are { color?: '#rrggbb', alpha?, node? }. The
lines are overlay drawings of the scene editor's viewport (lite engine) — not scene content: never
outlined with the selection, absent from the Rendered (filament) view and from play mode, where
every Gizmos call is a no-op.
Anchored gizmos — { node: this.node } — take points in that node's local frame (its
scale ignored) and behave as the node's own picture: they follow the node live through a gizmo
drag, clicking them in the viewport selects the node, and they draw in the selection colour while
it is selected. That is how the editor's own empty/camera markers work, and how CameraPlace
draws its frustum: Gizmos.frustum(this.fov, { node: this.node }). World-space lines (a path
between nodes) are never pickable. Each rebuild() starts from an empty picture — what the call draws is
the whole picture, no clearing needed. Draw synchronously inside rebuild() (or a make()
factory / an editor tool hook): a call after an await has no run to attach to and is dropped.
The built-in movement aspects (MoveTo, FollowPath, LookAt) draw their paths this way.
Custom inspector cards (static inspector)
For full control over how an aspect looks in the scene editor's inspector, declare a
static inspector(ui, aspect) — an immediate-mode function (think imgui): it re-runs on every
edit or interaction and describes the card with InspectorUI calls. Without it, the editor shows
the inferred fields; ui.auto() emits those same fields, so a custom card usually starts there and
appends status lines, buttons, or dynamic dropdowns:
class Road extends Aspect<'road'> {
from: Node | null = null
to: Node | null = null
width = 2
static editor = { rebuild: true }
static inspector(ui: InspectorUI, road: Road) {
ui.auto() // the inferred fields, as usual
if (road.from && road.to) ui.info(`${road.generated.children.length} pieces`)
else ui.warn('Assign both endpoints')
if (ui.button('Shuffle')) road.rebuild() // true on the click's run — act right there
}
}The InspectorUI vocabulary: fields — number / slider / text / color / switch /
select(key, options) / vec2 / vec3 / vec4 / node (each emits a widget AND returns the
current value); actions & text — button(label) (true on the run that consumes the click),
header, info, warn; and auto(...keys) for the inferred fields.
Binding rule — one vocabulary, two lifetimes: a field whose key is a declared class field
is doc-bound (the editor persists edits to the scene file, with undo); any other key is transient
editor state, kept per card and never written anywhere (a preview clip choice, a brush size).
select options may be computed fresh every run, so live data makes dropdowns for free.
The aspect argument is the live editor instance for generator classes (static editor), or a
preview instance (with node set, refs resolved, never attached) for plain aspects. A throwing
inspector logs and renders a warning line — it can't take the editor down. Model nodes get a
built-in Animation card (clip / speed / loop, Play/Stop) built on this same protocol.
Editor-only code (EDITOR + *.editor.ts)
EDITOR is a compile-time boolean: true in scene-editor bundles, false in shipped ones —
and in production the compiler const-folds it, so if (EDITOR) { … } branches (and everything
only they reference) are removed entirely. Gate debug helpers, editor warnings, or expensive
validation with it at zero shipped cost:
if (EDITOR && this.segments > 500) console.warn("road: very dense — consider fewer segments")*.editor.ts files are the file-level form: they are compiled and executed only in editor
bundles (imported automatically, after the scene file), and are invisible to production compiles —
a production build never even parses them, and they are skipped by entrypoint detection. Editor
plugins (windows, viewport tools) live in these files — see
editor-plugins.md.
Edit mode (how the scene editor runs a scene file)
When the le.codes scene editor runs a scene bundle it sets a host flag before execution: sources
are instantiated for real (the viewport shows the scene), but aspects are held as data without
attaching — no onAttach side effects, no update() ticks, physics stays inert. Pressing Play
runs the project normally. Hand-written code never sees this mode.