# Models and scenery

> Bundled .glb models through Model (sized, anchored, animated, with a fallback until the file is real), Scenery for one model placed many times with instancing, Billboard sprites in the 3D scene, SheetImage in the HUD, and the loaders behind them.

The models folder (`src/engine/models/`, exported from `@/engine`) puts art files into the 3D scene:

- `Model`: one bundled `.glb` (a character, a vehicle, a prop), scaled to a size you choose, with its clips, and a `fallback` drawn until the file is loaded or while it is still a placeholder.
- `Scenery`: one static `.glb` placed many times (a street of buildings, a forest), drawn as instanced meshes so thirty buildings cost about what one does.
- `Billboard`: one cell of a sprite sheet on a quad that turns to face the camera (crops, coins, lanterns as cut-outs).

`SheetImage`, the same idea for the HUD, is in `@/engine/ui` ([HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)). The character's own mesh, `CharacterMesh`, wraps `Model` and is documented with the controller ([Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)).

## Smallest working example

A lantern model with a box as its fallback. The game plays the same before the model exists, while it loads, and if it fails:

```tsx
import React from 'react';
import { EngineCanvas, Environment, Model, preloadGlb } from '@/engine';

const LANTERN = require('../../assets/models/lantern.glb');
preloadGlb(LANTERN); // start loading while the title screen shows

export const LanternScene = () => (
  <EngineCanvas camera={{ position: [0, 2, 4] }}>
    <Environment preset="night" />
    <Model
      src={LANTERN}
      size={0.6}
      position={[0, 0, 0]}
      fallback={
        <mesh position={[0, 0.3, 0]}>
          <boxGeometry args={[0.4, 0.6, 0.4]} />
          <meshStandardMaterial color="#F6C85F" />
        </mesh>
      }
    />
  </EngineCanvas>
);
```

The template's `metro.config.js` adds `glb`, `gltf`, `bin` and `hdr` to Metro's asset extensions, so `require('….glb')` works. Put model files in `assets/models/`.

## `Model`

`Model` loads the file once per asset (cached), clones it per use, scales it, and puts its bottom (or centre) at the group origin. Your gameplay sizes (capsule radius, collision boxes) stay as they are.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `src` | `number` | required | `require('../../assets/models/hero.glb')` |
| `height` | `number` | none | Scale so it stands this tall in metres (characters) |
| `size` | `number` | none | Scale so its longest side is this, in metres (props, vehicles). Ignored when `height` is set |
| `anchor` | `'feet'` or `'center'` | `'feet'` | Where the group origin sits |
| `animation` | `string` | the first clip | Clip to play by name; cross-fades (0.2 s) on change |
| `once` | `string[]` | none | Clips that play once and hold their last frame (a death, an attack) |
| `clips` | `Record<string, number>` | none | Extra clips from other `.glb` files: `{ run: require('../../assets/models/hero.run.glb') }` |
| `timeScale` | `number` | `1` | Clip playback speed |
| `graph` | `AnimationGraph` or `(clipNames) => AnimationGraph` | none | Drive the clips with an animation graph instead of `animation` |
| `onPose` | `(scene, dt) => void` | none | Called every frame after the pose is applied: IK and pose tweaks |
| `fallback` | `React.ReactNode` | `null` | Drawn while loading, for a placeholder file, or when loading fails |
| `tint` | `string` | none | Multiplied into every material's colour (team colours from one model); materials are cloned per instance |
| `castShadow` | `boolean` | `true` | |
| `receiveShadow` | `boolean` | `false` | |
| `position`, `rotation`, `scale`, `visible` | | | On the outer group |
| `onLoad` | `(gltf) => void` | none | The loaded file |
| `collides` | `'box'`, `'sphere'`, `'hull'` | none | Inside `<Physics>`: a body from its fitted bounds, or its convex hull (computed once per asset) |
| `body` | `'static'` or `'dynamic'` | `'static'` | A dynamic body falls and gets knocked over; then place the model directly under the scene |
| `mass` | `number` | `10` | kg, for a dynamic body |
| `onBody` | `(id: BodyId) => void` | none | The physics body's id |
| `level` | `Level` | none | With `collides` on static scenery: its bounds join `level.boxes` as a hidden box while mounted, so the character controller and bots collide with it |

