Skip to main content

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 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). 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.

PropTypeDefaultWhat it does
srcnumberrequiredrequire('../../assets/models/hero.glb')
heightnumbernoneScale so it stands this tall in metres (characters)
sizenumbernoneScale 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
animationstringthe first clipClip to play by name; cross-fades (0.2 s) on change
oncestring[]noneClips that play once and hold their last frame (a death, an attack)
clipsRecord<string, number>noneExtra clips from other .glb files: { run: require('../../assets/models/hero.run.glb') }
timeScalenumber1Clip playback speed
graphAnimationGraph or (clipNames) => AnimationGraphnoneDrive the clips with an animation graph instead of animation
onPose(scene, dt) => voidnoneCalled every frame after the pose is applied: IK and pose tweaks
fallbackReact.ReactNodenullDrawn while loading, for a placeholder file, or when loading fails
tintstringnoneMultiplied into every material's colour (team colours from one model); materials are cloned per instance
castShadowbooleantrue
receiveShadowbooleanfalse
position, rotation, scale, visibleOn the outer group
onLoad(gltf) => voidnoneThe loaded file
collides'box', 'sphere', 'hull'noneInside <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
massnumber10kg, for a dynamic body
onBody(id: BodyId) => voidnoneThe physics body's id
levelLevelnoneWith 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.

ExportWhat it is
AnimationGraphStates, blend spaces, transitions, clip events and overlays
locomotionGraph(clips, speeds)A ready graph for idle, walk, run, jump, fall, land
blendWeights, findClipHelpers for blends and finding a clip by name
threeClipPlayer(mixer, actions)Adapts three's mixer to the graph
maskClip, stripRootMotion, clipRootSpeed, LAYER_MASKSClip utilities (upper-body overlays, in-place clips, root speed)
findRig, FootPlanter, lookAtTarget, applyTwoBoneIK, solveTwoBoneFeet planted on slopes and steps, heads that turn to look

Loaders​

ExportWhat 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>
))}
/>
);
PropTypeDefaultWhat it does
srcnumberrequiredA static model
itemsSceneryItem[]requiredEvery place it stands
stretchbooleantruesize fills each axis on its own; false keeps the model's proportions inside the box
fallbackReact.ReactNodenullDrawn while loading, for a placeholder, or on failure
castShadowbooleanfalse
receiveShadowbooleantrue
litnumber0Let the model's own colours glow (0 to 1) so it reads at dusk or night; about 0.3 keeps the shading
levelLevelnoneMake every item solid for the controller and bots (a hidden box per item while mounted). Item positions must then be world positions

A SceneryItem:

FieldTypeWhat it is
position[x, y, z]Where the model's base centre goes
rotationYnumber, optionalTurn in radians. Generated models face +Z; Math.PI faces -Z
size[w, h, d], optionalThe box to fill, in metres, before the turn
heightnumber, optionalKeep proportions and stand this tall (ignored with size)
colorstring, optionalMultiplied 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" />
);
PropTypeDefaultWhat it does
sourcenumberrequiredThe require()d sheet
cols, rowsnumber1, 1The grid
framenumber0Cell index, reading order
position[x, y, z][0, 0, 0]Base centre in metres
sizenumber1Height 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)
tintstringnoneMultiplies the art's colours
opacitynumber1
fallbackReact.ReactNodenullDrawn until the art is real
visiblebooleantrue

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 Models.
  • 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).