---
name: incanto-3d-models
description: Loading GLB/glTF and VRM models in Incanto — the ModelInstance3D node, declaring model assets, sizing with targetHeight, playing embedded animations, and the incanto-model CLI that reports a file's hierarchy/bounding box/animations so you can place it correctly. Use when putting 3D model files into a scene.
---

# 3D Models (GLB · glTF · VRM)

> Shipped inside the `incanto` npm package — this document always matches the
> installed engine version. Sibling skills live in `node_modules/incanto/skills/`.

## Where models come from

**Search, don't invent.** Model URLs come from your asset MCP (vector search
over the agent8 library) or from the editor's 📚 button
(`bunx incanto-editor --token <v8 token>`) — never from a guessed path. There is
no 3D model in the package's own catalog: `incanto-assets` ships 2D sprites,
tiles, items, foliage/terrain textures and audio, and characters are the one
thing you are expected to go and get.

Then run the URL through the inspector, which is where a found file becomes a
scene:

```bash
bunx incanto-model "https://…/base-model.glb"
#   mixamorig rig — the 3d/animations clips play on this as they are
#   stands 1.5 units tall
#
#   paste a playable character into your scene:
#   { "assets": {…model + idle/walk/run/fastRun/airborne clips…},
#     "input":  {…move / jump / sprint…},
#     "node":   { "type": "RigidBody3D", … Controller + Skin … } }
```

For a **mixamorig or VRM humanoid** that is the whole character — body,
controller, skin fitted to the capsule and dropped to its feet, the locomotion
clips declared, and the input actions without which nothing reads the keyboard.
Paste it and the character walks. For a prop, you get the model node.

### The locomotion clips

`https://agent8-games.verse8.io/assets/3d/animations/mixamorig/<name>.glb` —
`idle-00` · `walk` · `run-medium` · `run-fast` · `jump`, plus
`melee-attack`, `punch-00/01`, `kick-00/01/02`, `cast`, `swim`,
`hit-to-body`, `death-backward`, `dance-wave`, and the `rifle-`/`pistol-`
sets.

These are not art you go searching for — they are the rig's vocabulary. They
play on a mixamorig model as they are, and retarget through the humanoid map
onto a VRM, so they fit whatever character your asset search returned.

## ALWAYS inspect before you place

A file name tells you nothing about a model's size or contents. The package ships an
inspector — run it FIRST and read the numbers:

```bash
bunx incanto-model public/models/knight.glb
#   size:   1.1 × 1.8 × 0.6  center: [0, 0.9, 0]     ← stands 1.8 units tall
#   anim Idle: 2.4s
#   anim Run: 0.8s                                       ← exact clip names for `animation`
bunx incanto-model avatar.vrm --json                   # full machine-readable report
bunx incanto-model https://.../goblin.glb              # a URL works anywhere a path does
```

The report gives you: the TRANSFORMED scene bounding box (size/center/min/max — skinned
meshes measured in bind pose), the node hierarchy, per-mesh vertex/triangle counts,
animation clip names + durations, materials/textures/skins, and for VRM the avatar
meta (name/authors) + humanoid bone count. Decision rules:

- **size.y** tells you the model's natural height → pick `targetHeight` (characters in a
  meters-scale scene: 1.6–1.9) or leave 0 and use the node's `scale`. Note the CLI reports
  the BIND pose; a rigged character on screen is measured as drawn, so the two can differ
  by a few percent. `targetHeight` uses the drawn size — the number you write is the
  height you get.
- **center** ≠ [0,0,0] means the model is off-origin — compensate with the node `position`
  (a center.y of half the height usually means feet at origin: good).
- **animation names** are exact strings for the `animation` prop.

**At runtime the node itself will tell you**, which is what a harness or a
behaviour picking a random idle needs:

```ts
model.availableAnimations();   // embedded clip names PLUS the scene's $animation assets
model.boneNames();             // every bone, for BoneAttachment3D's `bone`
model.findBone('RightHand');   // the Object3D, matching mixamorig prefixes too
```

