Skip to main content

unlit3d_examples/scenes/
spin_cube.rs

1//! The example's own scene: a spinning, textured cube with two egui panels.
2//!
3//! This is the scene the windowed example always showed. Its snapshot test
4//! is the workspace's CI check against `spin_cube.webp`: 30 frames at the
5//! default window size, so a rendering regression fails the build.
6
7use super::{BASELINE_SIZE, SceneControl, SceneDef, SceneOptions};
8use unlit_ecs::{Bundle, DynamicBundle, bundle};
9use unlit_wgpu::pipeline::LitOptions;
10use unlit3d::prelude::*;
11use unlit3d::ui::{UiSource, egui};
12
13/// The number of samples every frame is rendered with.
14pub const SAMPLE_COUNT: u32 = 4;
15
16/// How fast the cube spins, in radians per second.
17const SPIN: f32 = 0.8;
18
19/// How far from the target the camera orbits, in world units.
20const ORBIT_RADIUS: f32 = 3.4;
21
22/// How high above the horizon the camera starts, in radians.
23const ORBIT_ELEVATION: f32 = 0.5;
24
25/// The point the camera looks at, in world units.
26const ORBIT_TARGET: glam::Vec3 = glam::Vec3::new(0.0, 0.0, 0.0);
27
28/// The scene: a spinning cube at the example's default window size, whose
29/// snapshot test verifies `spin_cube.webp`.
30pub static SCENE: SceneDef = SceneDef {
31    id: "spin_cube",
32    title: "Spinning cube",
33    description: "the example's own scene: a textured cube with two panels",
34    size: (960, 720),
35    baseline: Some(BASELINE_SIZE),
36    clear: None,
37    frames: 30,
38    step_seconds: None,
39    samples: SAMPLE_COUNT,
40    depth: true,
41    ui: true,
42    reproducible_ui: true,
43    build,
44};
45
46/// Build the cube scene's world content.
47fn build(
48    world: &mut World,
49    context: RenderContext,
50    pass: Entity,
51    ui: Option<Entity>,
52    size: (u32, u32),
53    options: SceneOptions,
54) -> SceneControl {
55    let SceneOptions { ui: panels, .. } = options;
56
57    // The built-in mesh source and the frame driver, as resource entities.
58    let mut source = MeshSource::new(&world.root_scope(), context);
59    source.register_lit_family(&world.root_scope());
60    let key = LitPipelineKey::new(LitOptions {
61        unlit: true,
62        ..LitOptions::standard()
63    });
64    let source_entity = world
65        .scope_mut(pass)
66        .expect("the pass is alive")
67        .spawn(bundle![Source::new(source)]);
68
69    // Geometry, its base-color texture and its material, all allocated
70    // through the mesh source so they live in the frame's resource graph.
71    let root = world.root_scope();
72    let (mesh, material) = root
73        .query_one::<&mut Source>(source_entity)
74        .map(|mut source| {
75            let source = source
76                .get()
77                .as_mut::<MeshSource>()
78                .expect("the source entity carries a MeshSource");
79            let (positions, uvs, colors, indices) = super::cube();
80            let mesh = source.allocate_lit_mesh(
81                &root,
82                &key.options,
83                LitMeshDesc {
84                    positions: &positions,
85                    uvs: Some(&uvs),
86                    colors: Some(&colors),
87                    indices: Some(&indices),
88                    ..Default::default()
89                },
90            );
91            let texture = checkerboard(&source.device(&root), &source.queue(&root), 64);
92            let view = source.register_texture_and_default_view(&root, texture).1;
93            // Linear filtering: the checkerboard is a high-frequency pattern,
94            // and point sampling it under minification aliases into moire on
95            // the faces the camera sees at a glancing angle.
96            let sampler = source.register_sampler(
97                &root,
98                Some(wgpu::SamplerDescriptor {
99                    mag_filter: wgpu::FilterMode::Linear,
100                    min_filter: wgpu::FilterMode::Linear,
101                    mipmap_filter: wgpu::MipmapFilterMode::Linear,
102                    anisotropy_clamp: 4,
103                    ..Default::default()
104                }),
105            );
106            let material = source.allocate_lit_material(&root, &key.options, view, sampler);
107            (mesh, material)
108        })
109        .expect("the source entity exists");
110
111    // The camera the frame is viewed from, and the cube itself. The scene
112    // leaves its pass at the default load ops, so the pass clears color and
113    // depth on its own.
114    super::spawn_in(
115        world,
116        source_entity,
117        bundle![orbit_camera(size, 0.6, ORBIT_ELEVATION)],
118    );
119    // The panel is drawn by the UI source and the cube by the mesh source, and
120    // one source's scope reaches nothing of the other's. The state both of them
121    // touch therefore lives in the shared subtree, which every scope reaches: the
122    // spin the panel drives, the frame count it reads, the drag the pointer
123    // behaviour follows and the orbit it writes.
124    let (spin, orbit) = {
125        let mut shared = world
126            .scope_mut(world.shared())
127            .expect("the shared scope exists");
128        (
129            shared.spawn(SpinState {
130                radians_per_second: SPIN,
131            }),
132            shared.spawn(bundle![CameraOrbit {
133                azimuth: 0.6,
134                elevation: ORBIT_ELEVATION,
135            },]),
136        )
137    };
138    // The cube is drawn by the mesh source, so it hangs in that source's scope
139    // beside the camera.
140    super::spawn_in(
141        world,
142        source_entity,
143        TexturedCube {
144            mesh,
145            material,
146            pipeline: GpuRenderPipeline::new(key),
147        },
148    );
149
150    if panels {
151        let panel = UiPanel::new(move |world, _entity, ui| {
152            // The id is set explicitly: `Window::new` derives it from the
153            // title's `Atoms` text, whose hash differs from a plain string
154            // id, so other panels could not find this window's rect.
155            egui::Window::new("unlit3d")
156                .id(egui::Id::unique("unlit3d"))
157                .show(ui.ctx(), |ui| {
158                    let frames = world
159                        .query_one::<&FrameCount>(spin)
160                        .map_or(0, |mut frames| frames.get().0);
161                    ui.label(format!("frame {frames}"));
162                    ui.label("The cube spins behind this panel.");
163
164                    let spinning = world
165                        .query_one::<&Spin>(spin)
166                        .is_ok_and(|mut state| state.get().spinning);
167                    let mut spinning_now = spinning;
168                    if ui.checkbox(&mut spinning_now, "Spin").changed() {
169                        let _ = world
170                            .query_one::<&mut Spin>(spin)
171                            .map(|mut state| state.get().spinning = spinning_now);
172                    }
173
174                    let mut speed = world
175                        .query_one::<&Spin>(spin)
176                        .map_or(SPIN, |mut state| state.get().radians_per_second);
177                    if ui
178                        .add(egui::Slider::new(&mut speed, 0.0..=4.0).text("rad/s"))
179                        .changed()
180                    {
181                        let _ = world.query_one::<&mut Spin>(spin).map(|mut state| {
182                            state.get().radians_per_second = speed;
183                        });
184                    }
185
186                    if ui.button("Reset the spin").clicked() {
187                        let _ = world
188                            .query_one::<&mut SpinReset>(spin)
189                            .map(|mut reset| reset.get().0 = true);
190                    }
191
192                    // The world's input state, read out under the panel's own
193                    // controls: what makes the events visible next to the UI
194                    // they also drive. One window, so no second title bar can
195                    // ever sit on top of this one and be painted as the window
196                    // the user is working in.
197                    ui.separator();
198                    let held = world
199                        .query::<(Entity, &InputEvents)>()
200                        .iter()
201                        .next()
202                        .is_some_and(|(_, events)| events.with_state(|state| state.pointer_down));
203                    ui.label(if held { "pointer: down" } else { "pointer: up" });
204                    match world.query_one::<&CameraOrbit>(orbit) {
205                        Ok(mut orbit) => {
206                            let orbit = orbit.get();
207                            ui.label(format!(
208                                "azimuth {:.2}, elevation {:.2}",
209                                orbit.azimuth, orbit.elevation
210                            ))
211                        }
212                        Err(_) => ui.label("no camera state"),
213                    };
214                });
215        });
216        super::spawn_in(
217            world,
218            ui.expect("the UI source was mounted"),
219            bundle![panel],
220        );
221    }
222    // The second panel shows the world's input state, which is what makes the
223    // events visible next to the UI they also drive.
224    // A key behaviour: space toggles the spin, and it reads the state the
225    // panel's checkbox also writes.
226    world
227        .root_scope_mut()
228        .spawn(bundle![OnKey::new(move |world, _entity, key| {
229            if key.pressed && !key.repeat && key.key == Key::Space {
230                let _ = world
231                    .root_scope()
232                    .query_one::<&mut Spin>(spin)
233                    .map(|mut state| state.get().spinning = !state.get().spinning);
234            }
235        }),]);
236
237    // A pointer behaviour: dragging orbits the camera. It is mounted on
238    // `OnPointer` rather than `OnMouse`, so the same drag works with a mouse
239    // and with a finger on a touch screen.
240    world
241        .root_scope_mut()
242        .spawn(bundle![OnPointer::new(move |world, _entity, event| {
243            let Some(position) = event.position else {
244                if matches!(
245                    event.action,
246                    PointerAction::Released { .. } | PointerAction::Left
247                ) {
248                    let _ = world
249                        .root_scope()
250                        .query_one::<&mut DragFrom>(spin)
251                        .map(|mut drag| drag.get().0 = None);
252                }
253                return;
254            };
255
256            // A press or drag over one of the panels drives the panel, not the
257            // camera: the UI's context knows what it claimed of the frame it laid
258            // out, and a pointer behaviour yields to it. The claim is broad — it
259            // is `true` whenever the pointer hangs over a panel, let alone drags
260            // one — so a gesture that started on the scene keeps working even when
261            // it sweeps over a panel. The contact is dropped here too, so a press
262            // that ends over a UI never leaves a drag behind for the scene.
263            let over_ui =
264                world
265                    .root_scope()
266                    .query::<(Entity, &Source)>()
267                    .iter()
268                    .any(|(_, source)| {
269                        source
270                            .as_ref::<UiSource>()
271                            .is_ok_and(|ui| ui.context().egui_wants_pointer_input())
272                    });
273            if over_ui {
274                let _ = world
275                    .root_scope()
276                    .query_one::<&mut DragFrom>(spin)
277                    .map(|mut drag| drag.get().0 = None);
278                return;
279            }
280
281            match event.action {
282                PointerAction::Pressed => {
283                    let _ = world
284                        .root_scope()
285                        .query_one::<&mut DragFrom>(spin)
286                        .map(|mut drag| {
287                            drag.get().0 = Some(PointerContact {
288                                kind: event.kind,
289                                id: event.id,
290                                position,
291                            });
292                        });
293                }
294                PointerAction::Moved => {
295                    let previous =
296                        world
297                            .root_scope()
298                            .query_one::<&mut DragFrom>(spin)
299                            .map(|mut drag| {
300                                let tracked = drag.get().0.as_mut()?;
301                                if (tracked.kind, tracked.id) != (event.kind, event.id) {
302                                    return None;
303                                }
304                                Some(std::mem::replace(&mut tracked.position, position))
305                            });
306                    let Ok(Some(previous)) = previous else {
307                        return;
308                    };
309                    const RADIANS_PER_PIXEL: f32 = 0.01;
310                    let (dx, dy) = (position[0] - previous[0], position[1] - previous[1]);
311                    let _ =
312                        world
313                            .root_scope()
314                            .query_one::<&mut CameraOrbit>(orbit)
315                            .map(|mut drag| {
316                                drag.get().azimuth -= dx * RADIANS_PER_PIXEL;
317                                drag.get().elevation = (drag.get().elevation
318                                    + dy * RADIANS_PER_PIXEL)
319                                    .clamp(-1.4, 1.4);
320                            });
321                }
322                PointerAction::Released { .. } | PointerAction::Left => {
323                    let _ = world
324                        .root_scope()
325                        .query_one::<&mut DragFrom>(spin)
326                        .map(|mut drag| drag.get().0 = None);
327                }
328                PointerAction::Zoom(_) | PointerAction::Rotate(_) => {}
329            }
330        },),]);
331
332    SceneControl {
333        advance: Box::new(move |world, _frame, delta_time, frame_size| {
334            // The spin and the orbit live in the shared subtree, which the root
335            // view reaches along with everything else.
336            let root = world.root_scope();
337
338            // The panel's button leaves its press as state; consume it here,
339            // once.
340            for (_, reset) in root.query::<(Entity, &mut SpinReset)>().iter() {
341                if core::mem::take(&mut reset.0) {
342                    for (_, spin) in root.query::<(Entity, &mut Spin)>().iter() {
343                        spin.angle = 0.0;
344                    }
345                }
346            }
347
348            // The behaviour components run first: the world the renderer reads
349            // is this frame's.
350            for (_, frames) in root.query::<(Entity, &mut FrameCount)>().iter() {
351                frames.0 += 1;
352            }
353            for (_, spin) in root.query::<(Entity, &mut Spin)>().iter() {
354                if spin.spinning {
355                    spin.angle += spin.radians_per_second * delta_time;
356                }
357            }
358
359            // The cube and the camera are what the mesh source draws, so they
360            // are read and written through its own scope.
361            let view = world
362                .scope(source_entity)
363                .expect("the source entity is alive");
364            let angle = root
365                .query::<(Entity, &Spin)>()
366                .iter()
367                .next()
368                .map_or(0.0, |(_, spin)| spin.angle);
369            for (_, transform) in view.query::<(Entity, &mut Transform)>().iter() {
370                transform.rotation = glam::Quat::from_rotation_y(angle);
371            }
372
373            // The camera follows the orbit a slider or a drag set — and the
374            // frame's own size, so a window that changes shape re-aims it
375            // instead of stretching the cube.
376            let orbit = root.query::<(Entity, &CameraOrbit)>().iter().next().map_or(
377                CameraOrbit {
378                    azimuth: 0.6,
379                    elevation: ORBIT_ELEVATION,
380                },
381                |(_, orbit)| CameraOrbit {
382                    azimuth: orbit.azimuth,
383                    elevation: orbit.elevation,
384                },
385            );
386            for (_, camera) in view.query::<(Entity, &mut Camera)>().iter() {
387                *camera = orbit_camera(frame_size, orbit.azimuth, orbit.elevation);
388            }
389        }),
390        snapshot: Box::new(|frame| (frame == 29).then(|| "spin_cube.webp".to_owned())),
391    }
392}
393
394/// The scene's one visible object: a textured cube turned by the frame loop.
395///
396/// The mesh, the material bound to it and the pipeline that draws it are what
397/// a spawn has to name, so they are the fields; the transform is the bundle's
398/// own, because the frame loop writes the rotation into it.
399struct TexturedCube {
400    mesh: GpuMesh,
401    material: GpuMaterial,
402    pipeline: GpuRenderPipeline,
403}
404
405impl Bundle for TexturedCube {
406    fn into_bundle(self) -> DynamicBundle {
407        bundle![
408            Transform::default(),
409            self.mesh,
410            self.material,
411            self.pipeline,
412        ]
413    }
414}
415
416/// The state of the spin, shared between the panel, the pointer behaviour and
417/// the frame loop.
418///
419/// Each of the four components is read or written by one of those three, and
420/// none of them means anything without the others, so one spawn states the
421/// whole thing: the spin itself, the panel's reset request, the drag the
422/// pointer is following and the frame count the panel reads out.
423struct SpinState {
424    /// How fast the cube turns, in radians per second.
425    radians_per_second: f32,
426}
427
428impl Bundle for SpinState {
429    fn into_bundle(self) -> DynamicBundle {
430        bundle![
431            Spin {
432                radians_per_second: self.radians_per_second,
433                spinning: true,
434                angle: 0.0,
435            },
436            SpinReset(false),
437            DragFrom(None),
438            FrameCount(0),
439        ]
440    }
441}
442
443/// Set by the panel's button, read once by the frame loop.
444struct SpinReset(bool);
445
446/// A behaviour component: the cube turns by `radians_per_second`.
447///
448/// `spinning` is a sibling the panel's checkbox writes, so the panel and the
449/// spin never borrow the same component.
450struct Spin {
451    radians_per_second: f32,
452    /// Whether the cube is currently turning.
453    spinning: bool,
454    /// The angle turned so far, advanced once per frame.
455    angle: f32,
456}
457
458/// How far the camera has been dragged around the cube, in radians.
459struct CameraOrbit {
460    /// The azimuth the camera looks from.
461    azimuth: f32,
462    /// How far above the horizon it sits.
463    elevation: f32,
464}
465
466/// The pointer the drag is following, and where it was at the previous move.
467struct DragFrom(Option<PointerContact>);
468
469/// Counts the frames drawn, for the panel's readout.
470struct FrameCount(u64);
471
472/// A camera orbiting [`ORBIT_TARGET`] at `azimuth` and `elevation`, in radians.
473///
474/// The projection comes from [`super::camera_looking_at`], so it follows the
475/// frame's own aspect: the built-in pipeline compares depth with `Greater` and
476/// clears to the far plane, so the projection is reverse-z infinite.
477fn orbit_camera(size: (u32, u32), azimuth: f32, elevation: f32) -> Camera {
478    let eye = ORBIT_TARGET
479        + ORBIT_RADIUS
480            * glam::Vec3::new(
481                elevation.cos() * azimuth.sin(),
482                elevation.sin(),
483                elevation.cos() * azimuth.cos(),
484            );
485    super::camera_looking_at(eye, ORBIT_TARGET, size)
486}
487
488/// A `size` x `size` checkerboard base-color texture.
489fn checkerboard(device: &wgpu::Device, queue: &wgpu::Queue, size: u32) -> wgpu::Texture {
490    let mut texels = Vec::with_capacity((size * size * 4) as usize);
491    for y in 0..size {
492        for x in 0..size {
493            let value = if (x + y) % 2 == 0 { 235u8 } else { 60 };
494            texels.extend_from_slice(&[value, value, value, 255]);
495        }
496    }
497    let texture = device.create_texture(&wgpu::TextureDescriptor {
498        label: Some("example::checkerboard"),
499        size: wgpu::Extent3d {
500            width: size,
501            height: size,
502            depth_or_array_layers: 1,
503        },
504        mip_level_count: 1,
505        sample_count: 1,
506        dimension: wgpu::TextureDimension::D2,
507        format: wgpu::TextureFormat::Rgba8UnormSrgb,
508        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
509        view_formats: &[],
510    });
511    queue.write_texture(
512        texture.as_image_copy(),
513        &texels,
514        wgpu::TexelCopyBufferLayout {
515            offset: 0,
516            bytes_per_row: Some(size * 4),
517            rows_per_image: Some(size),
518        },
519        wgpu::Extent3d {
520            width: size,
521            height: size,
522            depth_or_array_layers: 1,
523        },
524    );
525    texture
526}