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}