Skip to content

Animation

A clip is an animation you own: a .oclip file of keys over time, which Orblit samples itself. It can move anything a scene has, such as where a lamp is or how bright its bulb burns, and any bone of a model’s skeleton. A blend decides which clips play, mixes them, and fades from one to the next. Both live in orblit_motion.

pubspec.yaml
dependencies:
orblit_motion:
git:
url: https://github.com/ChxisB/orblit.git
path: packages/orblit_motion

There are two ways to animate a model, and a model uses one or the other, never both.

  • The renderer plays the file’s own clips. Name one with OrblitAnimation and it is sampled natively as each frame is drawn, with fading between two. Models shows it. That is the fastest way to get a character moving.
  • Orblit plays a clip. It is imported from the file, or keyed by hand, and sampled in Dart. That is slower per joint, but the clip is yours: it can tell you when a foot lands, carry the character with the walk, hand a pose to a rig to adjust, move the scene around the model, be edited in the editor, and be mixed with others by a blend.

A clip is a list of channels. Each channel moves one thing through a list of keys, and names that thing three ways:

  • target is an entity, by id. An empty target is whatever plays the clip.
  • bone is one of that entity’s bones, for a model with a skeleton, or null for the entity itself.
  • property is what about it moves. On an entity that is a component and one of its fields, transform.position or light.power. On a bone it is position, rotation or scale.

A bone isn’t an entity, because a skeleton belongs to the model and not to the scene. A character from a file has sixty joints, and the scene’s hierarchy shouldn’t.

import 'dart:io';
import 'package:orblit_motion/orblit_motion.dart';
import 'package:vector_math/vector_math_64.dart';
// A lamp that bobs, and whose bulb goes out for a moment every lap.
final flicker = ClipDocument(
name: 'flicker',
duration: 2,
whenDone: WhenDone.loop,
channels: [
ClipChannel<Vector3>(
target: '',
property: 'transform.position',
kind: ChannelKind.vector,
keys: [
Key(0, Vector3(0, 2, 0)),
Key(1, Vector3(0, 2.2, 0)),
Key(2, Vector3(0, 2, 0)),
],
),
ClipChannel<double>(
target: 'bulb',
property: 'light.power',
kind: ChannelKind.number,
keys: [
Key(0, 60, hold: Hold.step),
Key(1.5, 0, hold: Hold.step),
Key(1.6, 60, hold: Hold.step),
],
),
],
marks: [const Mark(1.5, 'fizz')],
);
void main() {
File('flicker.oclip').writeAsStringSync(flicker.encode());
final read = ClipDocument.decode(File('flicker.oclip').readAsStringSync());
for (final problem in read.problems) {
print(problem);
}
}

A channel carries one of four kinds of value: a number, a vector, a rotation or a flag. A key says how the value travels from it to the next one:

Hold What it does
smooth Eases out of one key and into the next. The default
linear Straight there at a constant rate
step Holds, then jumps at the next key. For flags and on-off lights
shaped Eases with the Easing the key names, such as back or bounce
curve Along a curve that leaves and arrives at the slopes the keys give in slopeIn and slopeOut

A curve key that gives no slope gets one from its neighbours, so a curve flows through the keys in the middle. The first and last keys, and any peak or trough, are left flat, so the curve never overshoots a value somebody chose. A flag can’t curve, so it travels as linear does. For a flag that means switching halfway between the two keys, so give it step to switch at a key.

Marks are moments something happens, such as a footstep or a sword landing. Each has a name and an optional payload.

This is what encode writes for the clip above:

flicker.oclip
{
"kind": "orblit.clip",
"formatVersion": 1,
"name": "flicker",
"duration": 2.0,
"rate": 30.0,
"whenDone": "loop",
"channels": [
{
"target": "",
"property": "transform.position",
"kind": "vector",
"keys": [
{"at":0.0,"value":[0.0,2.0,0.0]},
{"at":1.0,"value":[0.0,2.2,0.0]},
{"at":2.0,"value":[0.0,2.0,0.0]}
]
},
{
"target": "bulb",
"property": "light.power",
"kind": "number",
"hold": "step",
"keys": [
{"at":0.0,"value":60.0},
{"at":1.5,"value":0.0},
{"at":1.6,"value":60.0}
]
}
],
"marks": [
{"at":1.5,"name":"fizz"}
]
}