`availableAnimations` is the one to check before setting `animation` from data:
a clip name that is not in the list plays nothing and says nothing.

## Scene JSON

```json
"assets": {
  "knight": { "type": "model", "url": "/models/knight.glb" },
  "avatar": { "type": "model", "url": "/models/avatar.vrm" }
},
"root": { "name": "World", "type": "Node3D", "children": [
  { "name": "Knight", "type": "ModelInstance3D",
    "props": { "model": "$knight", "targetHeight": 1.8, "animation": "Idle",
               "position": [0, 0, 0], "castShadow": true } },
  { "name": "Sun", "type": "DirectionalLight3D", "props": { "position": [3, 5, 4] } },
  { "name": "Cam", "type": "Camera3D", "props": { "position": [0, 2, 6], "current": true } }
] }
```

`ModelInstance3D` props: `model` (`$key` or a direct URL), `targetHeight` (>0 uniformly
scales the model to stand that many units tall — composes with the node `scale`, reactive
at runtime; see "Sizing" below), `animation` (see below), `tint` (any CSS colour — `"#5f8f4a"`, `"chartreuse"`; `""`=off — multiplies into
every material to RESKIN one shared GLB into many variants, e.g. base human → green
zombie; clones materials per instance so other instances are untouched),
`metalness`/`roughness` (0..1 overrides applied to EVERY material; `-1`=keep authored —
low-poly kits usually ship fully rough, so `metalness: 0.6, roughness: 0.25` turns flat
paint into a glinting clear-coat under the scene's sky IBL; per-instance clones like
tint), `castShadow`/`receiveShadow`, plus the usual Node3D transform.

## Animating named sub-nodes (wheels, turrets, doors) — `poseNode`

## What the file will DO here

`incanto-model` ends with a verdict, and it answers the three questions that
cost the most time. Finding an asset is the agent8 library's job — its search
matches meaning. This says whether what it found works in YOUR scene:

```
mixamorig rig — the 3d/animations clips play on this as they are
stands 1.5 units tall
```
```
skinned, but NOT a mixamorig rig — the 3d/animations clips will not bind to it
(bones are matched by name; a mismatch keeps the bind pose and only warns)
stands 28.45 units tall — not a character's size; set targetHeight (1.7) to fix it
clips in the file: die, idle, walk, normal_attack, … (play by name, no asset needed)
```
```
does NOT stand on its own origin — it sits 10.1 units above the origin. A node at
[0,0,0] puts it there too; offset the node, or expect an empty-looking frame
```
```
an animation CLIP — no mesh of its own. It plays ON a character: declare it as
{"type":"animation"} and point a ModelInstance3D's `animation` at it. Used as a
`model` it draws nothing at all, with no error
```

It then prints the scene JSON to paste — the asset entry and the node, with
`targetHeight` already set when the raw size calls for it.

Many GLBs ship articulated parts as NAMED nodes (`bunx incanto-model <file>` lists them —
e.g. the Kenney vehicles have `wheel-front-left` … `wheel-back-right`). From a behavior,
rotate one per frame with `poseNode(name, [rxDeg, ryDeg, rzDeg])` (local Euler XYZ):

```ts
// spin the wheels by travel + steer the fronts (Kenney car GLB)
const model = car.getNodesByName('CarModel')[0] as ModelInstance3D;
this.spin += this.speed * dt * 60; // degrees of roll
for (const w of ['wheel-back-left', 'wheel-back-right'])
  model.poseNode(w, [this.spin, 0, 0]);
for (const w of ['wheel-front-left', 'wheel-front-right'])
  model.poseNode(w, [this.spin, steerDeg, 0]);
```

Poses are queued if the model hasn't finished loading, re-applied every sync, and
unknown names are ignored (no throw). This is intent-level articulation — for full
skeletal animation use `animation` clips instead.