The ref is a `ModelHandle`: `{ group, clipNames() }`.

```tsx
<Model src={require('../../assets/models/stall.glb')} size={3} position={[4, 0, -2]} collides="box" level={level} />
```

Generated characters from the Moonspell pipeline face +Z with their feet at the origin. If a model faces another way, turn it with `rotation`.

### Animation and IK exports

These are used by `CharacterMesh` and by `Model graph`. The character page covers them in use.

| Export | What it is |
| --- | --- |
| `AnimationGraph` | States, blend spaces, transitions, clip events and overlays |
| `locomotionGraph(clips, speeds)` | A ready graph for idle, walk, run, jump, fall, land |
| `blendWeights`, `findClip` | Helpers for blends and finding a clip by name |
| `threeClipPlayer(mixer, actions)` | Adapts three's mixer to the graph |
| `maskClip`, `stripRootMotion`, `clipRootSpeed`, `LAYER_MASKS` | Clip utilities (upper-body overlays, in-place clips, root speed) |
| `findRig`, `FootPlanter`, `lookAtTarget`, `applyTwoBoneIK`, `solveTwoBone` | Feet planted on slopes and steps, heads that turn to look |

## Loaders

| Export | What it does |
| --- | --- |
| `loadGlb(module)` | Loads and caches a bundled `.glb`; resolves to three's `GLTF`. The scene is shared: clone it before adding it (`Model` does) |
| `preloadGlb(module)` | Starts loading ahead of time; logs a warning on failure |
| `isPlaceholderGlb(gltf)` | True for a placeholder file (see below) |

Why a custom loader: three's `GLTFLoader` expects a browser image decoder for the textures inside a model, which React Native does not have. The engine registers a loader plugin that decodes every texture (PNG, JPEG, WebP) with Skia into a `DataTexture`. Meshes, skins, animations and PBR materials are three's own parser. On the phones the file's bytes come from `expo-asset` and `expo-file-system`; in a browser they are fetched from the asset's URL.

### Placeholders

In the hosted Moonspell pipeline, a game first references a model file that is a valid but empty placeholder `.glb` (marked with `asset.extras.crescentPlaceholder` in the glTF JSON); the real model is generated after the code pass and replaces the file. `Model`, `Scenery` and `Billboard` draw their `fallback` for a placeholder, so the game plays the same before and after. Outside Moonspell you usually add the real file directly, but keep a fallback anyway: it also covers loading time and load failures.

## `Scenery`

One static model placed many times. Each mesh part of the model becomes one `InstancedMesh`, so the draw cost does not grow with the number of items. Skinned (animated) meshes are skipped: characters go through `Model`.

```tsx
import React from 'react';
import { Scenery, type SceneryItem } from '@/engine';

const BROWNSTONE = require('../../assets/models/brownstone.glb');

const lots = [
  { x: -8, z: 0, width: 7, height: 12, side: -1, tint: '#E8D8C8' },
  { x: -8, z: -9, width: 8, height: 15, side: -1 },
  { x: 8, z: 0, width: 7, height: 11, side: 1 },
];

const row: SceneryItem[] = lots.map((lot) => ({
  position: [lot.x, 0, lot.z],
  rotationY: lot.side < 0 ? Math.PI / 2 : -Math.PI / 2,
  size: [lot.width, lot.height, 10],
  color: lot.tint,
}));

export const Street = () => (
  <Scenery
    src={BROWNSTONE}
    items={row}
    fallback={row.map((item, i) => (
      <mesh key={i} position={[item.position[0], (item.size?.[1] ?? 1) / 2, item.position[2]]}>
        <boxGeometry args={item.size ?? [1, 1, 1]} />
        <meshStandardMaterial color="#8A6A5A" />
      </mesh>
    ))}
  />
);
```

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `src` | `number` | required | A static model |
| `items` | `SceneryItem[]` | required | Every place it stands |
| `stretch` | `boolean` | `true` | `size` fills each axis on its own; `false` keeps the model's proportions inside the box |
| `fallback` | `React.ReactNode` | `null` | Drawn while loading, for a placeholder, or on failure |
| `castShadow` | `boolean` | `false` | |
| `receiveShadow` | `boolean` | `true` | |
| `lit` | `number` | `0` | Let the model's own colours glow (0 to 1) so it reads at dusk or night; about 0.3 keeps the shading |
| `level` | `Level` | none | Make every item solid for the controller and bots (a hidden box per item while mounted). Item positions must then be world positions |