It is laid out one key to a line, so a two-second walk is a few hundred lines rather than ten thousand, and the diff between two takes shows only the keys that changed. The hold most of a channel’s keys use is written once, on the channel. A rotation is four numbers, x, y, z and then w.

rate is the frame rate the clip was made at, which is what the editor snaps keys to. It belongs to the clip, because a clip made at 24 frames a second and snapped to 30 lands between its own keys. duration isn’t always the time of the last key. A clip can hold still at the end, and a loop often stops short of its length so that the first key is where it lands.

decode is lenient about the parts and strict about the whole. A key that can’t be read is dropped and listed in problems. A file that isn’t a clip, or was written by a newer Orblit, throws ClipFormatException. Like a scene file, it has a formatVersion and is migrated forward when that changes.

clip.sampleAt(seconds) gives a ClipFrame: every value the clip has at that moment, as values. Nothing is touched by it, and it keeps no state, so scrubbing back to a moment shows exactly what playing forwards to it would. That is the same idea as the rest of Orblit’s time.

ClipPlayer adds a playhead and the two things that happen along the way. Each call to advance returns a ClipStep with:

  • frame, the clip where the playhead is now.
  • marks, every mark it passed, in order. That includes one at the very start, on every lap, and every lap of a long step. A frame dropped under load doesn’t lose a footstep.
  • moved, how far root motion carried the character. See below.

A player starts paused, so call play first. seek moves the playhead without firing marks or moving anything, because a scrub is somebody looking, not the character moving. speed scales time, and a negative speed plays the clip backwards, which fires no marks and walks backwards.

whenDone says what happens at the end, and defaults to the clip’s own:

whenDone At the end
hold Stops on the last frame
loop Starts again
bounce Runs back to the start, then forwards again
release Stops, and released turns true, so you stop applying it

sceneOpsFor turns a frame into the edits that make a document look that way, one SetField per property. Wrap them in a SceneDiff and give it to the view that draws the document:

import 'package:orblit_motion/orblit_motion.dart';
import 'package:orblit_scene/orblit_scene.dart';
import 'package:orblit_stage/orblit_stage.dart';
class LampAnimator {
LampAnimator(this.view, ClipDocument clip, String lamp)
: player = ClipPlayer(clip)..play(),
scope = ClipScope.inScene(view.document, lamp);
final OrblitDocumentView view;
final ClipPlayer player;
final ClipScope scope;
// Once a frame, with the seconds since the last one.
void tick(double seconds) {
final step = player.advance(seconds);
for (final mark in step.marks) {
print('${mark.name} at ${mark.at} s');
}
final ops = sceneOpsFor(step.frame, view.document, scope);
if (ops.isNotEmpty) view.apply(SceneDiff(ops));
}
}

Nothing is written for a property already at its value, or for an entity or component the scene doesn’t have. A clip made for a lamp with a flame, played on one without, moves what is there. Each SetField carries the value it replaced, so a preview can be put back exactly.

ClipScope says where the clip’s targets are. The clip names things by their ids in the document it was made for. When that document is a prefab, those ids are the prefab’s. So bulb in the lamp’s clip is street1/lamp3/bulb for the lamp placed as street1/lamp3, and one clip plays on every lamp in the street. ClipScope.inScene works this out from the document. On something that isn’t part of an instance, the targets are the scene’s own ids.

The frame’s bones go to a model through the same OrblitSkinBinding that a rig uses (see Joints):

