---
title: Many characters on screen (culling + LOD)
summary: Read when many skinned characters drop the frame rate — culling that is safe for animated bodies, mesh + skeleton LOD from `lecodes assets doctor --lod`, distance-scaled animation, the Model.lod / Animator.lod knobs.
area: 3d
globals: []
sources: [src/gl/Model.ts, src/gl/animation/Animator.ts, src/gl/Scene.ts]
---

# Many characters on screen (culling + LOD)

A crowd of animated GLB characters — enemies, NPCs, spectators — without the frame rate falling
off a cliff: frustum culling that is safe for skinned meshes, mesh + skeleton LOD levels made by
the asset tool, and animation that costs less the farther it is.

- keep the frame rate up with dozens of animated enemies or NPCs
- make lower-detail mesh levels for a character model
- force full detail on the player's body or a close-up model
- make far-away characters cheaper to animate
- turn off frustum culling for a model that disappears
- set a "LOD distance" graphics option

## When the frame rate drops with many animated characters

- More than a handful of skinned models at once, or one heavy rig (20k triangles, 60+ joints)
  that appears many times. The symptom: fps drops as the count grows **even when the camera is
  not looking at them** — that is the culling half; fps drops only when they are on screen — that
  is the LOD half.
- Anything drawn from a `Model` gets the culling and the animation LOD for free; the mesh levels
  need one asset step. Nothing here is a runtime API you must call — the knobs exist for the
  cases where the automatic pick is wrong.

## The parts: culling, mesh LOD, animation LOD and their knobs

| Part | Where | What it does |
|---|---|---|
| Frustum culling | engine, on by default | a model the camera cannot see skips the draw and the shadow pass; a skinned mesh's bounds follow its joints every frame, so a walking, kneeling or ragdolled body is never culled while visible |
| Mesh LOD levels | the GLB (`lecodes assets doctor --lod`) | `<mesh>_LOD1`, `_LOD2` … sibling nodes, same skin and materials; the engine shows the one that fits the model's size on screen |
| Skeleton LOD | the GLB (`--lod-bones`) | the last level's skin keeps ~⅓ of the joints (fingers, toes, twist helpers folded onto their parents) |
| Animation LOD | engine, on by default | small on screen / out of view → the animator is evaluated every 2nd or 4th frame and stops sampling finger, toe and twist tracks; clocks and events stay exact |
| `Model.lod` | SDK knob | `'auto'` or a pinned level 0–3 for mesh **and** animation |
| `Animator.lod` | SDK knob | `'auto'` or `'full'`: exact animation whatever the distance; the mesh still switches |
| `scene.setLodBias()` | SDK knob, engine-wide | every threshold × bias — a graphics-settings "LOD distance" |

Levels by the model's height on screen (fraction of the viewport): 0 ≥ 30 %, 1 ≥ 12 %, 2 ≥ 5 %,
3 below that or out of view; switching back to a finer level needs 1.25× the threshold, so a
model at the boundary does not flicker. `scene.setLodBias(2)` makes every level switch at half the
distance, `0.5` keeps full detail twice as far.

## Steps: make the LOD levels, load, pin a level

**1. Make the levels once, in the asset pipeline** — every mesh with ≥ 2000 triangles gets them:

```
lecodes assets doctor assets/bot/hero.glb --lod 0.5,0.2 --lod-bones
#   lod: Hero 20062 / 10031 / 4011 tris
#   lod bones: Hero last level 62 → 22 joints
```

A level the simplifier cannot shrink (hard-surface props with many seams) is dropped with a
message — try a smaller ratio or leave that model without levels. A viewer without LOD support
draws every level on top of each other (harmless), so the file stays portable.

**2. Load as usual** — nothing changes in code:

```ts
const main = async () => {
  const bot = await Model.load(asset('./assets/bot/hero.glb'))   // culling on, levels grouped by name
  bot.anim.playLoop('Idle')
}
main()
```

**3. Pin a level only where the automatic pick is wrong:**

```ts
const main = async () => {
  const hero = await Model.load(asset('./assets/bot/hero.glb'))
  hero.lod = 0                    // the player's own body / a showcase: always full mesh + full animation
  const extra = hero.clone()
  extra.lod = 3                   // a crowd filler: coarsest mesh, animation every 4th frame, no fingers
  const target = hero.clone()     // a clone inherits the model's pinned level (not the animator's)
  target.lod = 'auto'
  target.anim.lod = 'full'        // seen through a scope: exact pose however small; the mesh may still switch
}
main()
```

**4. Keep the escape hatch in mind:** `Model.load(src, { culling: false })` or `model.culling = false`
draws a model always — for a skybox-sized mesh or while debugging a body you believe is culled
wrongly. It is the old default, and the wrong default for a crowd.

## Verify: LOD stats and fps on the desktop host

Reference project: `fps-demo` (not published — the steps above are self-contained; 24 rifle bots,
verified 2026-08-29 on the desktop host, `stress-bots.ts` + `tests/.stress/*.flow.json`). The steps
and the LOD statistics were re-checked 2026-09-30 on the desktop host with a `doctor --lod` GLB and
a pinned level.

- Native only (headless renders 3D as JSON and has no LOD pass): run the scene through
  `lecodes render --desktop --script …` with `CREATOR_LOD_STATS=1` in the project's `.env` — the
  host prints `[lod] N groups (M movers), levels/frame 0:a 1:b 2:c 3:d, …` every 120 frames; a
  pinned model sits in its level whatever the camera does. `CREATOR_PHASE_STATS=1` prints the CPU
  split (`anim` and `skin` fall as models leave level 0/1); `CREATOR_PERF=1` (or a visible
  `lecodes desktop run --perf`) adds a `[frame]` / `[perf]` pair a second — fps, the CPU tick and
  the GPU frame time.
- A/B the pass itself with `CREATOR_LOD=0` (everything at full detail, culling still on).
- Look: a close-up, a body at the screen edge, one 30 m away, then close again — every body
  drawn, no pop when the level switches. fps-demo keeps these as `tests/.stress/stress-verify.flow.json`
  and `stress-lod2.flow.json`; its `tests/waves.flow.json` is the regression.
- What the reference project measured (Windows GL host, 24 bots): animators 836 → 365 µs and
  skin flush 166 → 85 µs per frame from the animation LOD; culling alone took the
  bots-off-screen case from 29 to 57 fps. Triangle count was not the on-screen lever on that
  GPU — mesh levels are for tile GPUs and the shadow pass.

## Traps: a vanishing model, lost levels, root motion

- A skinned model that vanishes up close means a host without the joint-bounds refit (an old
  wasm / Android / Apple build): the SDK only defaults culling on where the host reports that
  refit; do not force `culling: true` there.
- The LOD tool names levels `<node>_LOD<n>`; a node you rename afterwards loses its levels
  (they stay drawn all at once). Re-run the doctor after renames.
- Morph targets are not carried to the levels; a face with blend shapes wants `lod = 0` or no
  levels on that mesh.
- The animation LOD holds a pose between evaluations and hands root motion out in bursts at
  level 3 — fine for a distant walker, wrong for a character whose root motion drives gameplay
  up close: pin that one with `anim.lod = 'full'`.
- Measuring: a laptop GPU throttles after a few minutes of sustained runs; compare A and B back
  to back, never across an hour.