A `SceneryItem`:

| Field | Type | What it is |
| --- | --- | --- |
| `position` | `[x, y, z]` | Where the model's base centre goes |
| `rotationY` | `number`, optional | Turn in radians. Generated models face +Z; `Math.PI` faces -Z |
| `size` | `[w, h, d]`, optional | The box to fill, in metres, before the turn |
| `height` | `number`, optional | Keep proportions and stand this tall (ignored with `size`) |
| `color` | `string`, optional | Multiplied with the model's colours: one model, many looks |

Placement maths is exported for tests and your own colliders: `itemMatrix(item, bounds, stretch?, out?)`, `itemBox(item, bounds, stretch?)`, `itemScale(item, bounds, stretch?)` and `boundsOf(box3)`.

## `Billboard`

A sprite in the 3D scene: one cell of a sheet on a quad standing on its base, turning to face the camera. Until the sheet is real art (a 1 by 1 placeholder image), the `fallback` is drawn instead.

```tsx
import React from 'react';
import { Billboard } from '@/engine';

const CROPS = require('../../assets/sprites/crops.png');

export const Wheat = ({ x, z, stage }: { x: number; z: number; stage: number }) => (
  <Billboard source={CROPS} cols={4} rows={4} frame={stage} position={[x, 0, z]} size={0.9} face="y" />
);
```

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `source` | `number` | required | The `require()`d sheet |
| `cols`, `rows` | `number` | `1`, `1` | The grid |
| `frame` | `number` | `0` | Cell index, reading order |
| `position` | `[x, y, z]` | `[0, 0, 0]` | Base centre in metres |
| `size` | `number` | `1` | Height in metres (cells are square) |
| `face` | `'y'` or `'full'` | `'y'` | `y` spins around the vertical axis only (trees, crops); `full` faces the camera exactly (coins, icons) |
| `tint` | `string` | none | Multiplies the art's colours |
| `opacity` | `number` | `1` | |
| `fallback` | `React.ReactNode` | `null` | Drawn until the art is real |
| `visible` | `boolean` | `true` | |

`useSheetTexture(module)` returns the decoded sheet as a three `DataTexture` (shared by every billboard that uses it), or `null` while loading or for a placeholder. Use it for your own materials.

## Gotchas

- Textured models cost memory. One hero and a few props is a good budget on a phone; use `Scenery` for anything repeated instead of many `Model`s.
- Keep gameplay on capsules and boxes; the model is what is drawn. `collides` and `level` are the bridges when a model must be solid.
- With `Model level`, mount the model before building the bots' nav grid, or rebuild the grid after.
- Skinned meshes have frustum culling turned off (they move past their rest bounds), so many animated characters off screen still cost draw time.
- Basis and KTX2 textures are left to three's own plugin, which the engine does not set up. Use PNG, JPEG or WebP textures.
- Never build a model's path or URL by hand. Load it with `require()`: a web build names assets by address.
- Procedural shapes (`Extruded` from an outline, `Lathed` from a profile) and level pieces live in `level/` ([Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)).