import 'package:orblit_filament/orblit_filament.dart';
import 'package:orblit_motion/orblit_motion.dart';
import 'package:orblit_stage/orblit_stage.dart';
import 'package:vector_math/vector_math_64.dart';
// A model walking, played by Orblit rather than by the renderer.
class Walker {
Walker(OrblitAssetInfo info, ClipDocument walk)
: player = ClipPlayer(walk, whenDone: WhenDone.loop)..play(),
bindings = [
for (var i = 0; i < info.skins.length; i++)
OrblitSkinBinding(
armatureOfSkin(info.skins[i]),
info.skins[i],
index: i,
),
];
final ClipPlayer player;
final List<OrblitSkinBinding> bindings;
OrblitObject draw(String mesh, Matrix4 placement, double seconds) {
final bones = player.advance(seconds).frame.bones[''] ?? const {};
return OrblitObject(
key: 1,
transform: placement,
colour: Vector3.all(0.8),
mesh: mesh,
joints: [for (final binding in bindings) ...binding.jointsFrom(bones)],
);
}
}

Build the bindings once per model, not once per frame. jointsFrom returns every joint. A joint the clip leaves out, whole or in part, stays as the file rests it, so a clip that only turns an arm keeps the arm’s length.

To let a rig have its say, use poseFrom instead. It writes the clip into a Pose, and then you evaluate that and take jointsFor, the way Models does. A constraint, or a limb reaching for a target, then starts from the clip’s pose rather than from rest. The one thing a rig can’t carry exactly is a joint stretched more along one of its own axes than another. To a bone that is a shear, which a pose has no way to hold. The joint still ends up where the clip puts it, but a model whose clips squash and stretch that way should play through jointsFrom.

The Imported models example has a Played by setting, which switches the fox between the renderer and Orblit.

A walk can be made on the spot, like a treadmill, or moving, so that the model walks off its own origin. Root motion plays the second kind as if it were the first. The root is held where it is, and the player tells you how far it would have gone, so you can move the character yourself.

import 'package:orblit_motion/orblit_motion.dart';
import 'package:vector_math/vector_math_64.dart';
// A character the walk carries, rather than one walking on the spot.
class Character {
Character(ClipDocument walk)
: player = ClipPlayer(
walk.copyWith(rootMotion: RootMotion(bone: 'hips', turns: true)),
whenDone: WhenDone.loop,
)..play();
final ClipPlayer player;
Vector3 position = Vector3.zero();
Quaternion heading = Quaternion.identity();
// [scale] is the model's: drawn at half size, it walks half as far.
ClipFrame tick(double seconds, {double scale = 1}) {
final moved = player.advance(seconds).moved;
position += heading.asRotationMatrix().transformed(moved.position) * scale;
heading = (heading * moved.rotation)..normalize();
return player.sample();
}
}

The step is measured in the character’s own frame, so forward is forward whichever way it faces. Take it by moving that far in the direction the character faces, and then turning.

RootMotion says which channel is the root, either an entity or one of its bones, and how much of it the character takes:

  • Across the ground, always. That is the walk.
  • Up and down only with rises. A walk’s bob stays in the pose, because a body that bobs its capsule up and down looks like it’s skipping. A climb or a jump takes it.
  • Turning only with turns, and then only about up. A turn on the spot gives the character a new heading. A lean or a sway stays in the pose.

up is Y unless you say otherwise. It is in the space the root’s channels are in, which for a bone is the model’s own. A model exported Z-up and stood upright by a node above its skeleton needs up set to Z.

A loop that wraps in the middle of a step still moves the character by the whole step. seek never moves it.

On a physics character, don’t add the step to a position. Divide it by the tick and ask for that velocity, and the world decides how far the character actually gets.

A clip is one movement, and a character has a dozen. Something has to decide which one is playing, mix a walk into a run as the character speeds up, and fade one out as the next comes in. That is a blend, kept as a .oblend file.

A blend is a graph of states. Each state plays something: one clip, or several mixed by an input that gameplay sets, such as how fast the character is going. Changes lead from one state to another when a condition holds, and fade over the time they give. A blend doesn’t move the character, read a gamepad or know about physics. Gameplay sets the inputs, and the blend says what the character looks like.

