LeCodesdocs

Model & ModelAnimation

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 the ModelAnimation aspect pre-attached at model.anim — you never attach it yourself.

At a glance

TypeScript
const hero = await Model.load(asset('./hero.glb'))
hero.position = [0, 0, -2]
scene.add(hero)

hero.anim.play('Run', { loop: true })
hero.addEventListener('loopReached', clip => console.log('lap', clip))

Loading

TypeScript
Model.load(source: string | FetchResponse, options?: {
  culling?: boolean          // default false — the whole model always draws
  onProgress?: (p: { loaded: number, total?: number }) => void
}): 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.

Note

frustum culling is off by default for models (skinned meshes move outside their import-time bounds). Pass culling: true for large static props to let off-screen parts skip drawing.

Note

there is no clone — each Model.load creates one instance, and loading the same URL N times is the current pattern for a spawn pool. Load the copies up front (at scene build, not at spawn time) and recycle them.

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                       // { name: string, duration: number }[] — baked into the glb (seconds)

model.anim.play(clip?: string | number, options?: { loop?: boolean }): this
model.anim.stop(): this

model.anim.playing = true              // get/set — resume/pause the current clip
model.anim.loop = true                 // get/set — also settable via play() options
model.anim.speed = 1.5                 // playback rate multiplier (1 = authored speed)
model.anim.time = 0.5                  // get/set — seek within the current clip (seconds)

play() accepts a clip name or index; with no argument it (re)plays the current clip. Name-based play only works when the GLB's clips are usefully named — check model.anim.clips (or name them in the DCC tool/exporter).

Note

an unknown clip name is silently ignored — play('Runn') keeps the previously selected clip and plays that. Validate against clips if the name comes from data.

Clip-end events

Fired on the node (not on anim), with the clip index as the argument:

TypeScript
model.addEventListener('completed', clip => { /* non-looping clip finished */ })
model.addEventListener('loopReached', clip => { /* looping clip wrapped around */ })

After completed the aspect flips to playing = false; a looping clip keeps going until stop().

Limitations

  • No cross-fade or blendingplay() hard-switches to the new clip on the next frame. One clip plays at a time; there is no layered/partial-body mixing.
  • No cloning — see the loading note above; pools load N copies.

Pitfalls

TypeScript
// ✗ awaiting loads one-by-one in a spawn loop — hitches at spawn time
const enemy = await Model.load(url)          // per spawn
// ✓ preload the pool once, then reuse
const pool = await Promise.all(Array.from({ length: 8 }, () => Model.load(url)))

// ✗ expecting a smooth transition
hero.anim.play('Idle')                       // snaps — no cross-fade exists

See also

  • Node — transform, hierarchy, traverse, events (inherited)
  • Mesh — primitives / custom geometry (the other renderable kind)
  • Physics & shapes — give a model a collision shape
  • Scene — adding nodes, the camera