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

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

Loading

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

Culling

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

TypeScript
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

TypeScript
const enemy = template.clone(): Model

clone() 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.

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

TypeScript
model.traverse(node => {                 // this node, then every descendant
  if (node.name === 'Sword') node.visible = false
})
model.children                           // direct children

Transform, visible, events, aspects (Shape, Physics, …) all work on the root and on the internal nodes.

Animation — model.anim

TypeScript
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

TypeScript
// ✗ 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