import 'dart:io';
import 'package:orblit_motion/orblit_motion.dart';
// Standing, moving at any speed from a walk to a run, and jumping.
final moves = BlendDocument(
name: 'moves',
inputs: const {'speed': 0},
states: [
BlendState('idle', plays: const BlendClip('clips/idle.oclip')),
BlendState(
'move',
plays: BlendLine('speed', const [
LinePoint(1.4, BlendClip('clips/walk.oclip')),
LinePoint(4, BlendClip('clips/run.oclip')),
]),
),
BlendState(
'jump',
plays: const BlendClip('clips/jump.oclip'),
whenDone: WhenDone.hold,
),
],
changes: const [
BlendChange(
to: 'jump',
when: BlendCondition.on('jump'),
fade: 0.1,
shape: Easing.out,
),
BlendChange(
from: 'jump',
to: 'idle',
when: BlendCondition.through(1),
fade: 0.25,
),
BlendChange(
from: 'idle',
to: 'move',
when: BlendCondition.above('speed', 0.1),
fade: 0.2,
),
BlendChange(
from: 'move',
to: 'idle',
when: BlendCondition.below('speed', 0.1),
fade: 0.3,
),
],
);
void main() {
File('moves.oblend').writeAsStringSync(moves.encode());
}

A blend names its clips rather than holding them, so one blend serves every character that moves the same way, each with its own clips. The names are whatever you find the clips by. Here they are paths in the project, the way a scene’s motion component lists them. clipNames lists every one the blend uses.

  • BlendClip plays one clip.
  • BlendLine mixes clips along one input. Each LinePoint puts a clip at a value of the input, and the two points either side of the input’s value share the say between them in proportion. The move state above has a walk at 1.4 m/s and a run at 4, so at 2.7 it is half of each. Past either end, the nearer clip plays alone. Two points at the same value make a step: the first has everything below it, and the second everything from there up.
  • BlendPlane mixes clips across two inputs, such as the sideways and forward parts of a velocity. Each PlanePoint puts a clip at an x and a y. On a point, that clip plays alone. Between points, the nearest share the say, and outside them all the nearest plays alone.
import 'package:orblit_motion/orblit_motion.dart';
// Walking any way while facing forwards, from the character's velocity in
// its own frame.
final strafe = BlendPlane('sideways', 'forwards', const [
PlanePoint(0, 0, BlendClip('clips/idle.oclip')),
PlanePoint(0, 1.4, BlendClip('clips/walk.oclip')),
PlanePoint(0, -1.2, BlendClip('clips/walk_back.oclip')),
PlanePoint(1.2, 0, BlendClip('clips/step_right.oclip')),
PlanePoint(-1.2, 0, BlendClip('clips/step_left.oclip')),
]);

A point can hold a line or a plane in place of a clip, so a line on speed can end in a plane of directions. Its say is then shared out among its own clips.

Give a plane’s two inputs the same units. It measures how far apart points are in whatever units it is given, so a plane of metres a second one way and degrees the other mixes as if a degree were a metre a second.

A state also has a speed, which scales how fast its clips play, and a whenDone, which overrides what its clips do at their ends. A state plays forwards or not at all. A walk backwards is a clip of a walk backwards, because a walk played in reverse puts its weight on the wrong foot.

How far a state has got is its lap: how many times through it has played, so 2.5 is halfway through the third time. Every clip in a state plays at the same lap, so a walk of 1.2 seconds mixed with a run of 0.8 keeps its feet in step with it. How long a lap takes is the clips’ lengths, mixed by their say, so a walk speeding up into a run shortens its stride as it goes.

A BlendChange goes from one state to another when its condition holds. It fades over fade seconds, eased by shape, which is smooth unless you say otherwise. A fade of nought is a cut.

Condition Holds when
BlendCondition.above(input, n) The input is more than n
BlendCondition.below(input, n) The input is less than n
BlendCondition.on(input) The input isn’t nought, such as a button gameplay copies across
BlendCondition.through(n) The state being left has played n laps
BlendCondition.all, any, not Every one of a list holds, any one does, or the one given doesn’t
BlendCondition.always Always. The default

