Play GLB clips per character, crossfade them and ground feet on the animated pose.
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
import { loadModel } from "@nayan-ui/engine";export async function loadHero() {const hero = await loadModel("/models/hero.glb", { center: false }); // origin stays at the feetconsole.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
import { loadModel } from "@nayan-ui/engine";export async function loadHero() {const hero = await loadModel(require("./assets/hero.glb"), { center: false }); // origin stays at the feetconsole.log(hero.jointCount, hero.animations.map((clip) => clip.name)); // 52 ["idle", "walk", "run"]return hero;}
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.
| ClipOptions | Meaning |
|---|---|
| loop | Repeat the clip. Default true. |
| speed | Playback rate; 0 holds the pose. Default 1. |
| fade | Crossfade from the current pose, in seconds. Default 0.15. |
| time | Seek 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.
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, heldcurrent = "";},move: (speed: number) => play(speed > 4 ? "run" : speed > 0.1 ? "walk" : "idle"),stop() {world.stopClip(hero); // keep the current posecurrent = "";},};}
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
import { GameView, type Entity, type World } from "@nayan-ui/engine";const BOOTS = new Uint32Array([812, 1430]); // vertex indices from your own assetconst 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 Yworld.transforms.position(hero, at[0], groundAt(at[0], at[2]) - lowest, at[2]);}} />);}