Skip to content

Skeletal Animation

Skeletal animation is a type of model animation. By rotating and translating the skeleton's joints, it transforms the Mesh vertex positions to drive the model animation.

TIP

  1. Currently the engine only supports the model's built-in skeletal animation. Users need to prepare the corresponding skeletal animation assets in advance with 3D modeling software.
  2. From v0.8, both skeletal animation and Morph animation are driven uniformly through the AnimatorComponent.

Introduction

Each vertex data on a Mesh contains the index numbers of the bones that affect that vertex, as well as the weights of those influences. This type of data is collectively referred to as skinning information. The number of bones that influence a vertex is generally limited to 4; more bones only increase the computational load without significantly improving animation quality.

In the AnimatorComponent, PrefabBoneData contains data related to bone joints, such as name, rotation, translation, and parent bone. Multiple PrefabBoneData entries together form a complete skeleton called PrefabAvatarData.

PropertyAnimationClip is a dataset of curves representing a series of skeletal pose transformations, storing the scale, rotation, and translation transformation data for each bone node.

PropertyAnimationClipState represents the animation playback state. It is associated with PropertyAnimationClip and is used to maintain playback status, interpolation weights, and other related data.

The AnimatorComponent is the driving component for the entire animation. It is associated with multiple PropertyAnimationClipState instances, used to switch and blend between various animation states, driving the final transformation pose of the entire skeletal animation.

Load Animation Model

When loading a model file with skeletal animation data, the engine automatically adds an AnimatorComponent component to the model and adds the model's animation data into it. You can directly get the AnimatorComponent component on the root entity of the model and play the specified animation.

ts
// load test model
let soldier = await engine.res.loadGltf('gltfs/glb/Soldier.glb');
soldier.rotationY = -90;
soldier.localScale.set(2, 2, 2);
scene.addChild(soldier);

// get animator component
let animator = soldier.getComponentsInChild(AnimatorComponent)[0];
animator.playAnim('Walk');

Get Animation Name

The component provides the clips property to get all animation clip data objects, which each have a unique clipName to distinguish different animation states.

ts
let clips = animation.clips;
for (var i = 0; i < clips.length; i++) {
    console.log("Name:", clips[i].clipName)
}

Play Specified Animation

The AnimatorComponent component provides the playAnim method to play the specified animation:

ts
// Play the animation named Walk
animator.playAnim('Walk');

// Play the first animation in the list
let clips = animation.clips;
animator.playAnim(clips[0].clipName);

Adjust Playback Speed

When the playAnim method plays the specified animation, it plays at normal speed (1.0) by default. If you need to speed up playback, set it through the speed parameter. The larger the value, the faster the playback speed; the smaller the value, the slower the playback speed; when the value is negative, it will play in reverse.

ts
// Normal speed
animator.playAnim('Walk', 1);

// 2 times slower
animator.playAnim('Walk', 0.5);

// 3 times faster
animator.playAnim('Walk', 3.0);

// Normal reverse playback
animator.playAnim('Walk', -1.0);

// 3 times faster reverse playback
animator.playAnim('Walk', -3.0);

You can also set the global timeline scaling through the timeScale property on AnimatorComponent, which is the same as speed. The larger the value, the faster the playback speed; the smaller the value, the slower the playback speed; when the value is negative, it will play in reverse.

ts
// Normal speed
animator.timeScale = 1.0;

// 2 times slower
animator.timeScale = 0.5;

// 2 times faster
animator.timeScale = 2.0;

// 2 times faster reverse playback
animator.timeScale = -2.0;

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { Engine3D, Scene3D, Object3D, AtmosphericComponent, View3D, DirectLight, HoverCameraController, Color, CameraUtil, SkeletonAnimationComponent, Vector3, AnimatorComponent } from '@orillusion/core';
import * as dat from 'dat.gui';

// Init Engine3D
let engine = await Engine3D.init();

