flutter3d
Showcase Changelog 38 packages API reference

Root motion

since 0.7.0 Animation

A walk cycle exported with root motion has already had its own forward step taken out of the animated track: the rig plays in place, and the distance it would have walked is saved separately. AnimationPlayer.rootMotionDelta reads that saved distance back, a step at a time, so a character controller can move the object the rig belongs to instead of the rig sliding across the floor by itself.

Step 1: A clip whose own track goes nowhere #

The track named here bobs up and down and goes nowhere: its keys are all at x = 0, z = 0. The forward travel the walk actually had lives in extras under flutter3dRootMotion, one triple per keyframe, which is where an exporter's own extraction pass leaves it.

// The track that ships with the clip goes up and down in place and
// nowhere else: the walk's forward travel was taken out of it, the way
// an exporter's own extraction pass leaves it. The values a real walk
// would have had live in `extras` instead, one triple per keyframe.
final Float32List times = Float32List.fromList(<double>[0.0, 0.5, 1.0]);
final AnimationTrack bobTrack = AnimationTrack(
  nodeIndex: 0,
  path: AnimationPath.translation,
  interpolation: AnimationInterpolation.linear,
  times: times,
  values: Float32List.fromList(<double>[
    0.0, 0.0, 0.0, //
    0.0, 0.15, 0.0,
    0.0, 0.0, 0.0,
  ]),
  componentCount: 3,
);
final AnimationClip walk = AnimationClip(
  name: 'walk',
  tracks: <AnimationTrack>[bobTrack],
  extras: <String, Object?>{
    kRootMotionExtra: <List<double>>[
      <double>[0.0, 0.0, 0.0],
      <double>[0.0, 0.0, 1.0],
      <double>[0.0, 0.0, 2.0],
    ],
  },
);

Step 2: A controller to carry it #

The walker mesh is a child of a separate node, the controller. The player only ever moves the walker's own flattened track, so the walker stays still inside the controller's space; the controller is what root motion is going to move instead.

_controller = SceneNode(name: 'controller')..setPosition(0.0, 0.5, -_reach);
_controller.add(walker);
_player = AnimationPlayer(
  clips: <AnimationClip>[walk],
  targets: <AnimationTarget?>[walker],
)..play(0);

Step 3: Read the delta and carry it #

Each frame asks the player how far the walk would have moved between the playhead's last position and its new one, and adds that to the controller. The clip only ever walks forward, so when the controller reaches the end of the floor it is the controller that turns round and sends the same delta the other way.

final double fromTime = _lastTime;
_player.update(dt);
_lastTime = _player.time;
final Vector3? delta = _player.rootMotionDelta(
  0,
  fromTime: fromTime,
  toTime: _lastTime,
);
if (delta != null) {
  // The clip only ever walks forward; which way forward is on the floor
  // is the controller's business, so it is the controller that turns
  // round when the floor runs out.
  _controller.translate(0.0, 0.0, delta.z * _direction);
  final double z = _controller.readPosition().z;
  if (z.abs() >= _reach) {
    _direction = z > 0 ? -1.0 : 1.0;
    _controller
      ..setPosition(0.0, 0.5, _reach * (z > 0 ? 1.0 : -1.0))
      ..setRotation(
        Quaternion.axisAngle(
          Vector3(0.0, 1.0, 0.0),
          _direction > 0 ? 0.0 : math.pi,
        ),
      );
  }
}

What to look at #

The cube walks to one end of the floor, turns, and walks back, over and over. Nothing here moved the cube's own local transform except the little bounce in the clip's own track: the trip is entirely the controller answering rootMotionDelta, frame after frame, for a rig that itself never leaves its own origin. The posts along the edge are what show it — the walker's own shape never changes, and the ground goes past it.