An input the blend doesn’t list in inputs is nought until something sets it.

  • The list is the order of precedence. Each step takes one change at most, the first in the list whose condition holds. An interruption that should beat everything else, like the jump above, goes first.
  • A change with no from is from anywhere. It is never taken into the state already playing, so holding jump down doesn’t restart the jump every frame. To restart a state on purpose, give it a change from itself to itself.
  • through counts laps of the state being left. That is how a jump hands back once it has landed. A state that plays once wants whenDone: WhenDone.hold as well, as jump has, or its clip starts again while it fades out.
  • inStep keeps the feet. The state entered starts as far through its lap as the one being left is through its own, so a walk turning into a strafe doesn’t start on the wrong foot.
  • A fade can start before the last one finishes. It fades in over everything that was playing. Four states can sound at once, and a fifth drops the oldest.

BlendPlayer plays a blend on one character. It holds the clips, the inputs, and where the blend has got to, and advance moves it on:

import 'dart:io';
import 'package:orblit_filament/orblit_filament.dart';
import 'package:orblit_motion/orblit_motion.dart';
import 'package:orblit_stage/orblit_stage.dart';
import 'package:vector_math/vector_math_64.dart';
// Every clip a blend names, read from the project.
Map<String, ClipDocument> clipsFor(BlendDocument blend, String project) => {
for (final name in blend.clipNames)
name: ClipDocument.decode(File('$project/$name').readAsStringSync()).clip,
};
class Mover {
Mover(BlendDocument moves, String project, this.bindings)
: player = BlendPlayer(moves, clips: clipsFor(moves, project));
final BlendPlayer player;
final List<OrblitSkinBinding> bindings;
Vector3 position = Vector3.zero();
Quaternion heading = Quaternion.identity();
// Once a frame, with the seconds since the last one.
List<OrblitJointPose> tick(
double seconds, {
required double speed,
required bool jump,
}) {
player.inputs['speed'] = speed;
player.inputs['jump'] = jump ? 1 : 0;
final step = player.advance(seconds);
for (final mark in step.marks) {
print('${mark.name} while in ${player.state}');
}
position += heading.asRotationMatrix().transformed(step.moved.position);
heading = (heading * step.moved.rotation)..normalize();
final bones = step.frame.bones[''] ?? const {};
return [for (final binding in bindings) ...binding.jointsFrom(bones)];
}
}

Each step is a BlendStep with:

  • frame, the pose: every clip with a say, sampled at its state’s lap and mixed. Apply it the way you would a clip’s, with sceneOpsFor on a scene or jointsFrom on a skeleton.
  • marks, the marks passed by the clip with the most say in the state playing. A walk mixed with a run takes one footstep, not two, and a state fading out fires none.
  • moved, root motion from every clip with a say, mixed the same way. A clip gives root motion only if it has rootMotion set, as above.
  • place and change: where the blend is now, and the change it took on this step, if it took one.

sample poses the character where the blend is, without moving on. enter puts it in a state whatever the changes say, with a cut or a fade, for gameplay that knows better than a condition, such as a hit landing or a cutscene taking over.

A value that only one side of a fade keys isn’t faded. It stays where that side has it until the fade ends, and is then let go. Key the same bones and fields in clips that fade into each other.

A clip the player wasn’t given has no say, and the rest share what it would have had. Nothing complains, so check clipNames against what you load.

Everything a blend remembers is a BlendPlace. That is the state, its lap, and whatever is fading out underneath it, with how far that fade has got. It is a plain value and it is the whole story: two players with the same place and the same inputs pose the same and play on the same. That gives you three things.

Save files. place.toJson() is a small map, and BlendPlace.fromJson(json, blend) reads it back, dropping any state the blend no longer has. A character saved halfway through fading from a walk into a run loads halfway through that fade.

import 'dart:convert';
import 'package:orblit_motion/orblit_motion.dart';
String save(BlendPlayer player) =>
jsonEncode({'place': player.place.toJson(), 'inputs': player.inputs});
void load(BlendPlayer player, String text) {
final saved = jsonDecode(text) as Map<String, Object?>;
player.place =
BlendPlace.fromJson(saved['place'], player.blend) ??
BlendPlace(player.blend.start);
final inputs = saved['inputs']! as Map<String, Object?>;
for (final MapEntry(:key, :value) in inputs.entries) {
player.inputs[key] = (value! as num).toDouble();
}
}

