Skip to main content

unlit3dx_examples/
lib.rs

1//! The multi-world demo's library half: the scene the windowed binary
2//! shows, the thread-free headless run the --headless mode and the
3//! snapshot tests drive, and the entry point the Android activity calls.
4//!
5//! A multi-world demo: one scene where rendering, physics and audio each
6//! run in their own world, joined by jobs and snapshots.
7//!
8//! The host world owns the window and drives the frame. A render sub-world
9//! sits on the main thread as a nested world and serves the others: it
10//! allocates meshes, materials and instances when the scene asks, and the
11//! entity bits it answers with are the handles the rest of the show uses.
12//! The scene's other worlds — physics, audio and gameplay — are spawned
13//! through one runner, so the host carries the same entities and components
14//! whether they run on threads of their own or are stepped by the host's
15//! frames. The gameplay world holds the scene's objects: one entity each for
16//! the scene's assets, the floor, the balls and the music, every one of them
17//! ticking itself. The panel is the fifth object, and lives in the gameplay
18//! world with the rest: it lays its frames out there and sends them to the
19//! render world, which holds and draws them.
20//!
21//! Space drops a body. The render world's control panel drives two music
22//! tracks — a looping background piece and a one-shot MIDI sequence — each
23//! of which pauses, resumes and rewinds on its own, and it drops the next
24//! ball or puts every live ball away.
25//! Bodies fall, bounce off the ground and each other while the music plays.
26//! The soundfont is embedded at
27//! build time: build.rs reads the FluidR3Mono_GM.sf3 file the repository
28//! keeps out of version control under assets, and the demo runs silent
29//! when it is missing.
30//!
31//! The --headless switch runs the same scene without a window or
32//! threads: the worlds are stepped at fixed rates, the run keeps its
33//! last frame and audio mix, and writes them into --out. A browser build
34//! steps the worlds the same way, because the target has no threads to
35//! put them on, and --stepped asks a windowed run to take that same path.
36
37pub mod headless;
38/// The interactive run, stepped inline: the wasm page drives this module.
39pub mod interactive;
40pub mod render;
41pub mod scene;
42
43#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
44pub mod mcp;
45
46#[cfg(target_os = "android")]
47pub mod android;
48
49use argh::FromArgs;
50use std::process::ExitCode;
51use std::sync::atomic::Ordering;
52use unlit_ecs::bundle;
53
54use crate::scene::*;
55#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
56use image::ImageEncoder;
57use unlit3d::input::winit::WinitInput;
58use unlit3d::prelude::*;
59use unlit3d::winit::builtin::{
60    ExitOn, ExitRequest, ExitTrigger, WindowSetup, request_redraw_while_foreground, window,
61    winit_input_behaviour,
62};
63use unlit3d::winit::event::WinitHost;
64// The multi-world pacing of these three differs from the single-world ones
65// the unlit3d prelude also names.
66use unlit3dx::prelude::*;
67use unlit3dx::present::{draw_presented, pump_presented, setup_presenter};
68use winit::event_loop::{ControlFlow, EventLoop};
69use winit::window::Window;
70
71/// Install the logger backend this target writes through.
72///
73/// A browser and an activity have no terminal: the browser's log goes to the
74/// console and its panics are reported there instead of unwinding into an
75/// empty page, and an activity's go to logcat.
76pub fn init_logging() {
77    #[cfg(target_arch = "wasm32")]
78    {
79        console_log::init_with_level(log::Level::Info).ok();
80        std::panic::set_hook(Box::new(console_error_panic_hook::hook));
81    }
82    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
83    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
84    #[cfg(target_os = "android")]
85    android_logger::init_once(
86        android_logger::Config::default().with_max_level(log::LevelFilter::Info),
87    );
88}
89
90/// Run the whole show: five worlds, one window.
91///
92/// The host world owns the window and the render sub-world, which serves
93/// the scene's assets; the physics, audio and gameplay worlds are remote.
94/// The scene's objects build it by requesting from the render server, jobs
95/// go out, snapshots come back, and nothing but data crosses a thread
96/// boundary. With the mcp flag, a Model Context Protocol server over
97/// stdio reads the host and its render world. A `stepped` run drives the
98/// worlds from the frame loop instead of threads of their own: that is
99/// what a browser build always does, and what a desktop run asks for to
100/// exercise the same path.
101pub fn windowed(mcp: bool, stepped: bool, event_loop: EventLoop<()>) {
102    event_loop.set_control_flow(ControlFlow::Poll);
103    let mut host = WinitHost::<()>::new();
104    let world = host.world_mut();
105
106    let input = InputWriter::new();
107    world.root_scope_mut().spawn(InputFeed(input.clone()));
108    world
109        .root_scope_mut()
110        .spawn(bundle![winit_input_behaviour(WinitInput::new(
111            input.clone()
112        )),]);
113
114    let attributes = Window::default_attributes()
115        .with_title("unlit3dx multi-world")
116        .with_inner_size(winit::dpi::LogicalSize::new(WINDOW_SIZE.0, WINDOW_SIZE.1));
117    // A browser has no window of its own to attach to: winit appends the
118    // canvas it creates to the page body.
119    #[cfg(target_arch = "wasm32")]
120    let attributes = {
121        use winit::platform::web::WindowAttributesExtWebSys;
122        attributes.with_append(true)
123    };
124    world.root_scope_mut().spawn(WindowSetup { attributes });
125    world
126        .root_scope_mut()
127        .spawn(ExitOn(ExitTrigger::CloseRequested));
128    world.root_scope_mut().spawn(ExitOn(ExitTrigger::Escape));
129    request_redraw_while_foreground();
130
131    // The render sub-world: spawned empty, on the main thread, before
132    // anyone needs it, so gameplay holds its proxy from the first tick.
133    // Its content lands once the GPU request resolves.
134    let render = spawn_sub_world(world, "render", RENDER_COMMAND_CAPACITY);
135
136    // The worlds behind the scene: threads of their own by default,
137    // stepped by the frame when the target has none to give them - a
138    // browser - or when the run asked for the stepped path.
139    // The control panel lives in the gameplay world and sends each frame to
140    // the render world, which draws it. Neither asks the other for anything.
141    let panels = PanelSlots::default();
142    let worlds = if stepped {
143        spawn_scene::<Stepped>(
144            world,
145            render.clone(),
146            stepped_audio(),
147            panels.clone(),
148            &input,
149            true,
150        )
151    } else {
152        spawn_scene::<Threaded>(
153            world,
154            render.clone(),
155            AudioSetup::Device,
156            panels.clone(),
157            &input,
158            true,
159        )
160    };
161    let gameplay_sender = worlds.gameplay.sender.clone();
162
163    // The frame's input: the host dispatches the events its own key
164    // behaviours react to. Nothing clears anything afterwards — each world and
165    // the render world's UI source read their own copy of the stream — and no
166    // other behaviour in this world reads what a key behaviour wrote, so the
167    // dispatch states no order and joins the default band.
168    world
169        .root_scope_mut()
170        .spawn(bundle![OnFrame::new(|world, _entity, _frame| {
171            dispatch_input(world);
172        }),]);
173
174    // The display the window is on paces the gameplay thread, and the
175    // frame loop reads its refresh rate every frame: it moves the window to
176    // another display, or the display changes mode, the next frame follows.
177    // A display that reports nothing leaves the rate at zero, which the
178    // gameplay world reads as "keep the default".
179    let refresh_millihertz = worlds.refresh_millihertz.clone();
180    world
181        .root_scope_mut()
182        .spawn(bundle![OnFrame::new(move |world, _entity, _frame| {
183            let rate = window(world)
184                .and_then(|window| window.current_monitor())
185                .and_then(|monitor| monitor.refresh_rate_millihertz())
186                .unwrap_or(0);
187            refresh_millihertz.store(rate, Ordering::Relaxed);
188        }),]);
189
190    // The stepped worlds run before the draw, so the transforms they
191    // publish are the ones this frame shows.
192    if stepped {
193        let stepper = SceneStepper::new(PHYSICS_HZ, None);
194        world
195            .root_scope_mut()
196            .spawn(bundle![stepped_frame(worlds, stepper)]);
197    }
198
199    // The app entity and the behaviours that drive it. The GPU request
200    // resolves into the scene setup, which fills the render sub-world and
201    // builds the presentation path; from then on the pump and the redraw
202    // requests dispatch frames into it.
203    let app = world.root_scope_mut().spawn(bundle![ExitRequest(false)]);
204    let render_entity = render.entity;
205    world.root_scope_mut().spawn(bundle![request_gpu(app, {
206        // Only an MCP-driven scene needs the offscreen target: it is what
207        // a screenshot reads back. A plain window draws into the swap chain.
208        let offscreen = mcp;
209        move |world, gpu| {
210            let (renderer, pass) = setup_render_world(
211                world,
212                render.entity,
213                &gpu.device,
214                &gpu.queue,
215                gpu.capabilities,
216            );
217            // The scene's own content — its camera, and the source that draws
218            // whatever the gameplay world publishes — reaches the world it was
219            // set up to draw as jobs. The panels themselves never leave the
220            // world that holds them; what crosses is the slots they publish
221            // into.
222            mount_scene_content(&render, pass, Some(panels.clone()));
223            let outcome = setup_presenter(
224                world,
225                render.entity,
226                renderer.0,
227                pass.0,
228                gpu,
229                WINDOW_SIZE,
230                offscreen,
231            );
232            if let Err(error) = outcome {
233                log::error!("failed to build the presentation path: {error}");
234            } else {
235                log::info!("the scene is set up");
236            }
237            #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
238            if mcp {
239                serve_mcp(world);
240            }
241        }
242    }),]);
243    world
244        .root_scope_mut()
245        .spawn(bundle![draw_presented(render_entity)]);
246    let (pump, redraw) = pump_presented();
247    world.root_scope_mut().spawn(bundle![pump]);
248    world.root_scope_mut().spawn(bundle![redraw]);
249    world.root_scope_mut().spawn(bundle![resize_presented()]);
250
251    // Space drops a ball; the music and the balls are driven from the
252    // render world's control panel.
253    world
254        .root_scope_mut()
255        .spawn(bundle![OnKey::new(move |_world, _entity, key| {
256            if !key.pressed || key.repeat {
257                return;
258            }
259            if key.key == Key::Space {
260                drop_ball(&gameplay_sender);
261            }
262        },),]);
263    host.run(event_loop).expect("the event loop runs");
264}
265
266/// Step the scene's worlds once per frame, before the draw.
267///
268/// A stepped run has no threads, so the host steps what a threaded build
269/// would leave to them. The stepper follows the frame's own duration and
270/// carries the step remainders, so the worlds keep their rates whatever
271/// the display redraws at.
272fn stepped_frame(worlds: SceneHandles, mut stepper: SceneStepper) -> OnFrame {
273    OnFrame::new(move |world, _entity, frame| {
274        stepper.step(world, &worlds, f64::from(frame.delta_time));
275    })
276}
277
278/// The audio setup a windowed run takes when the host steps the worlds.
279///
280/// A browser's stepped world opens the page's Web Audio context once the
281/// user's first click on Play asks for it: a context may only start inside
282/// a gesture, so the scene stays silent until then even though the page
283/// has been up the whole time. A desktop run that asked for the stepped
284/// path has nothing to play to and stays silent.
285fn stepped_audio() -> AudioSetup {
286    if cfg!(target_arch = "wasm32") {
287        AudioSetup::Device
288    } else {
289        AudioSetup::Silent
290    }
291}
292
293/// The example's command line: switches for the Model Context Protocol,
294/// the headless render and the stepped runner.
295#[derive(argh::FromArgs)]
296pub struct Args {
297    /// serve the worlds over the Model Context Protocol
298    #[argh(switch)]
299    mcp: bool,
300
301    /// drive the worlds from the frame loop instead of threads of their
302    /// own, the path a browser build always takes
303    #[argh(switch)]
304    stepped: bool,
305
306    /// render headless instead of opening a window: the run's last frame
307    /// and audio mix land in --out, then the process exits
308    #[cfg_attr(
309        any(target_arch = "wasm32", target_os = "android"),
310        expect(
311            dead_code,
312            reason = "only the headless run reads it, and only the host build has one"
313        )
314    )]
315    #[argh(switch)]
316    headless: bool,
317
318    /// how many frames the headless render runs, at its fixed 60 Hz
319    #[cfg_attr(
320        any(target_arch = "wasm32", target_os = "android"),
321        expect(
322            dead_code,
323            reason = "only the headless run reads it, and only the host build has one"
324        )
325    )]
326    #[argh(option, default = "300")]
327    frames: u32,
328
329    /// the directory the headless render writes its outputs into
330    #[cfg_attr(
331        any(target_arch = "wasm32", target_os = "android"),
332        expect(
333            dead_code,
334            reason = "only the headless run reads it, and only the host build has one"
335        )
336    )]
337    #[argh(option, default = "\"headless\".to_owned()")]
338    out: String,
339}
340
341/// Read the command line into [`Args`].
342///
343/// A browser has no command line: `std::env::args()` is empty there, and
344/// argh refuses to parse without a program name. Supplying one leaves every
345/// option at its default, which is what a browser build runs with.
346fn parse_args() -> Result<Args, String> {
347    let mut raw: Vec<String> = std::env::args().collect();
348    if raw.is_empty() {
349        raw.push(env!("CARGO_PKG_NAME").to_owned());
350    }
351    let refs: Vec<&str> = raw.iter().map(String::as_str).collect();
352    match Args::from_args(&refs[..1], &refs[1..]) {
353        Ok(args) => Ok(args),
354        // argh reports `--help` as an early exit whose status is `Ok`, and a
355        // failed parse as one whose status is `Err`; only the text is useful
356        // either way.
357        Err(exit) if exit.status.is_ok() => {
358            log::info!("{}", exit.output);
359            std::process::exit(0);
360        }
361        Err(exit) => Err(exit.output),
362    }
363}
364
365/// Run the example the binary was started as.
366pub fn run() -> ExitCode {
367    init_logging();
368    let args = match parse_args() {
369        Ok(args) => args,
370        Err(message) => {
371            log::error!("the command line could not be read: {message}");
372            return ExitCode::FAILURE;
373        }
374    };
375    #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
376    if args.headless {
377        if args.mcp {
378            mcp::run(unlit3dx_asset_bytes::soundfont().into());
379            return ExitCode::SUCCESS;
380        }
381        return run_headless(&args);
382    }
383    let event_loop = EventLoop::<()>::with_user_event()
384        .build()
385        .expect("an event loop");
386    // A browser has no threads to give the worlds, so it steps them
387    // whatever the command line says; the switch is how a desktop run
388    // reaches that same path.
389    windowed(
390        args.mcp,
391        args.stepped || cfg!(target_arch = "wasm32"),
392        event_loop,
393    );
394    ExitCode::SUCCESS
395}
396
397/// Render the scene headless and write the run's outputs.
398///
399/// The headless run has no window, no threads and no audio output: the
400/// same worlds run inline on this thread, stepped at their fixed rates,
401/// and the run's last frame and audio mix are written as files.
402#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
403fn run_headless(args: &Args) -> ExitCode {
404    use crate::headless::AUDIO_CHANNELS;
405
406    if args.frames == 0 {
407        log::error!("--frames must be at least 1");
408        return ExitCode::FAILURE;
409    }
410    let gpu = match pollster::block_on(Gpu::headless()) {
411        Ok(gpu) => gpu,
412        Err(error) => {
413            log::error!("the headless run could not open a GPU: {error}");
414            return ExitCode::FAILURE;
415        }
416    };
417    let config = headless::HeadlessConfig {
418        size: WINDOW_SIZE,
419        frames: args.frames,
420        capture: vec![args.frames - 1],
421        audio_sample_rate: headless::AUDIO_SAMPLE_RATE,
422        soundfont: unlit3dx_asset_bytes::soundfont().into(),
423    };
424    let output = headless::run(&config, &gpu.device, &gpu.queue, gpu.capabilities);
425    let out = std::path::Path::new(&args.out);
426    if let Err(error) = std::fs::create_dir_all(out) {
427        log::error!("could not create {}: {error}", out.display());
428        return ExitCode::FAILURE;
429    }
430    let png_path = out.join("screenshot.png");
431    let mut png = Vec::new();
432    if let Err(error) = image::codecs::png::PngEncoder::new(&mut png).write_image(
433        &output.frames[0],
434        config.size.0,
435        config.size.1,
436        image::ExtendedColorType::Rgba8,
437    ) {
438        log::error!("could not encode the screenshot: {error}");
439        return ExitCode::FAILURE;
440    }
441    let flac_path = out.join("audio.flac");
442    let format = AudioFormat {
443        sample_rate: config.audio_sample_rate,
444        channels: AUDIO_CHANNELS as u16,
445    };
446    let flac = match RecordingFormat::Flac.encode(format, &output.audio) {
447        Ok(flac) => flac,
448        Err(error) => {
449            log::error!("could not encode the audio: {error}");
450            return ExitCode::FAILURE;
451        }
452    };
453    if let Err(error) = std::fs::write(&flac_path, flac) {
454        log::error!("could not write {}: {error}", flac_path.display());
455        return ExitCode::FAILURE;
456    }
457    if let Err(error) = std::fs::write(&png_path, png) {
458        log::error!("could not write {}: {error}", png_path.display());
459        return ExitCode::FAILURE;
460    }
461    log::info!(
462        "the headless run wrote {} and {}",
463        png_path.display(),
464        flac_path.display()
465    );
466    ExitCode::SUCCESS
467}