**`targetHeight` + skinned rigs (the implausible-scale warning).** Mixamo-style humanoid
rigs hide their true size behind a ~0.01 armature scale + bone-driven vertex scaling, so
a bounding box can't measure them. If `targetHeight` would imply a scale `<0.05×` or
`>20×`, the engine WARNS and renders at the model's authored (1×) scale instead — this is
EXPECTED and fine: humanoid GLBs already ship at ~1.6–1.9 m, so they look right at 1×.
Don't fight the warning. If a NON-humanoid (a 1:100 prop) really is mis-sized, use the
node `scale` instead of `targetHeight`. Always `bunx incanto-model <file>` first to see
the real `size.y`.

## Grounding a model on the floor / terrain

A model's feet sit at the model's OWN origin, which is usually `y=0` but not always —
`bunx incanto-model` reports `center.y`. Two cases:

- **Static placement:** set the node's `position[1]` so the feet rest on the ground
  (e.g. on a Terrain3D, `position[1] = terrain.heightAt(x, z)`; offset by `-center.y`
  if the model's origin isn't at its feet).
- **A MOVING body driven by a custom AI (enemy/NPC) — IT WON'T AUTO-FALL.** A
  `CharacterBody3D`/`RigidBody3D` whose position you set every frame from script is
  effectively kinematic: nothing integrates gravity, so it floats at its spawn height.
  Ground it yourself in the behavior's `update()`:

  ```ts
  // keep the husk on the rolling terrain every frame (AI moved only x/z)
  const t = this.node.getRoot().getNodeOrNull('World/Terrain/Ground/Surface'); // your Terrain3D
  if (t) { const p = body.position; body.position = [p[0], t.heightAt(p[0], p[2]) + footLift, p[2]]; }
  ```

  `heightAt(x, z)` takes WORLD x/z and returns the WORLD surface y. (`incanto/gameplay`
  movement behaviors like `ZombieAI` deliberately move only in the ground plane and leave
  the up axis to you, exactly so you can ground it like this — or let real physics do it.)

## Troubleshooting a 3D model

