A crowd from a baked clip: the source
Open the live demo · Read the guide · View on GitHub
Regions the guide quotes: rig (line 40), bake (line 56), read (line 151).
1/// A crowd that plays clips baked into a table, not a skeleton apiece.
2///
3/// A skinned draw hands the GPU one array of joint matrices, and that array
4/// is the same for every instance of the draw. A thousand copies would all
5/// stand in the same pose. `BakedPoses` samples every clip once into a table
6/// instead, so each instance only needs which clip and how far into it, the
7/// two numbers instancing already carries. This page fakes the "read a row of
8/// the table" step on the CPU, since no draw path in this engine samples the
9/// table on the GPU yet, but the table itself, and what a row of it means, are
10/// exactly what a real one would read.
11///
12/// Quoted by `baked_crowd.md` and shown whole in the Source tab.
13library;
15import 'dart:typed_data';
17import 'package:flutter3d/flutter3d.dart';
18import 'package:flutter3d_showcase/src/demo/demo.dart';
19import 'package:vector_math/vector_math.dart';
21final class BakedCrowdDemo extends ShowcaseDemo {
22 double _time = 0.0;
23 static const int _framesPerClip = 32;
24 static const int _people = 6;
26 late final BakedPoses _table;
27 late final List<double> _phase;
28 late final List<int> _clip;
29 late final List<SceneNode> _arms;
32 void configureView(DemoContext context) {
33 context.orbit
34 ..distance = 6.0
35 ..pitch = 0.3;
36 }
39 Scene build(DemoContext context) {
40 // The rig every clip is baked against: a shoulder with an arm hanging off
41 // it. `BakedPoses` samples it once per clip and needs no scene of its own
42 // afterwards.
43 final Scene rig = Scene();
44 final SceneNode shoulder = rig.add(SceneNode(name: 'shoulder'));
45 final SceneNode arm = SceneNode(name: 'arm')..setPosition(0.0, -0.8, 0.0);
46 shoulder.add(arm);
47 final Skeleton skeleton = Skeleton(
48 name: 'wave',
49 joints: <SceneNode>[shoulder, arm],
50 inverseBindMatrices: <Matrix4>[
51 Matrix4.copy(shoulder.worldMatrix)..invert(),
52 Matrix4.copy(arm.worldMatrix)..invert(),
53 ],
54 );
56 // Two clips, a slow wave and a fast one, baked into one table. A crowd
57 // wearing both is one sampler, not two.
58 final AnimationPlayer player = AnimationPlayer(
59 clips: <AnimationClip>[_wave(seconds: 1.6), _wave(seconds: 0.5)],
60 targets: <AnimationTarget?>[shoulder, null],
61 );
62 _table = BakedPoses.of(
63 player,
64 skeleton: skeleton,
65 meshWorld: Matrix4.identity(),
66 framesPerClip: _framesPerClip,
67 );
69 // A capsule-and-sphere silhouette rather than bare boxes — still a rigid
70 // part per joint, which is what lets the table drive a whole mesh with
71 // one matrix (see `{{code read}}`), but one a crowd actually reads as
72 // people rather than furniture.
73 final Scene scene = Scene();
74 final Material torso = Material(
75 name: 'torso',
76 baseColor: Vector4(0.55, 0.6, 0.68, 1.0),
77 );
78 final Material limb = Material(
79 name: 'arm',
80 baseColor: Vector4(0.85, 0.5, 0.3, 1.0),
81 );
82 final Material skin = Material(
83 name: 'head',
84 baseColor: Vector4(0.85, 0.68, 0.55, 1.0),
85 );
86 final DeviceMesh torsoMesh = DeviceMesh.upload(
87 context.device,
88 CapsuleShape(radius: 0.22, height: 0.6).build(),
89 );
90 final DeviceMesh headMesh = DeviceMesh.upload(
91 context.device,
92 SphereShape(radius: 0.18, segments: 16, rings: 12).build(),
93 );
94 final DeviceMesh armMesh = DeviceMesh.upload(
95 context.device,
96 CapsuleShape(radius: 0.09, height: 0.5).build(),
97 );
99 _phase = <double>[];
100 _clip = <int>[];
101 _arms = <SceneNode>[];
102 for (var i = 0; i < _people; i++) {
103 final int clip = i.isEven ? 0 : 1;
104 _clip.add(clip);
105 _phase.add(i / _people);
106 final double x = (i - (_people - 1) / 2) * 1.1;
107 scene.add(
108 MeshNode(torsoMesh, torso, name: 'person $i')..setPosition(x, 0.0, 0.0),
109 );
110 scene.add(
111 MeshNode(headMesh, skin, name: 'head $i')..setPosition(x, 0.68, 0.0),
112 );
113 final SceneNode shoulderNode = SceneNode(name: 'shoulder $i')
114 ..setPosition(x, 0.55, 0.0);
115 final SceneNode armNode = MeshNode(armMesh, limb, name: 'arm $i')
116 ..setPosition(0.0, -0.4, 0.0);
117 shoulderNode.add(armNode);
118 scene.add(shoulderNode);
119 _arms.add(armNode);
120 }
121 return scene;
122 }
124 AnimationClip _wave({required double seconds}) => AnimationClip(
125 name: 'wave $seconds',
126 tracks: <AnimationTrack>[
127 AnimationTrack(
128 nodeIndex: 0,
129 path: AnimationPath.rotation,
130 interpolation: AnimationInterpolation.linear,
131 times: Float32List.fromList(<double>[0.0, seconds / 2, seconds]),
132 values: Float32List.fromList(<double>[
133 0.0, 0.0, 0.0, 1.0, //
134 ..._quarterZ(0.6),
135 0.0, 0.0, 0.0, 1.0,
136 ]),
137 componentCount: 4,
138 ),
139 ],
140 );
142 static List<double> _quarterZ(double radians) {
143 final Quaternion q = Quaternion.axisAngle(Vector3(0.0, 0.0, 1.0), radians)
144 ..normalize();
145 return <double>[q.x, q.y, q.z, q.w];
146 }
149 void update(DemoContext context, double dt) {
150 _time += dt;
151 // What a shader would do per instance: pick a clip's rows, blend the two
152 // the current time falls between, and use the joint matrix that came out.
153 // Nothing here touches a skeleton; the table already has the answer.
154 //
155 // A joint matrix is a delta from rest, not a place in the world — the
156 // shoulder is joint 0, and its matrix is identity when the shoulder has
157 // not turned. Composing it with the arm's own rest offset turns that
158 // delta back into "where the arm hangs now, relative to its shoulder".
159 for (var i = 0; i < _people; i++) {
160 final int clip = _clip[i];
161 final double duration = _table.durations[clip];
162 final double t = ((_time + _phase[i] * duration) % duration) / duration;
163 final double row = t * _framesPerClip;
164 final int a = row.floor() % _framesPerClip;
165 final int b = (a + 1) % _framesPerClip;
166 final double f = row - row.floor();
167 final Matrix4 ma = _table.matrixAt(clip, a, 0);
168 final Matrix4 mb = _table.matrixAt(clip, b, 0);
169 final Matrix4 shoulderDelta = Matrix4.zero();
170 for (var e = 0; e < 16; e++) {
171 shoulderDelta.storage[e] = ma.storage[e] * (1 - f) + mb.storage[e] * f;
172 }
173 _arms[i].setLocalMatrix(
174 shoulderDelta.multiplied(Matrix4.translation(Vector3(0.0, -0.4, 0.0))),
175 );
176 }
177 }
180 void verify(Scene scene, FrameResult frame) {
181 if (_table.clips != 2) throw StateError('two clips were baked');
182 if (_table.frames != _framesPerClip) {
183 throw StateError('the table samples $_framesPerClip frames a clip');
184 }
185 // A joint matrix is worldMatrix * inverseBindMatrix: at the shoulder's own
186 // rest pose the two cancel, so row 0 of every clip has to read back as the
187 // identity, or a crowd reading the table wears the wrong pose from frame
188 // one.
189 final Matrix4 rest = _table.matrixAt(0, 0, 0);
190 if ((rest.clone()..sub(Matrix4.identity())).storage.any(
191 (double v) => v.abs() > 0.01,
192 )) {
193 throw StateError('frame 0 is not the shoulder at rest');
194 }
195 if (frame.drawCalls < 1) throw StateError('the crowd was not drawn');
196 }
197}