// Create Scene3D
let scene = new Scene3D();

// add a camera object with Camera3D
let mainCamera = CameraUtil.createCamera3DObject(scene);
mainCamera.perspective(60, engine.aspect, 0.1, 10000.0);
let hc = mainCamera.object3D.addComponent(HoverCameraController);
hc.setCamera(0, -15, 5, new Vector3(0, 1, 0));

// add a dir light
{
    let ligthObj = new Object3D();
    ligthObj.rotationY = 135;
    ligthObj.rotationX = 45;
    let dl = ligthObj.addComponent(DirectLight);
    dl.lightColor = new Color(1.0, 0.95, 0.84, 1.0);
    scene.addChild(ligthObj);
    dl.castShadow = true;
    dl.intensity = 5.0;
}

// load test model
let soldier = await engine.res.loadGltf('https://cdn.orillusion.com/gltfs/glb/Soldier.glb');
soldier.rotationY = -90;
soldier.localScale.set(2, 2, 2);
scene.addChild(soldier);

// get animator component
let soldierAnimation = soldier.getComponentsInChild(AnimatorComponent)[0];
soldierAnimation.playAnim('Idle');

const GUIHelp = new dat.GUI();
GUIHelp.addFolder('Animation');
GUIHelp.add(soldierAnimation, 'timeScale', -6, 6, 0.01);
GUIHelp.add({ Idle: () => soldierAnimation.playAnim('Idle') }, 'Idle');
GUIHelp.add({ Walk: () => soldierAnimation.playAnim('Walk') }, 'Walk');
GUIHelp.add({ Run: () => soldierAnimation.playAnim('Run') }, 'Run');

// set skybox
scene.addComponent(AtmosphericComponent).sunY = 0.6;
// create a view with target scene and camera
let view = new View3D();
view.scene = scene;
view.camera = mainCamera;
// start render
engine.startRenderView(view);

Animation Transition

Old API (deprecated for blending)

Achieving multi-animation blending through crossFade / manually adjusting clipsState[].weight is the old API. For animation blending in new projects, switch to Animation Layering and Blending (addLayer) described below. crossFade itself can still be used for single-track transitions on the base layer.

You can use the crossFade method to transition the current animation to the specified state. The first parameter is the name of the animation state to transition to, and the second parameter is the transition time (seconds).

ts
// Play walk animation
animation.playAnim('Walk');
// Transition from walk state to run state over 1 second
animation.crossFade('Run', 1.0);

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { Engine3D, Scene3D, Object3D, AtmosphericComponent, View3D, DirectLight, HoverCameraController, Color, CameraUtil, SkeletonAnimationComponent, Vector3, AnimatorComponent } from '@orillusion/core';
import * as dat from 'dat.gui';

// Init Engine3D
let engine = await Engine3D.init();

// Create Scene3D
let scene = new Scene3D();
scene.exposure = 0.3;

// add a camera object with Camera3D
let mainCamera = CameraUtil.createCamera3DObject(scene);
mainCamera.perspective(60, engine.aspect, 0.1, 10000.0);
let hc = mainCamera.object3D.addComponent(HoverCameraController);
hc.setCamera(0, -15, 5, new Vector3(0, 1, 0));

// set light
{
    let ligthObj = new Object3D();
    ligthObj.rotationY = 135;
    ligthObj.rotationX = 45;
    let dl = ligthObj.addComponent(DirectLight);
    dl.lightColor = new Color(1.0, 0.95, 0.84, 1.0);
    scene.addChild(ligthObj);
    dl.castShadow = true;
    dl.intensity = 5.0;
}

// load test model
let soldier = await engine.res.loadGltf('https://cdn.orillusion.com/gltfs/glb/Soldier.glb');
soldier.rotationY = -90;
soldier.localScale.set(2, 2, 2);
scene.addChild(soldier);

// get animator component
let animator = soldier.getComponentsInChild(AnimatorComponent)[0];
animator.playAnim('Idle');