| Symptom | Likely cause → fix |
|---|---|
| Faces sideways/backwards | Wrong yaw — see incanto-3d-character.md "+Z-FORWARD rule" (don't add 180° to move-facing; use `skinYawOffset` for non-+Z models). |
| Giant / tiny | `bunx incanto-model` → read `size.y` → set `targetHeight` to your scene scale. Implausible-scale warning on a humanoid? Accept it (renders at 1×). |
| Floats / sinks | Origin offset (`center.y`) or an AI-driven body that won't fall — ground it (above). |
| No animation | Clip name typo (match `bunx incanto-model` output EXACTLY, case-sensitive), or the model/asset isn't declared, or you set `animation` before the model finished loading (it applies once ready), or a GLB clip's bone names don't match the model. |
| Black silhouette | No light — add a DirectionalLight3D or `environment.ambient`. |

## Animation assets — clips as data

Animations are first-class assets: `{type:"animation", url, clip?}` loads a GLB's clips
INTO MEMORY (never drawn). `animation` then accepts either an EMBEDDED clip name from the
model file, or an animation-asset reference:

```json
"assets": {
  "knight": { "type": "model",     "url": "/models/knight.glb" },
  "avatar": { "type": "model",     "url": "/models/avatar.vrm" },
  "run":    { "type": "animation", "url": "/anims/run.glb" }
},
…  { "props": { "model": "$knight", "animation": "Idle"  } }   // embedded clip
…  { "props": { "model": "$avatar", "animation": "$run" } }    // asset clip
```

- GLB models bind asset clips by NODE NAME — the animation file's rig must use the same
  bone names as the model (e.g. both exported from the same skeleton).
- **The animation never reshapes the model.** A Mixamo clip carries a position AND a
  scale track for every bone, not just rotations, so binding it raw would replace your
  character's skeleton with the one the clip was authored on — a stocky model silently
  becoming the proportions of whoever the animation was made from. Incanto retargets
  instead: rotations cross over untouched, the hips translation is scaled to THIS model's
  hip height (hip travel is in the source rig's units — a short character given a tall
  rig's stride slides its feet), and every other bone translation and scale is dropped.
  Your model keeps the proportions it was authored with. A clip whose bones this model
  does not have at all is played as-is, with a warning.
- **VRM models retarget through the humanoid rig**: Mixamo-rigged animation GLBs
  (`mixamorigHips`…) are mapped through the mixamo→VRM humanoid rigmap
  (rest-pose-compensated rotations, hips height scaling, VRM 0.x axis flips) and played
  on the VRM's normalized rig. Unmapped bones are logged. This is how you give any VRM
  avatar a Mixamo walk/run/idle: export the Mixamo animation as GLB (without skin works),
  declare it as an animation asset, set `animation: "$walk"`.
- `clip` in the asset declaration selects a clip when the file holds several
  (default: the first).

Models need LIGHT (standard materials): add a DirectionalLight3D or scene
`environment.ambient` or you will see black silhouettes.

## A model that just animates — no script

Setting `animation` is the whole thing. The clip starts the moment the model
mounts and keeps looping; nothing has to call play(). That is how you dress a
scene with a torch that flickers, a flag that waves, a fish that swims, or an
idling NPC:

```json
{ "name": "Torch", "type": "ModelInstance3D",
  "props": { "model": "$torch", "animation": "Flicker" } }
```

Four props shape it, and they are all live (a behavior or the editor can change
them mid-play without restarting the clip):

| prop | default | what it does |
|---|---|---|
| `animation` | `""` | embedded clip name, or `$key` of an animation asset. `""` = still |
| `animationLoop` | `true` | `false` = play once, hold the last pose, emit `animationFinished` |
| `animationSpeed` | `1` | playback rate. `0.5` half, `2` double, `0` freezes the pose, **negative plays backwards** (a closing door is an opening one reversed) |
| `animationOffset` | `0` | seconds into the clip to start at, wrapped into its duration |

**`animationOffset` is what stops a scene looking like copies.** Ten torches all
starting at 0 flicker in perfect unison and the eye reads it instantly. Give each
a different offset and the same clip becomes a crowd:

```json
{ "name": "Torch2", "type": "ModelInstance3D",
  "props": { "model": "$torch", "animation": "Flicker",
             "animationOffset": 0.7, "animationSpeed": 0.9 } }
```

A generator or scatter can hand out `index * 0.37` without knowing the clip
length — offsets wrap, so any number is legal.

For a second clip on the upper body only (attack while running), see
`animationUpper` / `animationUpperLoop` / `upperBodyRoot` in the node reference.

## How loading works

`Renderer3D` owns an `AssetStore3D` (GLTFLoader + `@pixiv/three-vrm`'s VRMLoaderPlugin).
Models load async and pop in when ready; every node mounts a skeleton-aware CLONE, so one
asset can back any number of instances. VRM specifics: 0.x files get their facing fixed
(`rotateVRM0`), unnecessary vertices removed, skeletons combined.

## In the editor

Declare the assets in the ⚙ scene row (`assets` JSON) — or open a scene that has them —
and the inspector's `model`/`animation` fields offer DROPDOWN suggestions (model `$key`s;
embedded clips + animation `$key`s). Selecting an animation plays it LIVE in the edit
viewport (game-logic time stays frozen; only model animation previews).

## Limits (be honest with users)

- A VRM mounts its source scene: humanoid rig + springbones stay live via `vrm.update`,
  but ONE node per VRM asset (a second node warns and stays empty). GLB models clone
  freely.
- GLB↔GLB cross-file animation needs matching BONE NAMES. Retargeting adapts the
  animation to your model's proportions; it does not map one naming scheme onto another.
  No animation blending yet.
- `targetHeight` measures a rigged character ONCE, on the first frame after it is posed —
  so it cannot resize mid-stride, and a model whose clip has not loaded yet renders at its
  file size for a frame or two before settling.
