Models and scenery
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 afallbackdrawn until the file is loaded or while it is still a placeholder.Scenery: one static.glbplaced 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). The character's own mesh, CharacterMesh, wraps Model and is documented with the controller (Character and cameras).
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:
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() }.
<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.
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.
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
Sceneryfor anything repeated instead of manyModels. - Keep gameplay on capsules and boxes; the model is what is drawn.
collidesandlevelare 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 (
Extrudedfrom an outline,Lathedfrom a profile) and level pieces live inlevel/(Character and cameras).