Skip to main content

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}