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.

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                       // { 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.

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

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