Multiplayer. place.toNumbers(blend) is always BlendPlace.width numbers, 21 of them, and BlendPlace.fromNumbers(blend, numbers) reads them back. That fits a component of 21 float64s, which is what orblit-net replicates. The numbers name each state by where it is in the blend’s list, so both ends need the same blend. The inputs aren’t part of the place. Replicate them as well, or have each end work them out from what it already has, such as the character’s velocity.

Tests. A place can be built by hand, so a test can start in the middle of a fade and ask what happens next, with no clips and no frames played:

import 'dart:io';
import 'package:orblit_motion/orblit_motion.dart';
void main() {
final text = File('moves.oblend').readAsStringSync();
final moves = BlendDocument.decode(text).blend;
// Halfway through fading from idle into a walk, jump goes down.
const place = BlendPlace(
'move',
lap: 3.5,
from: BlendPlace('idle', lap: 12.25),
faded: 0.1,
fade: 0.2,
);
print(moves.changeFor(place, {'speed': 2, 'jump': 1})?.to); // jump
// Idle has half the say, and the walk and the run a quarter each.
for (final weight in moves.weightsAt(place, {'speed': 2.7})) {
print('${weight.clip}: ${weight.weight}');
}
}

advance with a step of nought takes whatever change it would, without playing anything.

This is what encode writes for the blend above:

moves.oblend
{
"kind": "orblit.blend",
"formatVersion": 1,
"name": "moves",
"inputs": {"speed":0.0},
"start": "idle",
"states": [
{"name":"idle","clip":"clips/idle.oclip"},
{
"name": "move",
"line": {
"input": "speed",
"points": [
{"at":1.4,"clip":"clips/walk.oclip"},
{"at":4.0,"clip":"clips/run.oclip"}
]
}
},
{"name":"jump","clip":"clips/jump.oclip","whenDone":"hold"}
],
"changes": [
{"to":"jump","when":{"input":"jump"},"fade":0.1,"shape":"out"},
{"from":"jump","to":"idle","when":{"through":1.0},"fade":0.25},
{"from":"idle","to":"move","when":{"input":"speed","above":0.1},"fade":0.2},
{"from":"move","to":"idle","when":{"input":"speed","below":0.1},"fade":0.3}
]
}

Anything that fits goes on one line, so each state, point and change reads as a line of its own. start is the state a character starts in, the first unless you say. Like a clip, decode is lenient about the parts and strict about the whole. A state or a change that can’t be read is left out and listed in problems. A file that isn’t a blend, or was written by a newer Orblit, throws BlendFormatException.

clipsFromGltf reads every animation in a .glb or .gltf as a clip:

import 'dart:io';
import 'package:orblit_motion/orblit_motion.dart';
void main() {
final imported = clipsFromGltf(File('assets/fox.glb').readAsBytesSync());
for (final problem in imported.problems) {
print(problem);
}
for (final clip in imported.clips) {
final name = clip.name.toLowerCase();
File('assets/clips/$name$clipExtension').writeAsStringSync(clip.encode());
}
}

A .gltf names its buffer by URI. Pass the bytes of the .bin in files, keyed by that URI.

  • A joint becomes a bone of whatever plays the clip, named the way OrblitSkinBinding names it: its own name, or for one the file left unnamed the name of the mesh, light or camera it carries, or <unknown>, with .001 on a repeat. So a clip plays on the model it came from, and on any other model with the same bone names. Retargeting covers the same bones in different proportions.
  • Any other node becomes an entity, under the id a scene imported from the same file gives it.
  • Nothing is resampled. A key in the file is a key in the clip, at the same time. Linear stays linear, step stays step, and a cubic spline becomes a curve with its tangents.
  • The frame rate is the common one every key sits on, trying 30 first, then 24, 25, 60, 50 and 48, and falling back to 30.
  • An animation whose name starts or ends with “loop” or “cycle” loops. The rest hold.

Morph target weights aren’t carried across yet, and appear in problems instead.

