Model
A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
distinct from Mesh (raw primitives, no animation). Extends Node. Every
Model carries an Animator pre-attached at model.anim — its clip table is the
GLB's embedded clips, so playing them needs no setup.
At a glance
const hero = await Model.load(asset('./hero.glb'))
hero.position = [0, 0, -2]
scene.add(hero)
hero.anim.playLoop('Run')
await hero.anim.play('Jump', { fade: 0.2 }) // back to Run afterLoading
Model.load(source: string | FetchResponse, options?: {
culling?: boolean // frustum culling; default on where the host tracks skinned bounds (see below)
onProgress?: (p: { loaded: number, total?: number }) => void
lightmap?: boolean | 'dynamic' // baked lighting: a static (atlas) / a mover (light volume) — lightmap.md
foliage?: boolean // vegetation: the foliage tier (wind, touch bending, fade, tint) — foliage.md
}): Promise<Model>source is an asset('./file.glb') path, a remote https://… URL, or an already-fetched
FetchResponse. Rejects on HTTP ≥ 400 or a failed decode.
frustum culling is on by default: a model the camera cannot see skips the draw
and the shadow pass. It is safe for animated models — the engine refits a skinned mesh's bounds to
its joints every frame, so a walking, kneeling or ragdolled body is never culled while on screen.
On a host without that refit (_creator.skinnedCullingSupported absent: older wasm / Android /
Apple builds, web-lite, headless) the default stays off, since Filament would cull by the
bind-pose box. model.culling = false / { culling: false } = always draw (a skybox-sized mesh,
a debugging aid); true forces it on regardless of the host.
Level of detail
model.lod: 'auto' | 0 | 1 | 2 | 3 // default 'auto'
model.anim.lod: 'auto' | 'full' // default 'auto'A GLB that carries <mesh>_LOD1, _LOD2 … sibling nodes (lecodes assets doctor --lod 0.5,0.2 [--lod-bones]) shows the level that fits its size on screen — 0 while the model spans ≥ 30 % of the
viewport height, 1 ≥ 12 %, 2 ≥ 5 %, 3 below that or out of view (with hysteresis). Every animator
follows the same level whether or not the mesh has levels: at 2 it is evaluated every 2nd frame, at
3 every 4th, and both stop sampling finger / toe / twist joints; clocks and events stay exact. lod
pins a level for mesh and animation (0 = a hero, 3 = a crowd filler); anim.lod = 'full' keeps
the animation exact while the mesh still switches. No-op on hosts without the LOD pass. Recipe:
Many characters on screen.
Cloning
const enemy = template.clone(): Modelclone() makes a deep copy of the GLB — meshes, skeleton and baked animation clips — reusing the
already-decoded asset (no re-fetch, no re-parse), so it is far cheaper than a second Model.load.
The clone is attached to the source's parent and joins its scene, and starts at the source's current
transform. It has its own independent animation state, reached via clone.anim. Culling is off
by default, matching Model.load.
const template = await Model.load(asset('./enemy.glb')) // decode once
const wave = Array.from({ length: 8 }, () => template.clone()) // cheap copies, no await
wave.forEach((e, i) => { e.x = i * 2; scene.add(e); e.anim.play('Walk', { loop: true }) })Prefer clone() over re-loading the same URL for spawn pools — one decode, then N instances.
The node hierarchy
A Model is the GLB's root node; the file's internal nodes hang under it as plain Nodes:
model.traverse(node => { // this node, then every descendant
if (node.name === 'Sword') node.visible = false
})
model.children // direct childrenTransform, visible, events, aspects (Shape, Physics, …) all work on the root and on the
internal nodes.
Animation — model.anim
model.anim.clips / model.anim.clip('Run') / model.anim.clip(0) // AnimationClip[] in file order (+ any you addClip) / by name / by index
model.anim.playLoop('Run') // rest on a looping clip (or a blend: playLoop({ Idle: 0, Run: 6 })); model.anim.loop reads it
await model.anim.play('Jump', { fade: 0.2 }) // a one-shot over the loop; resolves at its hand-over
model.anim.stop({ fade: 0.2 }) // everything off → rest pose
model.anim.speed = 1.5 // global rate (0 = pause)That is the whole simple case; the same object is the full Animator — blends, layers,
clips from other files, clip events, root motion. Clip names come from the GLB (lecodes assets probe
lists them); unnamed clips are animation_0, … or reachable by index. An unknown name warns and plays
nothing; a model without clips warns on play()/playLoop() and does nothing.
Pitfalls
// ✗ awaiting a fresh load per spawn — re-fetches + re-decodes, hitches at spawn time
const enemy = await Model.load(url) // per spawn
// ✓ load once, then clone() — no await, no re-decode
const template = await Model.load(url)
const enemy2 = template.clone()
// ✗ a looping one-shot
hero.anim.play('Idle', { loop: true })
// ✓ hero.anim.playLoop('Idle')See also
-
foliage.md —
{ foliage: true }: the vegetation tier -
Node — transform, hierarchy,
traverse, events (inherited) -
Animation system —
AnimationClip+Animator: crossfade, blend, external clips -
Mesh — primitives / custom geometry (the other renderable kind)
-
Physics & shapes — give a model a collision shape
-
Scene — adding nodes, the camera