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}