Skip to main content

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}