const GUIHelp = new dat.GUI();
let f = GUIHelp.addFolder('Animation-weight');
animator.clipsState.forEach((clipState, _) => {
    f.add(clipState, 'weight', 0, 1.0, 0.01).name(clipState.clip.clipName);
});
f.open();

f = GUIHelp.addFolder('Animation-play');
animator.clipsState.forEach((clipState, _) => {
    f.add({ click: () => animator.playAnim(clipState.clip.clipName) }, 'click').name(clipState.clip.clipName);
});
f.open();

f = GUIHelp.addFolder('Animation-crossFade');
animator.clipsState.forEach((clipState, _) => {
    f.add({ click: () => animator.crossFade(clipState.clip.clipName, 0.3) }, 'click').name('crossFade(' + clipState.clip.clipName + ')');
});
f.open();
// set skybox
scene.addComponent(AtmosphericComponent).sunY = 0.6;

// create a view with target scene and camera
let view = new View3D();
view.scene = scene;
view.camera = mainCamera;
// start render
engine.startRenderView(view);

Animation Layering and Blending

Version Notes

Animation layering (AnimationLayer / AnimatorComponent.addLayer) was introduced in @orillusion/core 0.9.0. It is the recommended way to blend multiple animations, replacing the old approach of manually adjusting clipsState weights.

The new version blends animations through a layer mechanism: the base layer (layer 0) is driven by playAnim / crossFade; on top of it you can stack any number of AnimationLayer instances, each independently playing one animation, controlling its blend amount with weight, and using a BoneMask to restrict it to only affect some bones (such as moving only the upper body).

Create and Add a Layer

new AnimationLayer(name, weight, blendMode, mask):

ParameterTypeDescription
namestringLayer name (unique)
weightnumberBlend weight 0~1
blendModeLayerBlendModeBlend mode: Override (interpolate toward this layer's pose) or Additive (add the delta on top of the base pose)
maskBoneMask | nullBone mask, null means the whole body
ts
import { AnimatorComponent, AnimationLayer, LayerBlendMode } from '@orillusion/core';

const animator = model.getComponentsInChild(AnimatorComponent)[0];

// Base layer: normally play walking
animator.playAnim('Walk');

// Stack an Additive layer (e.g. "hit shake"), weight 0.6, whole body
const layer = new AnimationLayer('hit', 0.6, LayerBlendMode.Additive, null);
layer.clipName = 'HitReact';   // The animation this layer plays
animator.addLayer(layer);

// Adjust this layer's weight at runtime (fade in/out)
animator.setLayerWeight('hit', 0.3);

Blend Modes

ModeFormulaUse
LayerBlendMode.Overridelerp(base, layer, weight)Override the base pose with this layer's pose (interpolated by weight), such as switching the upper body action
LayerBlendMode.Additivebase + (layer - rest) * weightAdd a delta action on top of the base pose, such as breathing, aim offset, or being hit

Bone Mask (BoneMask)

Use a BoneMask to restrict a layer to only affect some bones, for example to make the upper body play "wave" while the lower body keeps "walking":

ts
import { BoneMask } from '@orillusion/core';

const upperBody = new BoneMask();
upperBody.add('Spine').add('Chest').add('LeftArm').add('RightArm');
// Or add a subtree in bulk: upperBody.addSubtree(avatar, 'Spine');

const waveLayer = new AnimationLayer('wave', 1.0, LayerBlendMode.Override, upperBody);
waveLayer.clipName = 'Wave';
animator.addLayer(waveLayer);

Layer Management API

MethodDescription
addLayer(layer)Add a layer, returns that layer
getLayer(name)Get a layer by name
removeLayer(name)Remove a layer
setLayerWeight(name, weight)Set a layer's weight
setLayerClip(name, clipName, time?, timeScale?)Set the animation a layer plays
layersRead-only, all current layers

Released under the MIT License