Root motion: the source
Open the live demo · Read the guide · View on GitHub
Regions the guide quotes: clip (line 84), controller (line 113), live (line 129).
1/// A walk clip whose own translation track stands still: the forward motion
2/// was extracted into the clip's `extras` under `flutter3dRootMotion`, and
3/// `AnimationPlayer.rootMotionDelta` hands it back a step at a time so a
4/// separate controller node can carry the character instead of the rig
5/// sliding inside itself. The controller paces between two ends of a floor.
6///
7/// Quoted by `root_motion.md` and shown whole in the Source tab.
8library;
10import 'dart:math' as math;
11import 'dart:typed_data';
13import 'package:flutter3d/flutter3d.dart';
14import 'package:flutter3d_showcase/src/demo/demo.dart';
15import 'package:vector_math/vector_math.dart';
17final class RootMotionDemo extends ShowcaseDemo {
18 late final AnimationPlayer _player;
19 late final SceneNode _controller;
20 double _lastTime = 0.0;
21 double _direction = 1.0;
23 /// How far from the middle of the floor the walker goes before it turns.
24 static const double _reach = 3.5;
27 void configureView(DemoContext context) {
28 context.orbit
29 ..distance = 11.0
30 ..pitch = 0.6
31 ..yaw = 0.7;
32 }
35 Scene build(DemoContext context) {
36 final Scene scene = Scene();
38 scene.add(
39 MeshNode(
40 DeviceMesh.upload(
41 context.device,
42 const PlaneShape(width: 7.0, depth: 11.0).build(),
43 ),
44 Material(name: 'floor', baseColor: Vector4(0.32, 0.36, 0.34, 1.0)),
45 name: 'floor',
46 ),
47 );
48 // Posts along both edges: the walker's own cube never changes shape, so
49 // what shows that it is being carried is the ground going past it.
50 final DeviceMesh post = DeviceMesh.upload(
51 context.device,
52 CuboidShape(size: Vector3(0.2, 0.6, 0.2)).build(),
53 );
54 final Material postMaterial = Material(
55 name: 'post',
56 baseColor: Vector4(0.85, 0.8, 0.6, 1.0),
57 );
58 for (var z = -5; z <= 5; z += 2) {
59 for (final double x in <double>[-2.8, 2.8]) {
60 scene.add(
61 MeshNode(post, postMaterial, name: 'post $x,$z')
62 ..setPosition(x, 0.3, z.toDouble()),
63 );
64 }
65 }
67 final MeshNode walker = MeshNode(
68 DeviceMesh.upload(context.device, CuboidShape().build()),
69 Material(name: 'walker', baseColor: Vector4(0.3, 0.7, 0.4, 1.0)),
70 name: 'walker',
71 );
72 // A nose, so the turn at each end reads as a turn.
73 walker.add(
74 MeshNode(
75 DeviceMesh.upload(
76 context.device,
77 CuboidShape(size: Vector3(0.3, 0.3, 0.4)).build(),
78 ),
79 Material(name: 'nose', baseColor: Vector4(0.95, 0.9, 0.8, 1.0)),
80 name: 'nose',
81 )..setPosition(0.0, 0.0, 0.6),
82 );
84 // The track that ships with the clip goes up and down in place and
85 // nowhere else: the walk's forward travel was taken out of it, the way
86 // an exporter's own extraction pass leaves it. The values a real walk
87 // would have had live in `extras` instead, one triple per keyframe.
88 final Float32List times = Float32List.fromList(<double>[0.0, 0.5, 1.0]);
89 final AnimationTrack bobTrack = AnimationTrack(
90 nodeIndex: 0,
91 path: AnimationPath.translation,
92 interpolation: AnimationInterpolation.linear,
93 times: times,
94 values: Float32List.fromList(<double>[
95 0.0, 0.0, 0.0, //
96 0.0, 0.15, 0.0,
97 0.0, 0.0, 0.0,
98 ]),
99 componentCount: 3,
100 );
101 final AnimationClip walk = AnimationClip(
102 name: 'walk',
103 tracks: <AnimationTrack>[bobTrack],
104 extras: <String, Object?>{
105 kRootMotionExtra: <List<double>>[
106 <double>[0.0, 0.0, 0.0],
107 <double>[0.0, 0.0, 1.0],
108 <double>[0.0, 0.0, 2.0],
109 ],
110 },
111 );
113 _controller = SceneNode(name: 'controller')..setPosition(0.0, 0.5, -_reach);
114 _controller.add(walker);
115 _player = AnimationPlayer(
116 clips: <AnimationClip>[walk],
117 targets: <AnimationTarget?>[walker],
118 )..play(0);
119 scene.add(_controller);
121 return scene..add(
122 LightNode(name: 'sun', intensity: 3.0)
123 ..setLocalForward(Vector3(-0.4, -0.7, -0.5)),
124 );
125 }
128 void update(DemoContext context, double dt) {
129 final double fromTime = _lastTime;
130 _player.update(dt);
131 _lastTime = _player.time;
132 final Vector3? delta = _player.rootMotionDelta(
133 0,
134 fromTime: fromTime,
135 toTime: _lastTime,
136 );
137 if (delta != null) {
138 // The clip only ever walks forward; which way forward is on the floor
139 // is the controller's business, so it is the controller that turns
140 // round when the floor runs out.
141 _controller.translate(0.0, 0.0, delta.z * _direction);
142 final double z = _controller.readPosition().z;
143 if (z.abs() >= _reach) {
144 _direction = z > 0 ? -1.0 : 1.0;
145 _controller
146 ..setPosition(0.0, 0.5, _reach * (z > 0 ? 1.0 : -1.0))
147 ..setRotation(
148 Quaternion.axisAngle(
149 Vector3(0.0, 1.0, 0.0),
150 _direction > 0 ? 0.0 : math.pi,
151 ),
152 );
153 }
154 }
155 }
158 void verify(Scene scene, FrameResult frame) {
159 final double z = _controller.readPosition().z;
160 if (z <= -_reach + 1e-5) {
161 throw StateError('the controller did not carry the walk forward');
162 }
163 if (z > -_reach + 1.0) {
164 throw StateError('the controller moved further than one frame allows');
165 }
166 if (frame.drawCalls < 1) throw StateError('the walker was not drawn');
167 }
168}