A bone channel says where a bone is in its parent, in absolute terms. So a clip means something only beside the skeleton it was made for. Play a walk made for one character on another with longer legs and different rest turns, and the bones point the wrong way. retargetClip rewrites a clip for another skeleton.

import 'dart:io';
import 'package:orblit_motion/orblit_motion.dart';
void main() {
final animations = File('assets/walk.glb').readAsBytesSync();
final model = File('assets/hero.glb').readAsBytesSync();
final result = retargetClip(
clipsFromGltf(animations).clips.first,
from: restSkeletonsFromGltf(animations).first,
to: restSkeletonsFromGltf(model).first,
);
for (final problem in result.problems) {
print(problem);
}
File('assets/clips/hero_walk$clipExtension')
.writeAsStringSync(result.clip.encode());
}

restSkeletonsFromGltf reads a skeleton at rest from each skin: its bones, how they hang from one another, and where each sits. Bones are named the way clipsFromGltf names them, and come parents first whatever order the file lists them in. A joint is taken to hang from the nearest joint above it, so a plain node between two joints has its transform left out. To build a skeleton by hand, use RestSkeleton(names:, parents:, local:, above:). above is what the root bones hang from, such as an armature node that turns a Z-up rig upright or scales a rig made in centimetres.

  • Each bone stands where its driver stood. A bone of the target is turned so that in the world it faces the way the source bone did, measured from where each one rests. A shoulder that tips forward in the walk tips forward on the hero, whichever way the two rigs point their bones.
  • Keys survive. When a bone and its parent line up with the bones that drive them, the turn is worked out exactly at the clip’s own keys. Easing, holds and cubic slopes come across as they were. When they don’t, because the hero has one spine bone where the animation has three, or a parent nothing drives, the turn is worked out at every key of the bones involved and joined with straight lines. Between those keys it can be a few degrees off.
  • Positions grow with the skeleton. A position is its change from rest, made larger by how much bigger the target is. Size is reach on a RestSkeleton: the distance from where the roots hang to the farthest bone. A tall character’s hips travel further over a stride.
  • Scale is the change from rest. An axis is assumed to be the same axis in both skeletons.
  • Root motion follows its bone. The direction it calls up is turned into the target’s frame.
  • Everything else is kept. Entity channels, marks, length and rate come across as they were. Only bone channels are rewritten.

Bones are paired by name. matchBones(from, to) takes a bone with the same name first, then those that match once a namespace such as mixamorig:, a DEF- or ORG- prefix and the way left and right are spelled are set aside. mixamorig:LeftHand drives hand.L. A name that two bones share once cleaned is left out rather than guessed. bones adds a pair or overrides one, naming the bone of the target first:

import 'package:orblit_motion/orblit_motion.dart';
ClipRetargeted forHero(
ClipDocument clip,
RestSkeleton source,
RestSkeleton hero,
) => retargetClip(
clip,
from: source,
to: hero,
bones: {'Pelvis': 'mixamorig:Hips', 'Spine_02': 'mixamorig:Spine1'},
);

A name that is not in the skeleton it is given for throws ArgumentError.

ClipRetargeted holds the new clip and a list of problems. Each is a motion that could not be carried and was left out:

  • The clip moves a bone the source skeleton doesn’t have.
  • A bone of the source has nothing in the target to move, so its motion is dropped.
  • The bone that carried root motion drives nothing in the target, so the clip has none.

Retargeting copies rotations. It doesn’t keep feet on the ground. If the target’s legs are a different length beside its body than the source’s, its feet can slide.

A motion component lists the clips an entity has to play, by their paths in the project, and can name one to start on its own:

"motion": {
"clips": ["clips/idle.oclip", "clips/walk.oclip"],
"autoplay": "clips/idle.oclip"
}

An autoplay that isn’t in clips is ignored rather than played, because a clip taken off the list was taken off for a reason. Which clip plays, how fast, and when it changes is up to the game while it runs.

Nothing plays the component for you yet, so read it yourself:

