unlit3dx_examples/scene/mod.rs
1//! The demo's scene: its constants, its objects, and the worlds that run them.
2//!
3//! The windowed binary and the headless run both build on this module:
4//! everything here is world-shaped, free of window and thread plumbing, so the
5//! same objects drive the windowed show and the deterministic headless capture.
6//!
7//! The gameplay world is a set of objects rather than one state blob. The
8//! `tick` module says how one is put together; the objects themselves are:
9//!
10//! - [`SceneState`] builds the scene's assets once; every drop asks the render
11//! world for an instance of its own.
12//! - [`spawn_floor`] puts the ground under the balls, in physics and in the
13//! render world.
14//! - [`Balls`] drops the balls: each one is a [`Ball`] entity of its own, which
15//! both the render world and the physics world answer by name, and the poses
16//! the physics world reports are published from it.
17//! - [`SceneAudio`] installs the synthesizer's voice and starts the music: the
18//! [`Track`]s it spawns own their clocks here, and each tick sends the audio
19//! world the messages that came due.
20//! - [`mount_controls`] is the panel. It lives here with the rest, and the
21//! render world draws it: the panel's egui context runs in this world, which
22//! lays it out, and the render world's `UiSource` holds and draws every frame
23//! it is sent.
24//!
25//! The panel is the one object whose drawing happens in another world: it
26//! carries a `UiPanel`, and the `UiPanelHost` spawned beside it holds the
27//! context its interface is built in, laying a frame out each tick and sending
28//! it to the render world as a job. It reads input like any other object, from
29//! this world's own buffer, so the events that move it are the ones this
30//! world's tick dispatched.
31//!
32//! The setup spawns the objects in whatever order reads best, and only the two
33//! that read what another object writes state an order: the balls tick after
34//! the default band the assets are built in, and the panel's publish after both.
35//! Everything else is in the default band, where the dispatch promises nothing
36//! about which of them runs first.
37
38use std::sync::Arc;
39use std::sync::atomic::{AtomicU32, Ordering};
40use std::time::Duration;
41
42use glam::{Quat, Vec3};
43use hashbrown::HashMap;
44use unlit_ecs::bundle;
45use unlit_ecs::{Entity, World};
46use unlit_wgpu::resources::ResourceGraph;
47use unlit3d::components::Transform;
48use unlit3d::frame::{Frame, dispatch_frame};
49use unlit3d::prelude::*;
50use unlit3d::ui::UiTarget;
51use unlit3dx::physics::PhysicsWorld;
52use unlit3dx::prelude::*;
53use unlit3dx::ui::UiPanelHost;
54
55use crate::render::{RenderEntity, camera_looking_at, mesh_source};
56
57use self::audio::Music;
58use self::balls::BallDrops;
59use self::shared::SceneBuild;
60
61pub use audio::{AudioSetup, SceneAudio, SceneTracks, Track, install_synth};
62pub use balls::{Ball, BallPhase, Balls};
63pub use floor::{
64 GROUND_CHECKER_DARK, GROUND_CHECKER_SIZE, GROUND_COLOR, GROUND_HALF, GROUND_POSITION,
65 checker_ground, spawn_floor,
66};
67pub use music::{ELECTRIC_PIANO_PROGRAM, MUSIC_BOX_PROGRAM, SCALE, bgm_midi, sequence_midi};
68pub use shapes::{
69 BALL_COLORS, BALL_RADIUS, PATTERN_SIZE, SHAPE_KINDS, ShapeKind, pattern_texture, shape_body,
70 shape_kind, shape_mesh,
71};
72pub use shared::{SceneAssets, SceneState};
73pub use ui::{control_panel_job, mount_controls};
74
75mod audio;
76mod balls;
77mod floor;
78mod music;
79mod shapes;
80mod shared;
81mod tick;
82mod ui;
83
84/// The window opens at this size, in logical pixels.
85pub const WINDOW_SIZE: (u32, u32) = (1024, 720);
86
87/// The render sub-world's command queue depth. A frame sends the scene
88/// build, the ground, one creation and one show per drop, one pose job and a
89/// periodic sweep, with the clears adding a batch despawn, so the capacity
90/// keeps every request in flight.
91pub const RENDER_COMMAND_CAPACITY: usize = 64;
92
93/// Where a hidden instance waits, far below everything the camera sees.
94pub const HIDDEN_POSITION: Vec3 = Vec3::new(0.0, -1000.0, 0.0);
95
96/// The rate a run without a window steps its frames at, in hertz.
97pub const HEADLESS_HZ: f64 = 60.0;
98
99/// The gameplay world's pacing rate, in hertz, until the window reports the
100/// refresh rate of the display it is on.
101///
102/// The rate is a ceiling on how often the world wakes, not a simulation rate:
103/// a tick's duration is the one it measured, so a display that redraws
104/// slower or faster changes how long the thread sleeps, never how the scene
105/// behaves.
106pub const GAMEPLAY_HZ: f64 = 60.0;
107
108/// The poses gameplay sends the render world every frame: one [`Transform`]
109/// per live body, at its physics pose. An instance that is hidden or waiting
110/// for a body is not named: the show job already put it where it belongs.
111/// [`pose_job`] turns one into the job that writes them onto the entities
112/// they name.
113///
114/// The entities are the render world's own. Every world numbers its entities
115/// from zero, so an id from the world that sends the job names something
116/// unrelated in the world that runs it, which is why they are
117/// [`RenderEntity`]s rather than bare entities.
118#[derive(Clone, Debug, Default, PartialEq)]
119pub struct ScenePoses(pub Vec<(RenderEntity, Transform)>);
120
121/// A job that writes `poses` onto the render entities they name.
122///
123/// Moving a scene's instances is the scene's own business, so it travels
124/// to the render world as a job like every other change to it: the world
125/// applies what it is told and holds no mailbox of its own. An entity the
126/// render world has not spawned yet is not an error — it simply names
127/// nothing.
128pub fn pose_job(poses: ScenePoses) -> Job {
129 Box::new(move |world: &mut World| {
130 // A render instance lives under the source that draws it, so the
131 // scope that owns it is the one that reaches it.
132 let Some((source, _)) = mesh_source(world) else {
133 return;
134 };
135 let Ok(scope) = world.scope(source.0) else {
136 return;
137 };
138 for (entity, pose) in poses.0.iter() {
139 let _ = scope
140 .query_one::<&mut Transform>(entity.0)
141 .map(|mut transform| {
142 transform.get().clone_from(pose);
143 });
144 }
145 })
146}
147
148/// How fast the camera circles the scene, in radians per second.
149pub const ORBIT_SPEED: f32 = 0.25;
150
151/// The camera's orbit radius, in metres.
152pub const ORBIT_RADIUS: f32 = 16.0;
153
154/// The camera's orbit height, in metres.
155pub const ORBIT_HEIGHT: f32 = 9.0;
156
157/// The point the camera looks at.
158pub const ORBIT_TARGET: Vec3 = Vec3::new(0.0, 0.6, 0.0);
159
160/// Where the camera's orbit starts, in radians.
161pub const ORBIT_START: f32 = 0.65;
162
163/// The order the balls tick at: after the default band, where the scene's
164/// assets are built, since a tick reads the assets the build wrote.
165pub(super) const BALLS_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(100);
166
167/// The order the panel publishes at: after every object, the balls included,
168/// since the panel shows what they wrote this frame.
169pub(super) const PUBLISH_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(200);
170
171/// Set up the render sub-world: the resource graph, the renderer, the pass
172/// its sources draw into, and the asset-server source. Answers the renderer
173/// resource entity and the pass entity.
174///
175/// This is the whole of what a render world needs before it can draw, and
176/// none of it is this scene's: no camera, no shape, no panel. Everything
177/// the scene puts in the world — meshes, materials, instances, poses,
178/// camera, UI — arrives as a job, through the sub-world's drain in the
179/// draw behaviour.
180pub fn setup_render_world(
181 host: &mut World,
182 render_world: Entity,
183 device: &wgpu::Device,
184 queue: &wgpu::Queue,
185 capabilities: DeviceCapabilities,
186) -> (RenderEntity, RenderEntity) {
187 let Ok(mut nested) = host.root_scope().query_one::<&mut World>(render_world) else {
188 unreachable!("the sub-world is a child of the host world");
189 };
190 let world = nested.get();
191 let context = spawn_context(
192 world,
193 device.clone(),
194 queue.clone(),
195 ResourceGraph::new(),
196 capabilities,
197 );
198 let renderer = world
199 .root_scope_mut()
200 .spawn(bundle![Renderer::new(context)]);
201 // The pass draws every source below it, so the scene's sources belong to
202 // it rather than to the world's root.
203 let pass = world.root_scope_mut().spawn(Pass::new(SCENE_PASS_ORDER));
204 let mut source = MeshSource::new(&world.root_scope(), context);
205 source.register_lit_family(&world.root_scope());
206 world
207 .scope_mut(pass)
208 .expect("the scene's pass is alive")
209 .spawn(bundle![Source::new(source)]);
210 (RenderEntity(renderer), RenderEntity(pass))
211}
212
213/// A job that gives the render world this scene's camera: one entity
214/// holding a camera aimed at the scene, and the frame behaviour that walks
215/// it around its orbit.
216///
217/// Where a scene looks from is the scene's to say, so it reaches the
218/// render world the same way its meshes and poses do. The behaviour keeps
219/// the projection matched to the frame's own aspect, and advances the
220/// orbit by each frame's delta, so the speed does not follow the redraw
221/// rate.
222pub fn camera_job() -> Job {
223 let mut azimuth = ORBIT_START;
224 Box::new(move |world: &mut World| {
225 // The camera belongs to the mesh source: a source frames what its own
226 // subtree holds, so a camera beside it would never be found.
227 let Some((source, _)) = mesh_source(world) else {
228 panic!("the render world carries no MeshSource");
229 };
230 let camera_entity = world
231 .scope_mut(source.0)
232 .expect("the mesh source entity exists")
233 .spawn(bundle![camera_looking_at(
234 WINDOW_SIZE,
235 orbit_eye(azimuth),
236 ORBIT_TARGET,
237 60f32.to_radians(),
238 0.1,
239 ),]);
240 world
241 .root_scope_mut()
242 .spawn(bundle![OnFrame::new(move |world, _entity, frame| {
243 azimuth += ORBIT_SPEED * frame.delta_time;
244 let camera = camera_looking_at(
245 frame.size,
246 orbit_eye(azimuth),
247 ORBIT_TARGET,
248 60f32.to_radians(),
249 0.1,
250 );
251 let _ = world
252 .root_scope()
253 .query_one::<&mut Camera>(camera_entity)
254 .map(|mut current| {
255 *current.get() = camera;
256 });
257 }),]);
258 })
259}
260
261/// Queue this scene's own content on the render world: its camera, and —
262/// where there is a panel to draw — the UI source that draws it.
263///
264/// `pass` is the pass returned by [`setup_render_world`], where the panel's
265/// source belongs.
266///
267/// Both are jobs, so a world that was set up to draw can be given a scene
268/// without its setup ever hearing of one. The panel itself lives in the
269/// gameplay world and nothing here reaches it: the source draws whatever the
270/// slots hold, and the world holding the panel fills them.
271pub fn mount_scene_content(render: &WorldHandle, pass: RenderEntity, panels: Option<PanelSlots>) {
272 if let Err(error) = render.sender.send(camera_job()) {
273 log::error!("the render world refused its camera ({error:?})");
274 }
275 if let Some(panels) = panels
276 && let Err(error) = render.sender.send(control_panel_job(panels, pass))
277 {
278 log::error!("the render world refused its control panel ({error:?})");
279 }
280}
281
282/// The point on the orbit the camera sits at for one azimuth.
283fn orbit_eye(azimuth: f32) -> Vec3 {
284 Vec3::new(
285 ORBIT_TARGET.x + azimuth.cos() * ORBIT_RADIUS,
286 ORBIT_HEIGHT,
287 ORBIT_TARGET.z + azimuth.sin() * ORBIT_RADIUS,
288 )
289}
290
291/// The physics state the demo subscribes to: where every body is.
292///
293/// The library defines no snapshot payload of its own, so this one belongs to
294/// the demo: the collector the balls subscribe with fills it from the rapier
295/// world. It holds the newest poses, not a history — a body that moved twice
296/// between two reads appears once, where it is now. A handle is the key
297/// because it is what a ball names once its body is spawned.
298#[derive(Default)]
299pub struct GamePhysics {
300 /// Every moving body's pose, by the handle it was spawned with.
301 pub transforms: HashMap<RigidBodyHandle, (Vec3, Quat)>,
302}
303
304/// The other worlds, as the scene's objects reach them.
305///
306/// A tick hands one to every object, so an object names the world it wants
307/// instead of keeping a channel of its own. The ports are plain senders:
308/// what an object says to another world is a job, and what it learns back is
309/// a subscription — [`poses`](Self::poses) is the physics world's, collected
310/// between its steps. Each subscription belongs to the world it reads, so a
311/// run that never asks for a snapshot pays nothing for it.
312///
313/// A port also carries what its world is doing: [`render_state`](Self::render_state),
314/// [`physics_state`](Self::physics_state) and [`audio_state`](Self::audio_state)
315/// are how an object that asked a question learns that the world it asked is
316/// gone, instead of waiting for an answer that will never come.
317///
318/// Both run modes build one from the worlds they have — threaded worlds for
319/// the windowed show, stepped worlds for the headless capture — and both then
320/// spawn the same objects with it, so the wiring is written once.
321#[derive(Clone)]
322pub struct Scene {
323 /// Commands into the render world: the objects build their scene and
324 /// move their instances through them.
325 pub render: JobSender,
326 /// Commands into the physics world.
327 pub physics: JobSender,
328 /// Commands into the audio world.
329 pub audio: JobSender,
330 /// The physics world's latest pose snapshot.
331 pub poses: SnapshotReader<GamePhysics>,
332 /// Whether the render world is still running.
333 pub render_state: SnapshotReader<WorldState>,
334 /// Whether the physics world is still running.
335 pub physics_state: SnapshotReader<WorldState>,
336 /// Whether the audio world is still running.
337 pub audio_state: SnapshotReader<WorldState>,
338}
339
340/// Watch the physics world's body poses.
341///
342/// The collector walks the rapier world on the physics thread and copies the
343/// moving bodies' transforms; it answers nothing while they are unchanged, so
344/// a world whose bodies are all asleep publishes no snapshot at all. The
345/// snapshot carries state, not history: a body that moved twice between two
346/// reads is seen once, where it is now.
347fn watch_poses(sender: &JobSender) -> SnapshotReader<GamePhysics> {
348 let mut previous: HashMap<RigidBodyHandle, (Vec3, Quat)> = HashMap::new();
349 sender
350 .watch(move |world: &World| {
351 let mut transforms = HashMap::new();
352 if let Some(physics) = world
353 .root_scope()
354 .query::<(Entity, &PhysicsWorld)>()
355 .iter()
356 .next()
357 {
358 for (handle, body) in physics.1.bodies.iter() {
359 if body.is_dynamic() || body.is_kinematic() {
360 transforms.insert(handle, (body.translation(), *body.rotation()));
361 }
362 }
363 }
364 if transforms == previous {
365 return None;
366 }
367 // The collector keeps what it published to compare the next step
368 // against, so the world allocates only when the poses changed.
369 previous = transforms.clone();
370 Some(GamePhysics { transforms })
371 })
372 .expect("the physics world accepts a subscription")
373}
374
375/// Whether the world behind a port has ended.
376///
377/// A question asked of a world that panicked is never answered, and neither is
378/// one asked of a world that stopped: the job is gone with the turn it ran in.
379/// An object that asked waits on this instead of waiting forever.
380pub(super) fn world_ended(state: &SnapshotReader<WorldState>) -> bool {
381 matches!(
382 state.latest().as_deref(),
383 Some(WorldState::Stopped | WorldState::Panicked(_))
384 )
385}
386
387/// The scene's worlds, however they are driven.
388///
389/// Every world is a host entity carrying the runner's proxy, so the host's
390/// data is the same whether the worlds run on threads or the host steps
391/// them; only who calls their ticks differs. What the objects reach them
392/// through is a [`Scene`], built beside them and handed to every tick.
393#[derive(Clone)]
394pub struct SceneHandles {
395 /// The rapier world.
396 pub physics: WorldHandle,
397 /// The synthesizer's world.
398 pub audio: WorldHandle,
399 /// The world the scene's objects live in; its sender is the control
400 /// panel's handle on them.
401 pub gameplay: WorldHandle,
402 /// The refresh rate of the display the window is on, in millihertz, as
403 /// the frame loop last read it; zero until a window reports one.
404 ///
405 /// It paces the gameplay thread, nothing else: a tick's duration is the
406 /// one it measured, so a stale rate only makes the thread wake more or
407 /// less often than the display redraws.
408 pub refresh_millihertz: Arc<AtomicU32>,
409}
410
411/// The slot a panel publishes its frames into.
412const PANEL_SLOT: usize = 0;
413
414/// What the two worlds holding the panel share.
415///
416/// A panel's frames travel the other way, as jobs to the source drawing them:
417/// the drawing side owns the slots, and no frame crosses a thread except in
418/// the job that carries it. What comes back this way is the target the
419/// drawing side measured, which a publisher has to match for its clip
420/// rectangles to be packed in the right pixels.
421#[derive(Clone, Default)]
422pub struct PanelSlots {
423 /// The target the drawing side measures every frame it draws.
424 pub target: Snapshot<UiTarget>,
425}
426
427/// The clickable rectangles the control panel laid out most recently.
428///
429/// A headless client uses these positions to send the same pointer input a
430/// window receives, without duplicating egui's layout in the client.
431#[derive(Default)]
432pub struct PanelLayout {
433 /// The button rectangles, keyed by their visible labels.
434 pub buttons: HashMap<&'static str, unlit3d::ui::egui::Rect>,
435}
436
437/// Build the scene's worlds with the runner `R`: on their own threads, or
438/// stepped by the host.
439///
440/// The worlds are spawned in the order they depend on each other — physics
441/// first, so the wiring can subscribe to its state, then audio, then the
442/// gameplay world with its objects.
443///
444/// `input` is the writer the host feeds. The gameplay world is a sibling of
445/// the host rather than a world nested inside it, so nothing distributes the
446/// writer to it: it subscribes here, once, before its first tick. A run with
447/// no input of its own — the headless capture — passes a writer nothing is
448/// fed, and the panel still lays its frames out.
449pub fn spawn_scene<R: WorldRunner>(
450 host: &mut World,
451 render: WorldHandle,
452 audio_setup: AudioSetup,
453 panels: PanelSlots,
454 input: &InputWriter,
455 automatic_drops: bool,
456) -> SceneHandles {
457 // The physics world: fixed step, default gravity. The ground is queued
458 // by the floor object before the first ball drops.
459 let physics = spawn_physics_world_with::<R>(host, PhysicsConfig::default());
460 // The demo asks for the poses it needs; the library collects nothing on
461 // its own. The subscription lives in the physics world, so it runs
462 // between that world's steps whatever drives it.
463 let poses = watch_poses(&physics.sender);
464
465 // The audio world: one synthesizer voice, mixed into the output stream.
466 // The voice builds its synthesizer on the first fill it renders, so it
467 // needs no rate here: whatever opens the stream says what that rate is.
468 // Only the setup that opens a device asks a stepped world for one; a
469 // world the host pulls the fills of opens nothing.
470 let config = AudioConfig {
471 output: match audio_setup {
472 AudioSetup::Device => AudioOutputMode::OnRequest,
473 _ => AudioOutputMode::Pulled,
474 },
475 ..AudioConfig::default()
476 };
477 let audio = spawn_audio_world_with::<R>(host, config);
478 // The voice is installed from the gameplay world, as a job whose answer
479 // is the entity the scenes' sequences address. The soundfont is kept for
480 // that job rather than loaded here, so the decode runs where the job
481 // runs instead of stalling the host's setup.
482 let (soundfont, autoplay_background) = match audio_setup {
483 AudioSetup::Silent => (None, true),
484 AudioSetup::Pulled {
485 soundfont,
486 autoplay_background,
487 } => (Some(soundfont), autoplay_background),
488 AudioSetup::Device => (Some(unlit3dx_asset_bytes::soundfont().into()), false),
489 };
490
491 let scene = Scene {
492 render: render.sender.clone(),
493 render_state: render.state.clone(),
494 physics: physics.sender.clone(),
495 physics_state: physics.state.clone(),
496 audio: audio.sender.clone(),
497 audio_state: audio.state.clone(),
498 poses,
499 };
500
501 // The gameplay world: one entity per object, each ticking itself as the
502 // world's frame. A world the host steps is ticked once per call, so it
503 // does not pace itself; a thread of its own wakes as often as the
504 // display redraws and sleeps between ticks.
505 let refresh_millihertz = Arc::new(AtomicU32::new(0));
506 let gameplay = R::spawn(
507 host,
508 "gameplay",
509 64,
510 {
511 let scene = scene.clone();
512 let soundfont = soundfont.clone();
513 let panels = panels.clone();
514 let input = input.clone();
515 move |world: &mut World| {
516 // This world is a sibling of the host, not a world nested in
517 // it, so the host's fan-out never reaches it: it takes its own
518 // buffer, once, before the first tick reads one.
519 world.root_scope_mut().spawn(bundle![input.subscribe()]);
520 world
521 .root_scope_mut()
522 .spawn(bundle![OnFrame::new(|world, _entity, _frame| {
523 dispatch_input(world);
524 }),]);
525 spawn_floor(&scene);
526 world.root_scope_mut().spawn(bundle![scene.clone()]);
527 world.root_scope_mut().spawn(SceneBuild::new(&scene));
528 world
529 .root_scope_mut()
530 .spawn(BallDrops::new(&scene, automatic_drops));
531 if let Some(soundfont) = soundfont {
532 world.root_scope_mut().spawn(Music::new(
533 &scene,
534 soundfont,
535 autoplay_background,
536 ));
537 }
538 // The panel is the fifth object, and lives here with the rest.
539 // The host that lays it out is an entity of its own, so the
540 // world it belongs to is the only thing tying the two
541 // together. Publishing states an order after every object,
542 // since the panel shows what they wrote this frame, and the
543 // render world draws whatever is there.
544 world.root_scope_mut().spawn(bundle![UiPanelHost::new(
545 render.sender.clone(),
546 panels.target.reader(),
547 PANEL_SLOT,
548 ),]);
549 mount_controls(world);
550 world.root_scope_mut().spawn(bundle![OnFrame::with_order(
551 PUBLISH_ORDER,
552 |world, _entity, frame| publish_panels(world, frame)
553 ),]);
554 }
555 },
556 {
557 let refresh_millihertz = refresh_millihertz.clone();
558 let mut clock = FixedClock::new(Duration::from_secs_f64(1.0 / GAMEPLAY_HZ));
559 let mut index = 0;
560 move |world: &mut World, control: &ThreadControl, (), delta_seconds: f32| {
561 if control.stop_requested() {
562 return false;
563 }
564 // A tick is a frame whose duration is the one it measured:
565 // the objects read it from the frame like any other
566 // behaviour, and nothing paces itself off a clock.
567 let mut frame = Frame {
568 delta_time: delta_seconds,
569 index,
570 size: WINDOW_SIZE,
571 };
572 index = index.wrapping_add(1);
573 dispatch_frame(world, &mut frame);
574 if R::SELF_DRIVEN {
575 // Waking faster than the display redraws only burns
576 // frames nobody sees, so the refresh rate the frame loop
577 // last read sets the pace; a run with no window, or one
578 // whose display reports nothing, keeps the default.
579 let millihertz = refresh_millihertz.load(Ordering::Relaxed);
580 let hz = if millihertz == 0 {
581 GAMEPLAY_HZ
582 } else {
583 f64::from(millihertz) / 1000.0
584 };
585 clock.set_step(Duration::from_secs_f64(1.0 / hz));
586 clock.wait(control);
587 }
588 true
589 }
590 },
591 );
592
593 SceneHandles {
594 physics,
595 audio,
596 gameplay,
597 refresh_millihertz,
598 }
599}
600
601/// Lay this tick's panel out and publish it, if it could differ from the one
602/// the drawing side holds.
603///
604/// The events the panel reads are this world's own, from the buffer its tick
605/// filled, so what the user clicked on is what this tick dispatched.
606fn publish_panels(world: &World, frame: &Frame) {
607 if let Some((_, host)) = world
608 .root_scope()
609 .query::<(Entity, &mut UiPanelHost)>()
610 .iter()
611 .next()
612 {
613 host.publish(world, frame);
614 }
615}
616
617/// The pacing a host uses to step a scene's worlds frame by frame.
618///
619/// The frame's own duration is what drives the worlds, and none of their
620/// rates is an integer multiple of the display's, so each frame carries
621/// the fraction of a step it did not spend into the next one. Counting
622/// seconds rather than frames is what keeps the simulation at its rates on
623/// a display that redraws at anything but 60 Hz; a fixed-rate run passes an
624/// exact step instead of a measured one, which is what makes it
625/// reproducible.
626pub struct SceneStepper {
627 /// The physics time the last frame did not spend.
628 physics_debt: f32,
629 /// The audio time the last frame did not spend.
630 audio_debt: f64,
631 /// How long one physics step lasts.
632 physics_period: f32,
633 /// How long one audio frame lasts, or none when the host pulls no
634 /// audio.
635 sample_period: Option<f64>,
636}
637
638impl SceneStepper {
639 /// A stepper for a scene whose physics world runs at `physics_hz`, and
640 /// — when `sample_rate` is given — whose synthesizer runs at that rate.
641 ///
642 /// Gameplay has no rate here: its world ticks once per frame, with the
643 /// frame's own duration.
644 #[must_use]
645 pub fn new(physics_hz: f64, sample_rate: Option<u32>) -> Self {
646 Self {
647 physics_debt: 0.0,
648 audio_debt: 0.0,
649 physics_period: 1.0 / physics_hz as f32,
650 sample_period: sample_rate.map(|rate| 1.0 / f64::from(rate)),
651 }
652 }
653
654 /// Step the scene once for `delta_seconds`: gameplay ticks, physics
655 /// steps as its rate comes due, and the audio world drains the note jobs
656 /// those ticks sent.
657 ///
658 /// The render world is not stepped here: it is a sub-world the draw
659 /// drains, not one with a rate of its own.
660 pub fn step(&mut self, host: &World, worlds: &SceneHandles, delta_seconds: f64) {
661 // Gameplay runs once per host frame with its measured duration: its
662 // requests reach the other worlds before they step, and a slow frame
663 // never causes a burst of catch-up ticks.
664 step_stepped_world(host, worlds.gameplay.entity, delta_seconds as f32)
665 .expect("the stepped world starts");
666 let steps = fixed_steps(
667 &mut self.physics_debt,
668 delta_seconds as f32,
669 self.physics_period,
670 );
671 for _ in 0..steps {
672 step_stepped_world(host, worlds.physics.entity, self.physics_period)
673 .expect("the stepped world starts");
674 }
675 step_stepped_world(host, worlds.audio.entity, delta_seconds as f32)
676 .expect("the stepped world starts");
677 }
678
679 /// The audio frames this frame's duration asks for, carrying the
680 /// fraction it did not spend into the next frame.
681 ///
682 /// Zero when the stepper was built without a sample rate.
683 pub fn audio_frames(&mut self, delta_seconds: f64) -> usize {
684 let Some(period) = self.sample_period else {
685 return 0;
686 };
687 self.audio_debt += delta_seconds;
688 let frames = (self.audio_debt / period) as usize;
689 self.audio_debt -= frames as f64 * period;
690 frames
691 }
692}
693
694/// Ask the balls to drop the next one, from another world.
695///
696/// The host's keyboard behaviour lives outside the gameplay world, so it
697/// reaches the balls through a job; the panel, which lives inside it, calls
698/// [`drop_ball_in`] instead.
699pub fn drop_ball(sender: &JobSender) {
700 sender
701 .send(Box::new(|world: &mut World| {
702 drop_ball_in(&world.root_scope());
703 }))
704 .ok();
705}
706
707/// Ask the balls to drop the next one, from inside the gameplay world.
708pub fn drop_ball_in(world: &Scope<'_>) {
709 if let Some((_, balls)) = world.query::<(Entity, &mut Balls)>().iter().next() {
710 balls.queue_drop();
711 }
712}
713
714/// Ask the balls to go away, from inside the gameplay world.
715///
716/// The music keeps playing; the panel drives it.
717pub fn clear_balls_in(world: &Scope<'_>) {
718 if let Some((_, balls)) = world.query::<(Entity, &mut Balls)>().iter().next() {
719 balls.queue_clear();
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use std::sync::Arc;
727 use std::sync::atomic::{AtomicUsize, Ordering};
728 use unlit_wgpu::ui::ScreenDescriptor;
729 use unlit3d::ui::{UiPanel, egui};
730
731 /// The target a drawing side publishes once it has measured its pass.
732 ///
733 /// These tests drive only the gameplay world, so nothing draws and
734 /// nothing would otherwise report a size to lay a frame out at.
735 fn measured_target() -> UiTarget {
736 UiTarget {
737 screen: ScreenDescriptor {
738 size_in_pixels: [WINDOW_SIZE.0, WINDOW_SIZE.1],
739 pixels_per_point: 1.0,
740 },
741 max_texture_side: 2048,
742 }
743 }
744
745 /// The gameplay world is a sibling of the host, so nothing distributes
746 /// the input writer into it: the world subscribes itself when it is set
747 /// up. This checks that an event fed to the writer reaches that world and
748 /// brings its panel back to lay a frame out.
749 #[test]
750 fn input_reaches_a_panel_published_from_a_stepped_world() {
751 let mut host = World::new();
752 let render = spawn_sub_world(&mut host, "render", 8);
753 let input = InputWriter::new();
754 let panels = PanelSlots::default();
755 // The target is published before the panel exists to subscribe to it:
756 // a snapshot keeps its newest value, so the panel finds it on its
757 // first tick instead of waiting for another draw.
758 panels.target.publish(measured_target());
759 let worlds =
760 spawn_scene::<Stepped>(&mut host, render, AudioSetup::Silent, panels, &input, true);
761 let runs = Arc::new(AtomicUsize::new(0));
762 let counted = Arc::clone(&runs);
763 worlds
764 .gameplay
765 .sender
766 .send(Box::new(move |world: &mut World| {
767 world.root_scope_mut().spawn(bundle![UiPanel::new(
768 move |_, _, _ui: &mut egui::Ui| {
769 counted.fetch_add(1, Ordering::Relaxed);
770 },
771 ),]);
772 }))
773 .expect("the gameplay world accepts a job");
774
775 // The first ticks settle the panel: egui answers its own first pass
776 // with a request for another, and once the picture is settled with no
777 // input arriving, no frame is laid out at all.
778 let mut settled = 0;
779 for _ in 0..8 {
780 step_stepped_world(&host, worlds.gameplay.entity, FRAME_SECONDS)
781 .expect("the stepped world starts");
782 let now = runs.load(Ordering::Relaxed);
783 if now == settled {
784 break;
785 }
786 settled = now;
787 }
788 assert!(settled > 0, "the panel was laid out");
789
790 // A press fed to the writer is what a window would push.
791 input.push(InputEvent::Key(KeyEvent {
792 key: Key::Space,
793 pressed: true,
794 repeat: false,
795 modifiers: Modifiers::default(),
796 }));
797 step_stepped_world(&host, worlds.gameplay.entity, FRAME_SECONDS)
798 .expect("the stepped world starts");
799
800 assert!(
801 runs.load(Ordering::Relaxed) > settled,
802 "the event brought the panel back to lay a frame out"
803 );
804 }
805
806 /// The events a panel sees are the ones this world's own dispatch
807 /// delivered on that tick, which is what makes the two agree.
808 #[test]
809 fn a_panel_reads_the_events_its_own_tick_dispatched() {
810 let mut host = World::new();
811 let render = spawn_sub_world(&mut host, "render", 8);
812 let input = InputWriter::new();
813 let pressed = Arc::new(AtomicUsize::new(0));
814 let panels = PanelSlots::default();
815 // Published before the panel exists to subscribe to it, which a
816 // snapshot keeps for it.
817 panels.target.publish(measured_target());
818 // The wiring is the scene's, but the panel is this test's: it counts
819 // the space presses it saw, the way the real one reads a click.
820 let worlds =
821 spawn_scene::<Stepped>(&mut host, render, AudioSetup::Silent, panels, &input, true);
822 let counted = Arc::clone(&pressed);
823 worlds
824 .gameplay
825 .sender
826 .send(Box::new(move |world: &mut World| {
827 world.root_scope_mut().spawn(bundle![UiPanel::new(
828 move |world, _, _ui: &mut egui::Ui| {
829 if let Some((_, events)) =
830 world.query::<(Entity, &InputEvents)>().iter().next()
831 {
832 events.with_batch(|batch| {
833 counted.fetch_add(batch.len(), Ordering::Relaxed);
834 });
835 }
836 },
837 ),]);
838 }))
839 .expect("the gameplay world accepts a job");
840
841 input.push(InputEvent::Key(KeyEvent {
842 key: Key::Space,
843 pressed: true,
844 repeat: false,
845 modifiers: Modifiers::default(),
846 }));
847 step_stepped_world(&host, worlds.gameplay.entity, FRAME_SECONDS)
848 .expect("the stepped world starts");
849
850 assert_eq!(
851 pressed.load(Ordering::Relaxed),
852 1,
853 "the panel saw the event its tick dispatched"
854 );
855 }
856}