Skip to main content

unlit3d_examples/scenes/
animated.rs

1//! The animated-grid scene: meshes allocated, recycled and moved across eight
2//! frames.
3//!
4//! Ported from the `ecs_animated_scene_matches_snapshots` test. One world is
5//! evolved over several frames: meshes are allocated, some are recycled, the
6//! moving ones translate and rotate, and the camera orbits the grid. Every
7//! frame is stored as its own snapshot, so the sequence shows what a wrong
8//! pooled range, a stale metadata entry or a mispacked instance looks like
9//! from frame to frame.
10
11use super::{SEQUENCE_STEP, SceneControl, SceneDef, SceneOptions, TEST_SIZE};
12use unlit_ecs::{Bundle, DynamicBundle, bundle};
13use unlit3d::prelude::*;
14
15/// The grid of cells a cube can occupy, in the XZ plane.
16const COLUMNS: usize = 3;
17const ROWS: usize = 3;
18const CELLS: usize = COLUMNS * ROWS;
19const CELL_SPACING: f32 = 1.5;
20
21/// Where the camera orbits, and how high above the grid it sits.
22const ORBIT_RADIUS: f32 = 4.3;
23const ORBIT_HEIGHT: f32 = 2.9;
24/// Radians the camera advances per frame.
25const ORBIT_STEP: f32 = 0.55;
26
27/// The per-instance base colors the allocations cycle through.
28const TINTS: [glam::Vec4; 6] = [
29    glam::Vec4::new(1.0, 0.25, 0.2, 1.0),
30    glam::Vec4::new(1.0, 0.75, 0.2, 1.0),
31    glam::Vec4::new(0.35, 0.9, 0.3, 1.0),
32    glam::Vec4::new(0.25, 0.75, 1.0, 1.0),
33    glam::Vec4::new(0.55, 0.35, 1.0, 1.0),
34    glam::Vec4::new(1.0, 0.4, 0.8, 1.0),
35];
36
37/// What a frame changes structurally, on top of the motion every frame applies.
38struct Step {
39    /// Cells the frame allocates a mesh in.
40    allocate: &'static [usize],
41    /// Cells the frame retires, before it allocates anything.
42    free: &'static [usize],
43}
44
45/// The structural change of each frame, as the snapshot test froze it.
46const STEPS: [Step; 8] = [
47    Step {
48        allocate: &[4, 1, 6],
49        free: &[],
50    },
51    Step {
52        allocate: &[0, 7],
53        free: &[],
54    },
55    Step {
56        allocate: &[],
57        free: &[],
58    },
59    Step {
60        allocate: &[2, 5, 8],
61        free: &[],
62    },
63    Step {
64        allocate: &[],
65        free: &[1],
66    },
67    Step {
68        allocate: &[3],
69        free: &[],
70    },
71    Step {
72        allocate: &[],
73        free: &[4, 0],
74    },
75    Step {
76        allocate: &[1, 4],
77        free: &[],
78    },
79];
80
81/// The scene: a 3x3 grid of cubes that fills, moves and recycles over eight
82/// frames, each stored as its own snapshot.
83pub static SCENE: SceneDef = SceneDef {
84    id: "animated",
85    title: "Animated grid",
86    description: "a grid of cubes filling, moving and recycling over eight frames",
87    size: TEST_SIZE,
88    baseline: Some(TEST_SIZE),
89    clear: None,
90    frames: STEPS.len() as u32,
91    step_seconds: Some(SEQUENCE_STEP),
92    samples: 1,
93    depth: true,
94    ui: false,
95    reproducible_ui: false,
96    build,
97};
98
99/// One cell of the grid: a cube placed at the cell this frame fills.
100///
101/// The transform and the tint change per allocation, so they are the fields;
102/// every cell draws the same way, so the pipeline is built here.
103struct CellCube {
104    transform: Transform,
105    tint: glam::Vec4,
106    mesh: GpuMesh,
107    pipeline: GpuRenderPipeline,
108}
109
110impl Bundle for CellCube {
111    fn into_bundle(self) -> DynamicBundle {
112        bundle![
113            self.transform,
114            InstanceColor::new(self.tint),
115            self.mesh,
116            self.pipeline,
117        ]
118    }
119}
120
121/// Build the animated-grid scene's world content.
122fn build(
123    world: &mut World,
124    context: RenderContext,
125    pass: Entity,
126    _ui: Option<Entity>,
127    size: (u32, u32),
128    _options: SceneOptions,
129) -> SceneControl {
130    let mut source = MeshSource::new(&world.root_scope(), context);
131    source.register_lit_family(&world.root_scope());
132    // Every cell is tinted through its instance colour.
133    let key = LitPipelineKey::new(super::default_options());
134    let source_entity = world
135        .scope_mut(pass)
136        .expect("the pass is alive")
137        .spawn(bundle![Source::new(source)]);
138
139    let camera_entity = super::spawn_in(world, source_entity, bundle![orbit_camera(0, size)]);
140
141    // One entry per cell, holding the entity and the mesh while it is
142    // occupied. Counts allocations, not cells: a cell that is filled twice
143    // gets two visibly different meshes.
144    let mut cells: Vec<Option<(Entity, GpuMesh)>> = (0..CELLS).map(|_| None).collect();
145    let mut allocated = 0usize;
146
147    SceneControl {
148        advance: Box::new(move |world, frame, _delta, frame_size| {
149            // The sequence loops, so a windowed run keeps animating. A pass
150            // starts from the state the first one did — every cell empty and
151            // the allocation counter back at zero — so a later pass replays the
152            // first exactly instead of laying new allocations over what the
153            // previous pass left behind. The allocation offset is baked into
154            // the vertices and grows with the counter, so a counter that kept
155            // climbing would drift the cubes out of the view.
156            let step_index = frame as usize % STEPS.len();
157            if step_index == 0 {
158                for entry in cells.iter_mut() {
159                    if let Some((entity, mesh)) = entry.take() {
160                        super::despawn_in(world, source_entity, entity);
161                        super::remove_mesh(world, source_entity, mesh);
162                    }
163                }
164                allocated = 0;
165            }
166            let step = &STEPS[step_index];
167
168            // Retire this frame's cells: the entity goes first, so nothing in
169            // the world still names a mesh the renderer is about to free.
170            for &cell in step.free {
171                let (entity, mesh) = cells[cell].take().expect("a freed cell holds a mesh");
172                super::despawn_in(world, source_entity, entity);
173                super::remove_mesh(world, source_entity, mesh);
174            }
175
176            for &cell in step.allocate {
177                // A cell that still holds a mesh — from an earlier pass over
178                // the looped sequence — is retired first, so a mesh never
179                // outlives the entity that names it.
180                if let Some((entity, mesh)) = cells[cell].take() {
181                    super::despawn_in(world, source_entity, entity);
182                    super::remove_mesh(world, source_entity, mesh);
183                }
184                // The allocation's offset is baked into the vertices, so every
185                // mesh draws different geometry even at the same transform.
186                let offset = glam::Vec3::splat(0.06 * allocated as f32);
187                let mesh = super::allocate_offset_cube_mesh(world, source_entity, &key, offset);
188                let entity = super::spawn_in(
189                    world,
190                    source_entity,
191                    CellCube {
192                        transform: cell_transform(cell, step_index),
193                        tint: TINTS[allocated % TINTS.len()],
194                        mesh: mesh.clone(),
195                        pipeline: GpuRenderPipeline::new(key.clone()),
196                    },
197                );
198                cells[cell] = Some((entity, mesh));
199                allocated += 1;
200            }
201
202            // Move the live meshes, then the camera: the world this frame
203            // renders is this frame's.
204            for (cell, entry) in cells.iter().enumerate() {
205                if let Some((entity, _)) = entry {
206                    let transform = cell_transform(cell, step_index);
207                    world
208                        .scope(source_entity)
209                        .expect("the source is alive")
210                        .query_one::<&mut Transform>(*entity)
211                        .map(|mut current| *current.get() = transform)
212                        .expect("a live mesh carries a transform");
213                }
214            }
215            // Re-aim from the frame's own size, so a window that changes
216            // shape re-aims the orbit instead of stretching the grid.
217            super::aim_camera(
218                world,
219                source_entity,
220                camera_entity,
221                orbit_eye(step_index),
222                GRID_TARGET,
223                frame_size,
224            );
225        }),
226        snapshot: Box::new(|frame| {
227            // The live count walks the steps up to `frame`, applying each
228            // frame's retirements and allocations in order — the same count
229            // the snapshot name froze.
230            let live = STEPS[..=frame as usize].iter().fold(0usize, |live, step| {
231                live - step.free.len() + step.allocate.len()
232            });
233            let step = &STEPS[frame as usize];
234            Some(format!(
235                "animated/frame_{frame:02}_alloc{}_free{}_live{live}.webp",
236                step.allocate.len(),
237                step.free.len(),
238            ))
239        }),
240    }
241}
242
243/// The world-space centre of `cell`.
244fn cell_center(cell: usize) -> glam::Vec3 {
245    let column = (cell % COLUMNS) as f32 - (COLUMNS as f32 - 1.0) * 0.5;
246    let row = (cell / COLUMNS) as f32 - (ROWS as f32 - 1.0) * 0.5;
247    glam::Vec3::new(column * CELL_SPACING, 0.2, row * CELL_SPACING)
248}
249
250/// The placement a cell's mesh has in `frame`.
251///
252/// Only every other cell moves: the others keep their placement, so a frame
253/// mixes static and moving meshes rather than moving the whole scene.
254fn cell_transform(cell: usize, frame: usize) -> Transform {
255    let moving = cell.is_multiple_of(2);
256    let phase = frame as f32 * 0.9 + cell as f32;
257    Transform {
258        translation: cell_center(cell)
259            + if moving {
260                glam::Vec3::Y * 0.35 * phase.sin()
261            } else {
262                glam::Vec3::ZERO
263            },
264        rotation: if moving {
265            glam::Quat::from_rotation_y(frame as f32 * 0.4 + cell as f32)
266        } else {
267            glam::Quat::from_rotation_y(cell as f32 * 0.7)
268        },
269        scale: glam::Vec3::splat(0.34 + 0.03 * (cell % 3) as f32),
270    }
271}
272
273/// The point the orbiting camera looks at.
274const GRID_TARGET: glam::Vec3 = glam::Vec3::new(0.0, 0.2, 0.0);
275
276/// The camera of `frame`, orbiting the grid once over the whole sequence.
277fn orbit_camera(frame: usize, size: (u32, u32)) -> Camera {
278    super::camera_looking_at(orbit_eye(frame), GRID_TARGET, size)
279}
280
281/// Where the camera of `frame` orbits.
282fn orbit_eye(frame: usize) -> glam::Vec3 {
283    let angle = frame as f32 * ORBIT_STEP;
284    glam::Vec3::new(
285        ORBIT_RADIUS * angle.sin(),
286        ORBIT_HEIGHT,
287        ORBIT_RADIUS * angle.cos(),
288    )
289}