Models & animation
Step 2 of the 3D track. Primitives from the previous step are great for a set, but you can't build a character out of boxes. Characters arrive as GLB — a file holding meshes, a skeleton and baked animation clips. In LeCodes that's its own node kind: Model.
A fox moves into the yard: we'll load the model, work through its clips, multiply it without loading it again, and send it running in circles.
Loading a model
Model.load() returns a promise. And here's the first runtime quirk: there is no top-level await in a program — the bundle runs as an ordinary function. Wrap anything that loads in an async function and call it:
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true }))
scene.camera.position = [0, 0.9, 1.9]
scene.camera.lookAt([0, 0.2, 0])
const status = UIText("Loading the model…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(status).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()
// The bundle runs without top-level await — wrap loading in an async function
async function main() {
const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
fox.castShadows = true
scene.add(fox)
// What the file actually holds is the only source of truth about clips
status.text = fox.anim.clips.map(c => `${c.name} — ${c.duration.toFixed(1)}s`).join("\n")
fox.anim.play("walk", { loop: true })
let t = 0
setLoop(dt => { t += dt; fox.eulerAngles = [0, Math.sin(t * 0.5) * 35, 0] })
}
main()- Open the scene first, add the model later. The scene is already drawn while the file downloads — the view is empty but not black, and there's somewhere to write "Loading…". Do the same for any slow resource.
- A model is a
Node, just another kind:position,scale,eulerAngles,castShadows, events — all as on a mesh. The GLB's internal nodes hang under it as children, andmodel.traverse(node => …)walks them (look one up bynode.name). - Check the scale — GLB units can be anything. A model arrives in whatever units it was exported in, and the multiplier is often baked into the file itself (our fox's root node already carries a
scaleof 0.01, which is why the code never touches scale at all). If a model is invisible afteradd, or fills half the screen, that's the first thing to look at:model.scale = 0.01on top of an already-shrunk file gives you a speck a tenth of a millimeter across. Think in meters: a grown fox is about 70 cm at the shoulder. - The source is an asset path —
asset("./fox.glb")— in a real project, or a plainhttps://URL as here. The promise rejects on an HTTP error or a bad file; in production wrap it intry/catchand show something human.
A top-level await in main.ts compiles without an error but the program silently never runs: both the web runner and the desktop host execute the bundle as a function. Blank screen and a quiet console? Look for an await outside a function first.
Clips
Animations ship inside the GLB and live on the model.anim aspect — already attached, nothing to create. clips is what's baked into the file; play() takes a name or an index:
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true }))
scene.camera.position = [0, 0.9, 1.9]
scene.camera.lookAt([0, 0.2, 0])
const label = UIText("…").style({ fontSize: 13, color: "#c7ccd6" })
const row = UIRow().style({ gap: 8 })
const hud = UIWidget(label, row).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 8 })
hud.attachTo(scene)
scene.open()
hud.show()
async function main() {
const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
fox.castShadows = true
scene.add(fox)
// Build the chips from what's actually in the file, not from a list of names off the top of your head
const chip = (name: string) =>
UIButton(UIText(name).style({ color: "#fff", fontSize: 13, fontWeight: 700 }))
.style({ bgColor: "#ffffff22", borderRadius: 999, px: 12, py: 7, $pressed: { opacity: 0.6 } })
.onClick(() => { fox.anim.play(name, { loop: true }); label.text = `clip: ${name}` })
row.append(...fox.anim.clips.map(c => chip(c.name)))
// One walk cycle without loop: when it ends, the completed event arrives
fox.anim.play("walk", { loop: false })
label.text = "clip: walk (once)"
fox.addEventListener("completed", () => {
label.text = "walk finished → idle, looping"
fox.anim.play("idle", { loop: true })
})
fox.addEventListener("loopReached", clip => console.log("loop", clip))
}
main()The fox walks one cycle and settles into idle — that was the completed event. The chips are built straight from anim.clips: as many buttons as the file has clips.
play(clip, { loop })covers both looping and one-shot playback. There's alsoanim.speed(rate multiplier),anim.time(seek, in seconds) andanim.playing = false(pause).- The events land on the node, not on the aspect:
model.addEventListener("completed" | "loopReached", clip => …), and the argument is the clip index. Aftercompletedthe aspect setsplaying = falseitself. - An unknown name is ignored silently:
play("Runn")doesn't throw — it just keeps playing the previous clip. Which is whyanim.clipsis the only source of truth: re-export the model with different names and code with hardcoded strings quietly stops animating.play(0)by index is safe from typos, but not from clips being reordered in the file. - There is no cross-fade:
play()hard-switches on the next frame. For blended transitions, blend spaces and clips from separate files there's theAnimatoraspect — also what you need for Mixamo assets, where every animation is its own GLB.
The HUD buttons are ordinary UIButtons from the UI track: a UIWidget over a scene behaves like any container, imperative append/setContent included.
Many instances
The second character is not a second Model.load. A loaded model can clone(): the copy reuses the already-decoded GLB (no refetch, no reparse) and gets its own animation state:
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 30, receiveShadows: true }))
scene.camera.position = [0, 1.8, 3.2]
scene.camera.lookAt([0, 0.2, 0])
const label = UIText("…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(label).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()
async function main() {
const template = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
const foxes = [template, ...Array.from({ length: 4 }, () => template.clone())]
foxes.forEach((fox, i) => {
const a = (i / foxes.length) * Mathf.TAU
fox.position = [Math.cos(a) * 1.1, 0, Math.sin(a) * 1.1]
fox.eulerAngles = [0, -a * RAD2DEG, 0]
fox.castShadows = true
scene.add(fox)
// Every copy owns its animation state: its own clip, speed and phase
fox.anim.play(i % 2 ? "walk" : "run", { loop: true })
fox.anim.speed = 0.6 + i * 0.2
fox.anim.time = i * 0.2
})
label.text = `${foxes.length} foxes, one load`
}
main()Five foxes, one network request and one file decode. Clones inherit the original's transform as of the copy, then live their own lives — own position, own clip, own speed and own phase (anim.time staggers them so they don't march in lockstep).
- Do the same for a pool of enemies or projectiles: one load at start-up,
clone()on demand. CallingModel.loadagain for the same URL is wasted work and a hitch exactly at spawn time. RAD2DEGandMathf.TAUare globals. Node angles are in degrees, trigonometry is in radians; convert right at the boundary.- To remove a node,
node.destroy()— after that it's unusable. Often it's cheaper to hide it (visible = false) and reuse it.
The whole screen
Bring back the yard from step 1 and send three foxes around it: each with its own radius, speed and clip. The position is computed in the frame loop, and the heading comes from the same angle:
// The yard from step 1 plus foxes: one GLB load, three instances, each with its own animation.
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
const lit = (color: string, roughness = 1) => Material.lit({ color, roughness })
scene.add(Mesh.plane({ material: lit("#2f4a35"), normal: [0, 1, 0], scale: 30, receiveShadows: true }))
function Tree(x: number, z: number, height = 1.6) {
const tree = new Node()
tree.position = [x, 0, z]
const crown = Geometry.cylinder({ radiusTop: 0, radiusBottom: 0.55, edges: 8, smooth: false })
.scale(1, height, 1).translate(0, height / 2 + 0.6, 0)
tree.add(
Mesh.cylinder({ material: lit("#6b4a2b"), radius: 0.12, scale: [1, 0.7, 1], position: [0, 0.35, 0], castShadows: true }),
Mesh.from(crown, { material: lit("#3d8b4f"), castShadows: true }),
)
return tree
}
scene.add(Tree(-1.9, -2.2, 2), Tree(1.9, -2.8, 1.6), Tree(-2, 1.8, 1.3), Tree(2, 1.4, 2.2))
scene.camera.position = [0, 3.2, 5.5]
scene.camera.lookAt([0, 0.3, 0])
const status = UIText("Loading foxes…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(
UIText("The yard").style({ fontSize: 22, fontWeight: 800, color: "#ffffff" }),
status,
).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 2 })
hud.attachTo(scene)
scene.open()
hud.show()
async function main() {
const template = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
// Each fox runs its own circle; clone() copies without loading again
const walkers = [
{ model: template, radius: 1.2, speed: 0.5, clip: "walk" },
{ model: template.clone(), radius: 2, speed: 0.9, clip: "run" },
{ model: template.clone(), radius: 0.6, speed: 0.3, clip: "walk" },
]
walkers.forEach(({ model, clip }, i) => {
model.castShadows = true
scene.add(model)
model.anim.play(clip, { loop: true })
model.anim.speed = 0.9 + i * 0.2
})
status.text = `${walkers.length} foxes · clips: ${template.anim.clips.map(c => c.name).join(", ")}`
let t = 0
setLoop(dt => {
t += dt
for (const w of walkers) {
const a = t * w.speed
w.model.position = [Math.cos(a) * w.radius, 0, Math.sin(a) * w.radius]
w.model.eulerAngles = [0, -a * RAD2DEG, 0]
}
})
}
main()Step speed and clip speed are matched by eye: anim.speed is tuned so the paws don't skate over the ground. That's normal hand-tuning — anim has no automatic link between animation and movement (root motion); Animator does.
Moving a node by assigning position is fine right up until physics takes it over — which is what the next steps are about.
What you learned
Model.load(url | asset("./file.glb"))returns a promise; there's no top-levelawait, so wrap it inasync function main().- Check a model's scale against the file: it's often already baked into the GLB's root node, and an extra
scalemakes the model invisible. - A
Modelis aNode: transform,scaleinto meters,castShadows,traverseover its internals. model.anim:clips,play(name | index, { loop }),speed,time,playing;completed/loopReachedfire on the node with the clip index.- An unknown clip name is ignored silently; there's no cross-fade —
Animatorhandles transitions and blending. clone()is a cheap copy of a loaded model with its own animation state — that's how pools are built.
Reference: Model & ModelAnimation · Node · Aspects · Host globals.
Next: Camera & picking — orbit the yard with a finger and hit things.