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
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
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.
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.
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:
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 // { 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).
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:
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 blending —
play()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
// ✗ 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 existsSee 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