Skeletal models and animation clips

Play GLB clips per character, crossfade them and ground feet on the animated pose.

Load a rig and read its clips

loadModel resolves a LoadedModel whose animations list each clip's name and duration in seconds; jointCount is 0 for a static mesh. Read clip names from the asset instead of assuming one called walk.

Skins hold up to 128 joints. Every entity spawned from the model has its own clip player, so characters sharing a model animate independently.

Load a character (React)

React · browser

Load a character (React)
import { loadModel } from "@nayan-ui/engine";
export async function loadHero() {
const hero = await loadModel("/models/hero.glb", { center: false }); // origin stays at the feet
console.log(hero.jointCount, hero.animations.map((clip) => clip.name)); // 52 ["idle", "walk", "run"]
return hero;
}

Load a character (React Native)

React Native · iOS and Android

Load a character (React Native)
import { loadModel } from "@nayan-ui/engine";
export async function loadHero() {
const hero = await loadModel(require("./assets/hero.glb"), { center: false }); // origin stays at the feet
console.log(hero.jointCount, hero.animations.map((clip) => clip.name)); // 52 ["idle", "walk", "run"]
return hero;
}

Play, hold, crossfade and stop

world.playClip(entity, name, options) switches to a clip, crossfading from the current pose, and returns false if it can't play. Re-selecting the playing clip only changes its speed; pass time to seek.

Hold a ready-screen pose with time: 0 and speed: 0, then play on Start. stopClip(entity) freezes the current pose and stopClip(entity, true) returns to the bind pose.

Play, hold, crossfade and stop reference
ClipOptionsMeaning
loopRepeat the clip. Default true.
speedPlayback rate; 0 holds the pose. Default 1.
fadeCrossfade from the current pose, in seconds. Default 0.15.
timeSeek to this second. Omit to keep the playback position.

Switch clips when the movement mode changes

React and React Native · shared engine API

Call ready() on reset, move(speed) from onUpdate and stop() on pause.

Switch clips when the movement mode changes
import type { Entity, LoadedModel, World } from "@nayan-ui/engine";
export function heroClips(world: World, hero: Entity, model: LoadedModel) {
const has = (name: string) => model.animations.some((clip) => clip.name === name);
let current = "";
const play = (name: string) => {
if (name !== current && has(name) && world.playClip(hero, name, { fade: 0.2 })) current = name;
};
return {
ready() {
world.playClip(hero, "idle", { time: 0, speed: 0, fade: 0 }); // first frame, held
current = "";
},
move: (speed: number) => play(speed > 4 ? "run" : speed > 0.1 ? "walk" : "idle"),
stop() {
world.stopClip(hero); // keep the current pose
current = "";
},
};
}

Ground feet on the animated pose

world.sampleSkinnedVertices(entity, indices, out, after) writes model-space XYZ for chosen vertex indices into out (three floats each) and returns false when it can't sample. Reuse both arrays and query a few boot vertices of your asset; never skin the whole character in JS.

In onUpdate, pass the frame's dt as after: the world steps by it right afterwards, so you read the pose about to be drawn. Scale and transform samples by the actor before comparing them with terrain.

Plant the lower boot on uneven ground

React and React Native · shared engine API

Plant the lower boot on uneven ground
import { GameView, type Entity, type World } from "@nayan-ui/engine";
const BOOTS = new Uint32Array([812, 1430]); // vertex indices from your own asset
const feet = new Float32Array(BOOTS.length * 3);
const at: [number, number, number] = [0, 0, 0];
type Props = { world: World; hero: Entity; scale: number; groundAt: (x: number, z: number) => number };
export function GroundedHero({ world, hero, scale, groundAt }: Props) {
return (
<GameView source={world} style={{ flex: 1 }} onUpdate={(dt) => {
if (!world.position(hero, at) || !world.sampleSkinnedVertices(hero, BOOTS, feet, dt)) return;
const lowest = Math.min(feet[1], feet[4]) * scale; // Y is unaffected by turning around Y
world.transforms.position(hero, at[0], groundAt(at[0], at[2]) - lowest, at[2]);
}} />
);
}