Model
A 3D model loaded from a GLB file (a character, a prop, a level piece): its node hierarchy,
materials and embedded animation clips. Built-in shapes and custom geometry are Mesh;
playing the clips is the Animator every model carries at model.anim. Extends
Node.
- load a GLB model into the 3D scene
- play the animations that come with the GLB
- spawn many copies of a model without loading it again
- hide or find a part of a model by name
- turn off a model's shadows
- show less detail on models far from the camera
Platforms: iOS, Android, desktop, the web, the web player, the le.codes viewer app, headless
tests. On the web (Web Lite) a GLB that requires meshopt compression loads without its meshes, and
model.lod and pickTriangle do nothing; headless tests load the hierarchy and clips but draw nothing.
At a glance
const main = async () => {
const scene = new Scene()
scene.add(Light.sun())
scene.open()
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
}
main()Loading
Model.load(source: string | FetchResponse, options?: {
culling?: boolean // frustum culling; default on where the host refits skinned bounds (see below)
onProgress?: (p: { loaded: number, total?: number }) => void
lightmap?: boolean // true = a baked static (needs lightmap UVs) — lightmap.md; default false = lit real-time
foliage?: boolean // vegetation: the foliage tier (wind, touch bending, fade, tint) — foliage.md
}): Promise<Model>Model.load loads a .glb file; scene.add() the result. source is an asset('./file.glb')
path, a remote https://… URL, or an already-fetched FetchResponse. A URL load rejects on HTTP ≥
400; a FetchResponse you pass is used as it is, so check its status yourself. Hosts without the
lightmap or foliage tier load through the standard shader.
bytes that are not a glTF (a wrong file, an HTML error page served with status 200) currently take the native host down instead of rejecting — load only GLBs.
Shadows and culling per model — castShadows, culling
model.culling = false // get/set — frustum culling
model.castShadows = false // get/set, default true — every renderable of the instance
model.receiveShadows = false // get/set, default trueA GLB is a whole hierarchy, so the flags go to every renderable in it. A first-person viewmodel —
arms, weapon, attachments — turns castShadows off: its shadow is never wanted.
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 the default stays off, since the engine 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: less detail far from the camera — model.lod
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. scene.setLodBias() scales the thresholds
engine-wide. No-op on hosts without the LOD pass. Recipe:
Many characters on screen.
Spawn many copies of a model — clone()
const enemy = template.clone(): Modelclone() 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, joins its scene, and starts at the source's current
local transform. It has its own independent animation state, reached via clone.anim. It
mirrors the source's culling, pinned lod and shadow flags (not anim.lod).
const main = async () => {
const scene = new Scene()
scene.open()
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.playLoop('Walk') })
}
main()Prefer clone() over re-loading the same URL for spawn pools — one decode, then N instances.
Find or hide parts of a model by name — traverse, bone
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 children
model.bone('RightHand') // a descendant by name — skin joints firstTransform, visible, events, aspects (Shape, Physics, …) and setMaterial all work on the root
and on the internal nodes. A model is tappable once it carries a Shape (see
Node events).
Play the GLB's animations — model.anim
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 (true) 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.
Debugging: the triangle under the pointer — pickTriangle
model.pickTriangle(screenX: number, screenY: number): TrianglePick | nullThe closest triangle of this model under a screen point (logical px), tested against the current
skinned pose: the mesh node and primitive, the triangle index, the hit point, and its three vertices
with bind / skinned positions and bone weights — to trace a stretched or misbound polygon to its
binding. One CPU skin of the model per call (click rate, not per frame); null on a miss or a host
without it.
Pitfalls
// ✗ 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()
// ✗ play() for a looping clip — a one-shot: it runs once, then hands back to the loop
hero.anim.play('Idle')
// ✓ hero.anim.playLoop('Idle')See also
- Node — transform, hierarchy,
traverse,bone, 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
- Foliage —
{ foliage: true }: the vegetation tier - Lightmap —
{ lightmap: true }: baked statics