import 'dart:io';
import 'package:orblit_motion/orblit_motion.dart';
import 'package:orblit_scene/orblit_scene.dart';
// A player for every entity that says it starts with one.
Map<String, ClipPlayer> autoplaying(SceneDocument document, String project) {
final players = <String, ClipPlayer>{};
for (final entity in document.entities) {
final motion = entity[SceneComponents.motion];
if (motion is! MotionComponent) continue;
final start = motion.autoplay;
if (start == null || !motion.clips.contains(start)) continue;
final text = File('$project/$start').readAsStringSync();
players[entity.id] = ClipPlayer(ClipDocument.decode(text).clip)..play();
}
return players;
}

Give each one a ClipScope.inScene(document, id) and apply its frames as above.

Select an object, open Animation, and press Make a clip on the Timeline. The clip is saved under clips/ and attached to the object. Its first clip starts automatically in the scene animation preview.

You can also make a file through Project › New › Clip, or double-click a .oclip. Use Inspector › Animation clips › Attach open clip to assign that file to an object. On start chooses which assigned clip plays automatically. None leaves all of them stopped.

Animation opens the selected object’s first assigned clip. In Scene, selecting an object with animation opens the Timeline below the view. Selecting something else folds it again. Your clip edits stay open.

  • Choose what it plays on from the menu at the right of the toolbar. The clip is shown on that object wherever the playhead is. That is only looking, not editing: scrubbing puts nothing on the undo stack, and saving saves the scene as it rests, not as the clip has posed it.
  • Key from the inspector. A diamond sits beside position, rotation, scale and a light’s power. Grey and hollow means the clip doesn’t move it yet. A coloured outline means the clip moves it, but not with a key on this frame. Filled means there is a key here. Click it to key the value as it is now. A value you change by hand on something the clip moves shows only until the playhead moves, so key it to keep it.
  • The dope sheet has a row per channel, under a heading for each thing it moves. Click a key to pick it, shift-click to add one, or drag a box around several. Drag the picked keys to move them frame by frame. Delete removes them once the timeline has the keyboard. Hold sets how the picked keys travel, including the eased shapes.
  • The curve view draws the channel you pick on the left as one line per number it moves. Drag a key in time and in value. A picked key grows handles that bend the curve through it, and they turn together unless you hold Alt, which makes a corner. Shift keeps a drag to whichever way it went furthest. Auto slopes works the picked keys’ slopes out again.
  • Everything snaps to the clip’s frame rate, the playhead and the keys alike. The toolbar sets the rate, the length and what happens at the end. Space plays and pauses, and Home and End jump to either end.

Every edit is a step on the undo stack, like any other, and saving the scene saves the clips that changed.

To make a cube bounce, key its position at the start. Move the playhead to the middle, raise the cube, and key its position again. At the end, lower it back to its starting height and add a third key. Choose Loop and press the Timeline’s Play button.

The top bar’s Play previews every assigned On start clip in the Game view. Pause holds that preview. Stop returns to editing. Playback uses a copy of the scene and includes unsaved clip edits. It previews scene properties; it does not run scripts, physics or skeletal animation. The Game view needs a camera and offers Add camera if none exists.

  • Layer one clip on another. A blend mixes and fades whole poses. A wave on the upper body over a walk on the legs needs a mask, and there isn’t one yet.
  • Play motion automatically in the stage. The editor can assign and preview clips. A game still wires its own players, as shown above. The stage keeps, diffs and saves the component without starting a player.
  • Show a blend in the editor. Build one in code or write the file.
  • Show bones in the editor. The timeline lists a clip’s bone channels, but the viewport doesn’t pose the model. Only what the scene holds moves.
  • Import or retarget clips in the editor. Use clipsFromGltf and retargetClip from code and save the results as .oclip files.
  • Retarget between different bodies. Bones are paired one to one, so a walk made for a person can’t move a horse. Nothing pins feet to the ground either.
  • Key everything from the inspector. It keys position, rotation, scale and a light’s power. In a file, a channel can move any field that is a number, a vector, a rotation or a flag. A colour isn’t one of those, so it can’t be keyed at all yet.
  • Move morph targets. Their weights are left out on import.
  • Export. A clip on an Orblit entity isn’t written into an exported glTF. See Exporting scenes.