unlit3d_examples/scenes/gltf.rs
1//! The glTF scene: a skinned fox and a morph-target cube, animated from the
2//! documents they were loaded from.
3//!
4//! Both assets under `unlit3d_asset_files/assets` are loaded as glTF documents
5//! and drawn through one [`MeshSource`]: the fox's mesh is deformed by its own
6//! skeleton under the document's `Walk` animation, and the morph stress test's
7//! mesh is deformed by its eight morph targets under the document's `Pulse`
8//! animation. In the window each clip plays at its own real duration and loops.
9//! A snapshot capture instead walks a fixed six-step sequence and stores each
10//! step as its own snapshot, so a wrong joint matrix, a mispacked morph delta
11//! or a stale binding shows up as a frame that does not move the way it should.
12
13use super::{SceneControl, SceneDef, SceneOptions, TEST_SIZE};
14use unlit_ecs::bundle;
15use unlit3d::gltf::LitGltf;
16use unlit3d::prelude::*;
17
18/// The frames a snapshot capture freezes, one snapshot each.
19const FRAMES: usize = 6;
20
21/// The animation each document plays, by the name the document gives it.
22///
23/// The fox walks; the morph cube pulses, which is the animation that drives
24/// every one of its eight targets at once.
25const FOX_ANIMATION: &str = "Walk";
26const MORPH_ANIMATION: &str = "Pulse";
27
28/// Where the fox stands.
29///
30/// The fox is authored nose along −Z, so a camera that frames the pair along
31/// the axis the two are separated on would see it exactly side-on. Turning it
32/// towards the camera reads the body's depth instead of flattening it to a
33/// silhouette. The rotation is about the fox's own centre, which is why the
34/// translation is not zero: turning a model whose origin sits at one end of
35/// its length would otherwise swing it out of frame.
36const FOX_PLACEMENT: Transform = Transform {
37 translation: glam::Vec3::new(-4.54, 0.0, -1.01),
38 // A 25° turn about Y towards the camera, written out because
39 // `from_rotation_y` is not a constant function.
40 rotation: glam::Quat::from_xyzw(0.0, -0.216_439_62, 0.0, 0.976_296),
41 scale: glam::Vec3::ONE,
42};
43
44/// Where the morph cube stands, so it sits beside the fox rather than inside
45/// it.
46///
47/// The two assets are authored at unrelated scales: the fox is about 155 units
48/// long and 79 tall, while the morph cube is 4 units across. Scaling the cube
49/// up and moving it clear of the fox is what makes both readable at once: the
50/// cube is placed to the fox's side and nearer the camera, which is what lets
51/// it be this large without hiding the animal behind it.
52const MORPH_PLACEMENT: Transform = Transform {
53 translation: glam::Vec3::new(20.0, 0.0, 90.0),
54 rotation: glam::Quat::IDENTITY,
55 scale: glam::Vec3::splat(20.0),
56};
57
58/// Where the camera sits, and the point it looks at.
59///
60/// The eye is lifted above the pair and set off to the side the fox turns
61/// towards, far enough back that the fox and the scaled cube each fill most of
62/// the frame without either leaving it.
63const CAMERA_EYE: glam::Vec3 = glam::Vec3::new(160.5, 109.0, 49.5);
64const CAMERA_TARGET: glam::Vec3 = glam::Vec3::new(4.5, 38.0, 26.5);
65
66/// The scene: two glTF documents whose clips play in real time, with six frames
67/// stored as snapshots.
68pub static SCENE: SceneDef = SceneDef {
69 id: "gltf",
70 title: "glTF documents",
71 description: "a skinned fox and a morph-target cube playing their glTF clips",
72 size: TEST_SIZE,
73 baseline: Some(TEST_SIZE),
74 clear: None,
75 frames: FRAMES as u32,
76 // The window plays each clip continuously from the frame delta; only the
77 // snapshot capture walks the fixed sequence below.
78 step_seconds: None,
79 samples: 1,
80 depth: true,
81 ui: false,
82 reproducible_ui: false,
83 build,
84};
85
86/// Build the glTF scene's world content.
87fn build(
88 world: &mut World,
89 context: RenderContext,
90 pass: Entity,
91 _ui: Option<Entity>,
92 size: (u32, u32),
93 options: SceneOptions,
94) -> SceneControl {
95 let fox = LitGltf::from_bytes(unlit3d_asset_bytes::fox())
96 .expect("the fox asset is a valid glTF document");
97 let morph = LitGltf::from_bytes(unlit3d_asset_bytes::morph_stress_test())
98 .expect("the morph asset is a valid glTF document");
99
100 let mut source = MeshSource::new(&world.root_scope(), context);
101 source.register_lit_family(&world.root_scope());
102 let source_entity = world
103 .scope_mut(pass)
104 .expect("the pass is alive")
105 .spawn(bundle![Source::new(source)]);
106
107 // Both documents' resources go into the one source the scene draws
108 // through: a mesh allocated by one source cannot be drawn by another, so
109 // two documents sharing a frame share a source. Each document derives its
110 // own pipeline keys, since a skinned primitive and a morphed one read
111 // different streams.
112 let (fox_resources, morph_resources) =
113 super::with_mesh_source(world, source_entity, |source, world| {
114 let fox_resources = fox.insert_resources(source, world);
115 let morph_resources = morph.insert_resources(source, world);
116 (fox_resources, morph_resources)
117 });
118
119 // The spawned handles are what `apply_animation` writes through: the fox's
120 // handle carries the pose entity its skin reads, and the cube's carries
121 // the weights entity its morph targets read.
122 let (fox_nodes, morph_nodes) = {
123 let mut scope = world
124 .scope_mut(source_entity)
125 .expect("the source entity is alive");
126 (
127 fox.spawn_default_scene(&mut scope, &fox_resources),
128 morph.spawn_default_scene(&mut scope, &morph_resources),
129 )
130 };
131
132 // Neither document animates the node its mesh hangs from — the fox's mesh
133 // sits on a root beside the skeleton, and the cube's node is animated only
134 // in its morph weights — so the placement written here survives every
135 // frame instead of being recomposed away.
136 {
137 let scope = world
138 .scope(source_entity)
139 .expect("the source entity is alive");
140 for node in &fox_nodes {
141 for &entity in &node.entities {
142 let _ = scope
143 .query_one::<&mut Transform>(entity)
144 .map(|mut current| *current.get() = FOX_PLACEMENT);
145 }
146 }
147 for node in &morph_nodes {
148 for &entity in &node.entities {
149 let _ = scope
150 .query_one::<&mut Transform>(entity)
151 .map(|mut current| *current.get() = MORPH_PLACEMENT);
152 }
153 }
154 }
155
156 let camera_entity = super::spawn_in(
157 world,
158 source_entity,
159 bundle![super::camera_looking_at(CAMERA_EYE, CAMERA_TARGET, size)],
160 );
161
162 let fox_animation = animation_index(&fox, FOX_ANIMATION);
163 let morph_animation = animation_index(&morph, MORPH_ANIMATION);
164 let fox_duration = fox.animation_duration(unlit3d::gltf::AnimationId::new(fox_animation));
165 let morph_duration = morph.animation_duration(unlit3d::gltf::AnimationId::new(morph_animation));
166
167 // In the window each clip plays at its own real duration and loops. A
168 // snapshot capture instead walks a fixed six-step sequence, so a frame does
169 // not depend on when it was drawn.
170 let reproducible = options.reproducible;
171 let mut clock = 0.0f32;
172
173 SceneControl {
174 advance: Box::new(move |world, frame, delta, frame_size| {
175 let (fox_time, morph_time) = if reproducible {
176 // The sequence stops one step short of the animation's end, so
177 // the six stored frames span the clip without repeating its
178 // first pose at the end.
179 let phase = (frame as usize % FRAMES) as f32 / FRAMES as f32;
180 (fox_duration * phase, morph_duration * phase)
181 } else {
182 clock += delta;
183 (clock % fox_duration, clock % morph_duration)
184 };
185 let scope = world
186 .scope(source_entity)
187 .expect("the source entity is alive");
188 fox.apply_animation(
189 &scope,
190 unlit3d::gltf::AnimationId::new(fox_animation),
191 fox_time,
192 &fox_nodes,
193 );
194 morph.apply_animation(
195 &scope,
196 unlit3d::gltf::AnimationId::new(morph_animation),
197 morph_time,
198 &morph_nodes,
199 );
200 // The camera the whole sequence shares, re-aimed at the frame's
201 // aspect so a resized window does not stretch the assets.
202 super::aim_camera(
203 world,
204 source_entity,
205 camera_entity,
206 CAMERA_EYE,
207 CAMERA_TARGET,
208 frame_size,
209 );
210 }),
211 snapshot: Box::new(|frame| {
212 (frame < FRAMES as u32).then(|| format!("gltf/frame_{frame:02}.webp"))
213 }),
214 }
215}
216
217/// The index of the animation `name` in `document`.
218///
219/// The assets name their animations, so the scene asks for one by name rather
220/// than by a position that a re-export could shuffle.
221fn animation_index(document: &LitGltf, name: &str) -> usize {
222 (0..document.animation_count())
223 .find(|&animation| {
224 document.animation_name(unlit3d::gltf::AnimationId::new(animation)) == Some(name)
225 })
226 .unwrap_or_else(|| panic!("the document has an animation named `{name}`"))
227}