unlit3d_examples/lib.rs
1#![doc = include_str!("../README.md")]
2#![warn(unsafe_code)]
3
4pub mod scenes;
5
6mod cli;
7
8#[cfg(target_os = "android")]
9pub mod android;
10
11/// The fullscreen button, and the landscape lock that only a browser permits.
12mod fullscreen;
13
14use std::collections::VecDeque;
15use std::io::Write;
16use std::process::ExitCode;
17use unlit_ecs::{Bundle, DynamicBundle, bundle};
18
19use cli::{Args, Parsed};
20use scenes::{Advance, SceneControl};
21use unlit_wgpu::resources::ResourceGraph;
22use unlit_wgpu::scene::ViewportRect;
23use unlit3d::frame::Frame;
24use unlit3d::input::winit::WinitInput;
25use unlit3d::prelude::*;
26use unlit3d::ui::{UiSource, egui};
27use unlit3d::winit::builtin::{
28 DisplayHandle, ExitOn, ExitRequest, ExitTrigger, Resumed, WindowSetup, WinitProxy,
29 request_redraw_while_foreground, window, winit_input_behaviour,
30};
31#[cfg(target_os = "android")]
32use unlit3d::winit::event::OnSuspended;
33use unlit3d::winit::event::{OnUserEvent, OnWindowEvent, WinitHost};
34use unlit3d::winit::presenter::SCENE_PASS_ORDER;
35use winit::event::WindowEvent;
36use winit::event_loop::{ControlFlow, EventLoop, EventLoopProxy};
37use winit::window::Window;
38
39/// The number of samples the windowed loop presents every frame with.
40const SAMPLE_COUNT: u32 = scenes::spin_cube::SAMPLE_COUNT;
41
42/// The timestep a scene is stepped by when it is drawn without a display.
43///
44/// A windowed run advances by how long its last frame took, so its pace depends
45/// on the machine. Drawing to compare against a stored frame has to be
46/// reproducible instead, and this is the fixed step that makes it so.
47pub const FIXED_STEP: f32 = 1.0 / 60.0;
48
49/// The order the scene and its presentation path come up at.
50///
51/// It is the one that sets the frame's skip flag, so it comes before every
52/// reader of the flag: a behaviour that reads it states a later order rather
53/// than leaving the two to chance.
54const READY_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(-40);
55
56/// The order the controls run at: after `ensure_presented`, which may have
57/// skipped the frame, and first of the readers that act on the flag, since
58/// switching a scene skips the frame for the two after them.
59const CONTROLS_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(-30);
60
61/// The order the scene advances at: after the controls, which may have skipped
62/// the frame, and before the present, which draws what the advance produced.
63const ADVANCE_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(-20);
64
65/// The order the scene's region is restated at: after the advance, and before
66/// the library's present, which draws the pass the region belongs to.
67const VIEWPORT_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(0);
68
69/// The order the scene world's queued commands are applied at: after the
70/// library's present, whose render is what queued them.
71const FLUSH_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(20);
72
73/// The order the scene world advances its scene at: after `step_sequence`,
74/// which decides whether this frame advances and records the decision for the
75/// behaviour that runs it. The value shares nothing with the host world's
76/// orders: the two worlds dispatch separately.
77const SCENE_ADVANCE_ORDER: FrameBehaviourOrder = FrameBehaviourOrder(20);
78
79/// Whether this build can serve the Model Context Protocol.
80///
81/// It needs the stdio transport and a tokio runtime, neither of which a
82/// browser or an activity has; the switch is parsed everywhere but only acts
83/// where this is true.
84const MCP_SUPPORTED: bool = cfg!(all(not(target_arch = "wasm32"), not(target_os = "android")));
85
86/// Write `text` to standard output.
87///
88/// Through [`std::io::Write`] rather than `print!`, which the workspace lints
89/// against: a command line that prints its usage is not a reason to relax them.
90fn stdout(text: &str) {
91 let _ = std::io::stdout().write_all(text.as_bytes());
92}
93
94/// Write `text` to standard error.
95fn stderr(text: &str) {
96 let _ = std::io::stderr().write_all(text.as_bytes());
97}
98
99/// Run `future` concurrently: on a worker thread on native, in the browser's
100/// task queue on the web (where a thread cannot be blocked).
101#[cfg(not(target_arch = "wasm32"))]
102fn spawn(future: impl core::future::Future<Output = ()> + Send + 'static) {
103 std::thread::spawn(move || pollster::block_on(future));
104}
105
106/// Run `future` concurrently: on a worker thread on native, in the browser's
107/// task queue on the web (where a thread cannot be blocked).
108#[cfg(target_arch = "wasm32")]
109fn spawn(future: impl core::future::Future<Output = ()> + 'static) {
110 wasm_bindgen_futures::spawn_local(future);
111}
112
113/// Install the logger backend and the panic hook the example reports through.
114///
115/// The backend follows the terminal the platform has: `env_logger` writes to
116/// the standard error a command line owns, logcat takes an activity's, and the
117/// browser console takes a page's. `RUST_LOG` selects the level where there is
118/// an environment to read it from, and `info` is the default, so the example's
119/// own startup messages are visible without a variable. A panic surfaces as an
120/// opaque `unreachable executed` on the web unless the hook logs it with its
121/// stack first.
122fn init_logging() {
123 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
124 env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
125 .try_init()
126 .ok();
127 #[cfg(target_os = "android")]
128 // The system starts an activity rather than a shell, so there is no
129 // environment to read a level from and the desktop default stands.
130 android_logger::init_once(
131 android_logger::Config::default().with_max_level(log::LevelFilter::Info),
132 );
133 #[cfg(target_arch = "wasm32")]
134 {
135 console_log::init_with_level(log::Level::Info).ok();
136 std::panic::set_hook(Box::new(console_error_panic_hook::hook));
137 }
138}
139
140/// Run the example from the command line.
141///
142/// Returns the process exit code: the snapshot tests report their result
143/// through it, so a CI run sees a mismatched snapshot as a failed command.
144///
145/// Android never comes through here — its activity has no command line and
146/// enters at the module that entry point lives in instead — but the function
147/// stays part of the library so the two entry points differ in as little as
148/// possible.
149pub fn run() -> ExitCode {
150 init_logging();
151
152 let args = match cli::parse(std::env::args().skip(1)) {
153 Ok(Parsed::Help(help)) => {
154 stdout(&help);
155 return ExitCode::SUCCESS;
156 }
157 Ok(Parsed::Run(args)) => args,
158 Err(error) => {
159 stderr(&format!("error: {error}\n\n{}", cli::usage()));
160 return ExitCode::from(2);
161 }
162 };
163
164 if args.list_scenes {
165 stdout(&scenes::list_text());
166 return ExitCode::SUCCESS;
167 }
168
169 let event_loop = EventLoop::<UserEvent>::with_user_event()
170 .build()
171 .expect("an event loop");
172 windowed(args, event_loop);
173 ExitCode::SUCCESS
174}
175
176/// Drive the windowed example on `event_loop` until it exits.
177///
178/// The loop is built by the caller, because that is the one thing the entry
179/// points differ on: Android's loop has to be handed the activity it runs in,
180/// which only the activity's own entry point has.
181fn windowed(args: Args, event_loop: EventLoop<UserEvent>) {
182 // Poll rather than wait: the scene animates every frame.
183 event_loop.set_control_flow(ControlFlow::Poll);
184 let scene = scenes::by_id(&args.scene).expect("validated by the CLI");
185 let proxy = event_loop.create_proxy();
186
187 let mut host = WinitHost::<UserEvent>::new();
188 let world = host.world_mut();
189
190 // One writer, shared: the host world carries it and the adapter that
191 // feeds it, and every nested world the host reaches gets a clone of it.
192 // Each world then builds its own buffer from the same stream.
193 let input = InputWriter::new();
194 // The writer is read by every source, so it lives in the shared subtree.
195 let shared = world.shared();
196 world
197 .scope_mut(shared)
198 .expect("the shared scope exists")
199 .spawn(bundle![input.clone()]);
200 world
201 .root_scope_mut()
202 .spawn(bundle![winit_input_behaviour(WinitInput::new(input))]);
203
204 // The window is requested at the first resume, not created here: a
205 // platform may not allow a window before then.
206 let initial_size = args.size.unwrap_or(scene.size);
207 #[cfg_attr(
208 not(target_arch = "wasm32"),
209 expect(unused_mut, reason = "wasm32 reassigns to append the canvas")
210 )]
211 let mut attributes = Window::default_attributes()
212 .with_title("unlit3d + winit")
213 .with_inner_size(winit::dpi::LogicalSize::new(initial_size.0, initial_size.1));
214 #[cfg(target_arch = "wasm32")]
215 {
216 use winit::platform::web::WindowAttributesExtWebSys;
217 attributes = attributes.with_append(true);
218 }
219 world.root_scope_mut().spawn(WindowSetup { attributes });
220
221 world
222 .root_scope_mut()
223 .spawn(ExitOn(ExitTrigger::CloseRequested));
224 world.root_scope_mut().spawn(ExitOn(ExitTrigger::Escape));
225 world
226 .root_scope_mut()
227 .spawn(bundle![request_redraw_while_foreground()]);
228
229 // The application's state and its per-callback behaviours share one
230 // entity: every component is its own cell, so a callback writing a sibling
231 // state component never conflicts with the behaviour being run.
232 let app = world.root_scope_mut().spawn(App {
233 scene,
234 proxy,
235 mcp: args.mcp && MCP_SUPPORTED,
236 });
237 // The frame's own behaviours. Requesting the GPU states no order: nothing
238 // reads what it writes in the same frame. The rest state one, chained from
239 // the behaviour that brings the scene up and decides whether the frame runs
240 // at all, through the readers of that decision, to the library's present
241 // and the flush that follows it.
242 world.root_scope_mut().spawn(bundle![request_gpu(app)]);
243 world.root_scope_mut().spawn(bundle![ensure_presented(app)]);
244 world
245 .root_scope_mut()
246 .spawn(bundle![serve_scene_controls(app)]);
247 world.root_scope_mut().spawn(bundle![advance_scene(app)]);
248 world.root_scope_mut().spawn(bundle![restate_viewport(app)]);
249 world.root_scope_mut().spawn(bundle![present_frames()]);
250 world.root_scope_mut().spawn(bundle![flush_scene(app)]);
251 let (pump, redraw) = pump_presented();
252 world.root_scope_mut().spawn(bundle![pump]);
253 world.root_scope_mut().spawn(bundle![redraw]);
254 // A resize is a second `OnWindowEvent` behaviour, so it sits on its own
255 // entity: two of one family on an entity would be two borrows of one cell.
256 world.root_scope_mut().spawn(bundle![resize_scene(app)]);
257 // Serving a user event needs the proxy and the app's state, so it captures
258 // the app entity; it is a family of its own, so it sits on its own entity.
259 world
260 .root_scope_mut()
261 .spawn(bundle![handle_user_event(app)]);
262 #[cfg(target_os = "android")]
263 world
264 .root_scope_mut()
265 .spawn(bundle![release_on_suspend(app)]);
266
267 host.run(event_loop).expect("the event loop runs");
268}
269
270/// Events delivered to the winit loop from outside a `WindowEvent`.
271enum UserEvent {
272 /// The async GPU setup finished; the scene is built from it here, on the
273 /// main thread.
274 Ready(Gpu),
275 /// The async GPU setup failed; the message is reported and the app exits.
276 Failed(String),
277 /// A command from the MCP transport, to run against a world of the host.
278 ///
279 /// It arrives on this thread because a world is not `Send`; the app
280 /// resolves the command's world handle and dispatches it here, between
281 /// frames, so no command ever runs while a frame is being drawn.
282 ///
283 /// The command is wrapped in an `Option` because a user event reaches its
284 /// callbacks mutably and the one that runs it takes it out.
285 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
286 Command(Option<unlit_ecs::mcp::McpCommand>),
287}
288
289/// The GPU context a scene draws with, requested asynchronously.
290///
291/// Everything here is a `Send + Sync` wgpu handle, so the request can run off
292/// the event loop's thread and the context can cross back to it. The scene
293/// itself cannot cross: its ECS world is single-threaded, so it is built on
294/// the main thread once this arrives.
295///
296/// The context and its device outlive a suspension: rebuilding them would drop
297/// every pipeline, buffer and texture the scene holds, for a change the
298/// platform only makes to the native window.
299#[derive(Clone)]
300struct Gpu {
301 instance: wgpu::Instance,
302 adapter: wgpu::Adapter,
303 device: wgpu::Device,
304 queue: wgpu::Queue,
305 /// What the device can do beyond the WebGPU baseline, captured while the
306 /// adapter is still here to report it.
307 ///
308 /// `wgpu::Device` exposes its limits and features but not the downlevel
309 /// flags, and the adapter is not kept once the device exists, so this is
310 /// read once and carried; see [`DeviceCapabilities`].
311 capabilities: DeviceCapabilities,
312}
313
314impl Gpu {
315 /// Request the adapter and device that can present to `surface`.
316 ///
317 /// `surface` is a *probe*: it exists only so the adapter is required to be
318 /// able to present to the window, and is dropped again before the device
319 /// is requested. The context deliberately ends up owning no surface,
320 /// because a surface is tied to the native window it was created from —
321 /// Android replaces that window across a suspension — while the adapter
322 /// and device are not. Whoever presents builds one for the window it has
323 /// at that moment.
324 ///
325 /// Async because both requests resolve on the browser's task queue on the
326 /// web; blocking on them there would hang the page.
327 async fn request(
328 instance: wgpu::Instance,
329 surface: wgpu::Surface<'static>,
330 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
331 let adapter = instance
332 .request_adapter(&wgpu::RequestAdapterOptions {
333 power_preference: wgpu::PowerPreference::HighPerformance,
334 compatible_surface: Some(&surface),
335 ..Default::default()
336 })
337 .await?;
338 drop(surface);
339
340 // The tier is what decides how much of the WebGPU baseline the device
341 // is asked for, and so which paths it takes. `UNLIT3D_DEVICE_TIER`
342 // selects it — the WebGPU baseline by default, WebGL2's shape when
343 // asked for; see [`DeviceTier`].
344 let tier = DeviceTier::from_env();
345 let capabilities = tier.capabilities_of(&adapter);
346 let (device, queue) = adapter
347 .request_device(&wgpu::DeviceDescriptor {
348 required_limits: tier.limits(&adapter.limits()),
349 ..Default::default()
350 })
351 .await?;
352 Ok(Self {
353 instance,
354 adapter,
355 device,
356 queue,
357 capabilities,
358 })
359 }
360}
361
362/// Where the one-time GPU setup stands.
363///
364/// The request is asynchronous, so a resume that arrives before it lands must
365/// not start a second one — two devices and two adapters would be built, and
366/// only one of them could present. The `Requested` state is what remembers
367/// that the setup is already in flight.
368#[derive(Default)]
369enum GpuState {
370 /// No request has been made: the loop is before its first resume, or the
371 /// first one failed and the app is exiting.
372 #[default]
373 Idle,
374 /// The request is in flight; `user_event` carries its result.
375 Requested,
376 /// The context is here and the scene can draw with it.
377 Ready(Gpu),
378}
379
380/// The scene's world and the frame loop that drives it.
381///
382/// Deliberately knows nothing about how a frame reaches a display: it advances
383/// the scene's behaviour and renders into whatever target is bound, so the
384/// windowed run and the snapshot tests differ only in what they bind and what
385/// with the result. The scene itself is a [`scenes::SceneDef`] whose build
386/// populated the world and returned the [`SceneControl`] this drives.
387///
388/// Public because the snapshot tests drive the same scenes offscreen: the web
389/// renders them and compares each frame against its snapshot, which is the
390/// comparison they make.
391///
392/// The world is *not* owned here: a scene is the driving state of a world the
393/// caller owns, and every frame method takes that world back as `&mut World`.
394/// A windowed run stores the world as a component of its host world and pulls
395/// it out per frame; the snapshot tests keep a local one. Either way one world
396/// has exactly one scene driving it, and the scene never reaches across to
397/// another.
398pub struct Scene {
399 /// The renderer resource entity: the handle every renderer access goes
400 /// through.
401 pub renderer: Entity,
402 /// The size the scene's content is drawn at, in pixels: the target's own,
403 /// or the letterboxed part of it when [`Self::viewport`] is set.
404 ///
405 /// This is what the scene's camera is built for and what its behaviour is
406 /// handed, so the projection matches what the viewport scales into place.
407 size: (u32, u32),
408 /// The region of the target the 3D content is drawn into, or `None` to draw
409 /// into the whole of it.
410 ///
411 /// Set when the scene declares an aspect of its own — see
412 /// [`scenes::SceneDef::baseline`] — so a target of any shape shows the same
413 /// picture at a different scale rather than showing more of it.
414 pub viewport: Option<ViewportRect>,
415 /// The frame pass the scene's own sources record into.
416 ///
417 /// Everything a scene builds hangs under it, which is what puts the scene
418 /// in the frame: a pass records the sources in its own scope, so the pass a
419 /// source's entity sits under is the pass its scene is drawn in.
420 pub pass: Entity,
421 /// The snapshot the scene's test compares each frame against, if any.
422 ///
423 /// The scene's per-frame behaviour is not here: it was moved into the
424 /// scene's own world as a frame behaviour at build time, so a frame
425 /// advances it without `&mut Scene`.
426 pub snapshot: scenes::Snapshot,
427 /// The frame-rate measurement the windowed shell's readout displays.
428 ///
429 /// Unused by the snapshot tests, which have no display to report a rate on.
430 frame_rate: Entity,
431 /// The index of the next frame the scene's behaviour advances from.
432 ///
433 /// Carried across calls because [`Self::advance`] hands it to the frame
434 /// behaviours through the frame's context, and a fixed sequence may skip a
435 /// frame without advancing it.
436 frame: u32,
437 /// The scene-selector's switch component, read once per frame.
438 switch: Entity,
439 /// The fullscreen button's request component, read once per frame.
440 fullscreen: Entity,
441 /// The window's fullscreen state, written once per frame for the button to
442 /// label itself from: a panel runs with no window to ask.
443 fullscreen_active: Entity,
444 /// A scene switch requested by the selector, consumed by the frame loop.
445 pending: Option<&'static scenes::SceneDef>,
446}
447
448/// Set by the windowed shell's scene-selector panel, read once by the frame
449/// loop.
450struct SceneSwitch(Option<&'static scenes::SceneDef>);
451
452/// Set by the windowed shell's fullscreen button, read once by the frame loop.
453///
454/// A flag rather than the state to move to: the button asks for the window to
455/// change, and which way it changes is read from the window itself at the
456/// moment the request is served. A panel runs while the world is borrowed to
457/// lay the UI out, so it can only record the click; the frame loop owns the
458/// window the request is served on. See [`fullscreen`].
459struct FullscreenRequest(bool);
460
461/// Whether the window is fullscreen, written once per frame by the frame loop.
462///
463/// The button labels itself for the action it would take, and a panel has no
464/// window to ask, so the frame loop reads the window's state and leaves it here
465/// for the panel to draw from.
466struct FullscreenActive(bool);
467
468/// The host entity carrying the scene's world and its [`Scene`].
469///
470/// `None` until the GPU context arrives. The world itself outlives a
471/// suspension — only the swap chain it is presented through does not — so the
472/// scene's state survives one.
473struct SceneEntity(Option<Entity>);
474
475/// The render world an application presents through, under the name it reports
476/// to the MCP tools.
477///
478/// A world is reachable from the host only as a component of an entity, and the
479/// name is what the report lists it by: spawning the two together is what makes
480/// the scene findable at all.
481struct RenderWorld {
482 /// The world the scene draws in.
483 world: World,
484 /// The scene driving it.
485 scene: Scene,
486 /// The name the MCP tools list it under.
487 name: &'static str,
488}
489
490impl Bundle for RenderWorld {
491 fn name(&self) -> &'static str {
492 self.name
493 }
494
495 fn into_bundle(self) -> DynamicBundle {
496 bundle![self.world, self.scene, WorldName(self.name)]
497 }
498}
499
500/// The name this example reports a host world under to the MCP tools.
501///
502/// The example keeps its own component rather than borrowing the core's: it
503/// does not depend on unlit3dx, and a name is all the report needs. Targets
504/// without the MCP dependency never read it back.
505#[cfg_attr(
506 any(target_arch = "wasm32", target_os = "android"),
507 expect(
508 dead_code,
509 reason = "only the MCP bridge reads the name back, and it is not built for these targets"
510 )
511)]
512struct WorldName(&'static str);
513
514/// The entity carrying the presentation path, absent until the scene and
515/// surface are ready.
516///
517/// The path is its own entity rather than a component of the app because the
518/// library's frame behaviours find it by querying for a [Presenter]; dropping
519/// the entity is how a scene switch gives the path up.
520struct PresenterEntity(Option<Entity>);
521
522/// The scene the example starts with; a switch replaces it.
523struct InitialScene(&'static scenes::SceneDef);
524
525/// The selector window's rectangle the last time a scene was live, so a scene
526/// switch can rebuild it in the same place and at the same size.
527struct SelectorPos(Option<egui::Rect>);
528
529/// The GPU-info panel's rectangle the last time a scene was live, so a scene
530/// switch can rebuild it where the user left it.
531struct GpuInfoPos(Option<egui::Rect>);
532
533/// The application's state, spawned as one object.
534///
535/// Every component is its own cell, so a callback writing a sibling state
536/// component never conflicts with the behaviour being run. The behaviours are
537/// spawned beside this entity rather than in it, each handed the handle back, so
538/// a callback reaches the state it reads and the proxy it posts its own events
539/// through from one place.
540struct App {
541 /// The scene the application starts with.
542 scene: &'static scenes::SceneDef,
543 /// The proxy a callback posts an event to the loop it runs on with.
544 proxy: EventLoopProxy<UserEvent>,
545 /// Whether to serve the world over the Model Context Protocol.
546 mcp: bool,
547}
548
549impl Bundle for App {
550 fn into_bundle(self) -> DynamicBundle {
551 bundle![
552 GpuState::Idle,
553 SceneEntity(None),
554 PresenterEntity(None),
555 InitialScene(self.scene),
556 SelectorPos(None),
557 GpuInfoPos(None),
558 McpEnabled(self.mcp),
559 ExitRequest(false),
560 WinitProxy(self.proxy),
561 FrameSkip::default(),
562 ]
563 }
564}
565
566/// Where the scene's draggable panels opened, carried from the scene a switch
567/// replaced.
568///
569/// The two rectangles travel together because they are one idea: each is the
570/// position egui remembered for a panel, read from the dying scene's context
571/// and handed to the new scene's. A scene built without a carried position
572/// opens its panels in their default corners.
573#[derive(Clone, Copy, Default)]
574pub struct PanelRects {
575 /// The scene-selector window's last rectangle.
576 pub selector: Option<egui::Rect>,
577 /// The GPU-info panel's last rectangle.
578 pub gpu_info: Option<egui::Rect>,
579}
580
581impl PanelRects {
582 /// The panels' rectangles as the app entity remembers them.
583 fn read(world: &World, app: Entity) -> Self {
584 Self {
585 selector: world
586 .root_scope()
587 .query_one::<&SelectorPos>(app)
588 .ok()
589 .and_then(|mut pos| pos.get().0),
590 gpu_info: world
591 .root_scope()
592 .query_one::<&GpuInfoPos>(app)
593 .ok()
594 .and_then(|mut pos| pos.get().0),
595 }
596 }
597}
598
599/// Whether to serve the world over the Model Context Protocol rather than
600/// drive a scene on this thread.
601///
602/// False on the web and on Android, which have no stdio transport; see
603/// [`MCP_SUPPORTED`].
604struct McpEnabled(bool);
605
606/// The interval a scene's index advances at, in seconds, or `None` to advance
607/// it once per drawn frame.
608struct SequenceStep(Option<f32>);
609
610/// Seconds accumulated towards the next advance of a scene's index.
611struct SequenceClock(f32);
612
613/// Whether this frame advanced the scene's index, decided by the sequence step
614/// and read by the behaviour that runs the scene's own advance.
615struct SequenceStepped(bool);
616
617/// A scene's own per-frame behaviour, stored in its world so the frame
618/// behaviour that drives it can call it with the world it mutates.
619///
620/// `None` only while it is being called: the closure needs `&mut World`, and a
621/// component cannot be borrowed mutably and handed the world it lives in at the
622/// same time, so it is taken out, called, and put back.
623struct SceneAdvance(Option<Advance>);
624
625/// The scene's per-frame state, spawned with its own advance.
626///
627/// The state and the behaviour that moves it live on one entity so a frame
628/// advances the scene without `&mut Scene`: the index the sequence step keeps
629/// and the clock it accumulates into are read by the advance beside them, and
630/// neither half means anything without the other.
631struct SceneFrame {
632 /// The scene's own per-frame behaviour.
633 advance: Advance,
634 /// The interval the scene's index advances at, or `None` to advance it once
635 /// per drawn frame.
636 step: Option<f32>,
637}
638
639impl Bundle for SceneFrame {
640 fn into_bundle(self) -> DynamicBundle {
641 bundle![
642 SceneAdvance(Some(self.advance)),
643 SequenceStep(self.step),
644 SequenceClock(0.0),
645 SequenceStepped(true),
646 ]
647 }
648}
649
650/// Run the scene's own per-frame behaviour, which needs `&mut World`.
651struct RunSceneAdvance {
652 /// The entity carrying the scene's [`SceneAdvance`] and sequence state.
653 entity: Entity,
654 /// The frame the behaviour is advanced by.
655 frame: Frame,
656}
657
658impl Command for RunSceneAdvance {
659 fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
660 let Self { entity, frame } = *self;
661 let advance = world
662 .root_scope()
663 .query_one::<&mut SceneAdvance>(entity)
664 .ok()
665 .and_then(|mut slot| slot.get().0.take());
666 let Some(mut advance) = advance else {
667 return Ok(());
668 };
669 advance(world, frame.index, frame.delta_time, frame.size);
670 let _ = world
671 .root_scope()
672 .query_one::<&mut SceneAdvance>(entity)
673 .map(|mut slot| slot.get().0 = Some(advance));
674 Ok(())
675 }
676}
677
678/// Run one MCP command against a nested world of the host.
679#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
680struct RunInWorld {
681 /// The host entity carrying the world the command names.
682 entity: Entity,
683 /// The command to run.
684 command: unlit_ecs::mcp::McpCommand,
685}
686
687#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
688impl Command for RunInWorld {
689 fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
690 let Self { entity, command } = *self;
691 let Ok(mut nested) = world.root_scope().query_one::<&mut World>(entity) else {
692 command.fail(format!("host entity {entity:?} carries no world"));
693 return Ok(());
694 };
695 command.dispatch(&mut *nested.get());
696 Ok(())
697 }
698}
699
700/// Run one MCP command against the host world itself.
701#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
702struct RunInHost {
703 /// The command to run.
704 command: unlit_ecs::mcp::McpCommand,
705}
706
707#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
708impl Command for RunInHost {
709 fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
710 self.command.dispatch(world);
711 Ok(())
712 }
713}
714
715/// The GPU context, once the asynchronous setup has landed.
716fn gpu(world: &World, app: Entity) -> Option<Gpu> {
717 let mut state = world.root_scope().query_one::<&GpuState>(app).ok()?;
718 match state.get() {
719 GpuState::Ready(gpu) => Some(gpu.clone()),
720 GpuState::Idle | GpuState::Requested => None,
721 }
722}
723
724/// Build the presentation path once the scene's queued world has landed.
725///
726/// The window is created before the GPU request, so the raw surface can travel
727/// through the asynchronous setup. `BuildPresenter` creates the library surface
728/// from that raw handle on the main thread, after the scene world exists.
729struct BuildPresenter {
730 app: Entity,
731 scene: Entity,
732 renderer: Entity,
733 pass: Entity,
734 gpu: Gpu,
735 size: (u32, u32),
736 surface: wgpu::Surface<'static>,
737 path: PresentationPath,
738}
739
740impl Command for BuildPresenter {
741 fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
742 let presenter = match build_presenter(
743 world,
744 Some(self.scene),
745 self.renderer,
746 self.pass,
747 self.surface,
748 &self.gpu.instance,
749 &self.gpu.adapter,
750 self.size,
751 self.path,
752 ) {
753 Ok(presenter) => presenter,
754 Err(error) => {
755 log::error!("failed to build the presentation path: {error}");
756 return Ok(());
757 }
758 };
759 let entity = world.root_scope_mut().spawn(bundle![presenter]);
760 world
761 .root_scope()
762 .query_one::<&mut PresenterEntity>(self.app)?
763 .get()
764 .0 = Some(entity);
765 Ok(())
766 }
767}
768
769/// Bring the scene and its presentation path up, as far as the state allows.
770///
771/// Spawning the scene world and building its surface both need `&mut World`,
772/// so they are queued. The next frame finishes whichever stage has landed.
773fn present(world: &World, app: Entity) {
774 let Some(gpu) = gpu(world, app) else {
775 return;
776 };
777 let Some(window) = window(world) else {
778 return;
779 };
780 let window_size = window.inner_size();
781 let size = (window_size.width.max(1), window_size.height.max(1));
782 let def = world
783 .root_scope()
784 .query_one::<&InitialScene>(app)
785 .expect("the app entity carries its initial scene")
786 .get()
787 .0;
788 let mcp = world
789 .root_scope()
790 .query_one::<&McpEnabled>(app)
791 .is_ok_and(|mut mcp| mcp.get().0);
792 let scene_entity = match world
793 .root_scope()
794 .query_one::<&SceneEntity>(app)
795 .ok()
796 .and_then(|mut slot| slot.get().0)
797 {
798 Some(entity) => entity,
799 None => {
800 let (scene_world, scene) = Scene::new(
801 gpu.device.clone(),
802 gpu.queue.clone(),
803 gpu.capabilities,
804 size,
805 scenes::SceneOptions {
806 ui: def.ui,
807 selector: true,
808 reproducible: false,
809 letterbox: true,
810 sequence_step: def.step_seconds,
811 },
812 def,
813 PanelRects::read(world, app),
814 );
815 let entity = world.root_scope().queue().spawn(RenderWorld {
816 world: scene_world,
817 scene,
818 name: "render",
819 });
820 let _ = world
821 .root_scope()
822 .query_one::<&mut SceneEntity>(app)
823 .map(|mut slot| slot.get().0 = Some(entity));
824 window.set_title(&format!(
825 "unlit3d{} — {}",
826 if mcp { " (MCP)" } else { "" },
827 def.title
828 ));
829 return;
830 }
831 };
832 if world
833 .root_scope()
834 .query_one::<&World>(scene_entity)
835 .is_err()
836 {
837 return;
838 }
839 let (renderer, pass) = {
840 let mut scene = world
841 .root_scope()
842 .query_one::<&mut Scene>(scene_entity)
843 .expect("the scene entity carries a scene");
844 scene.get().resize(size, def.baseline);
845 (scene.get().renderer, scene.get().pass)
846 };
847 if presenter_entity(world, app).is_some() {
848 return;
849 }
850 let surface = match gpu.instance.create_surface(window.clone()) {
851 Ok(surface) => surface,
852 Err(error) => {
853 log::error!("failed to build the presentation surface: {error}");
854 return;
855 }
856 };
857 let path = if mcp {
858 PresentationPath::Intermediate(TargetConfig {
859 samples: def.samples,
860 with_depth: def.depth,
861 ..TargetConfig::default()
862 })
863 } else {
864 PresentationPath::Surface {
865 samples: SAMPLE_COUNT,
866 with_depth: true,
867 }
868 };
869 world.root_scope().queue().push(BuildPresenter {
870 app,
871 scene: scene_entity,
872 renderer,
873 pass,
874 gpu,
875 size,
876 surface,
877 path,
878 });
879 window.request_redraw();
880}
881
882/// The entity carrying the presentation path, while one is up.
883fn presenter_entity(world: &World, app: Entity) -> Option<Entity> {
884 world
885 .root_scope()
886 .query_one::<&PresenterEntity>(app)
887 .ok()
888 .and_then(|mut slot| slot.get().0)
889}
890
891/// Release the swap chain, keeping the presentation path for a later resume.
892fn release_surface(world: &World, app: Entity) {
893 let Some(entity) = presenter_entity(world, app) else {
894 return;
895 };
896 let released = world
897 .root_scope()
898 .query_one::<&mut Presenter>(entity)
899 .map(|mut presenter| presenter.get().release(world));
900 if let Ok(Err(error)) = released {
901 log::error!("failed to release the presentation path: {error}");
902 }
903}
904
905/// Release the presentation path and drop it, before its scene goes.
906fn release_presenter(world: &World, app: Entity) {
907 release_surface(world, app);
908 let entity = world
909 .root_scope()
910 .query_one::<&mut PresenterEntity>(app)
911 .ok()
912 .and_then(|mut slot| slot.get().0.take());
913 if let Some(entity) = entity {
914 world.root_scope().queue().despawn(entity);
915 }
916}
917
918#[cfg(target_os = "android")]
919fn release(world: &World, app: Entity) {
920 release_surface(world, app);
921}
922
923/// Rebuild the app around a different scene.
924fn switch_scene(world: &World, app: Entity, def: &'static scenes::SceneDef) {
925 // The target and blit belong to the scene world, so both go with it.
926 // The switching frame skips drawing before the replacement is built.
927 release_presenter(world, app);
928 if let Some(entity) = world
929 .root_scope()
930 .query_one::<&mut SceneEntity>(app)
931 .ok()
932 .and_then(|mut slot| slot.get().0.take())
933 {
934 world.root_scope().queue().despawn(entity);
935 }
936 if let Ok(mut initial) = world.root_scope().query_one::<&mut InitialScene>(app) {
937 initial.get().0 = def;
938 }
939 present(world, app);
940}
941/// Run the scene world's input dispatch for the frame.
942fn dispatch_input_on_frame() -> OnFrame {
943 OnFrame::new(|world, _entity, _frame| {
944 dispatch_input(world);
945 })
946}
947
948/// Decide whether this frame advances the scene's sequence.
949///
950/// A scene with a fixed step advances once its clock has accumulated one; one
951/// without advances every frame. The decision is recorded in `SequenceStepped`
952/// for the behaviour that runs the advance.
953fn step_sequence(entity: Entity) -> OnFrame {
954 OnFrame::new(move |world, _entity, frame| {
955 let stepped = match world
956 .root_scope()
957 .query_one::<&SequenceStep>(entity)
958 .ok()
959 .and_then(|mut step| step.get().0)
960 {
961 Some(step) => world
962 .root_scope()
963 .query_one::<&mut SequenceClock>(entity)
964 .map(|mut clock| tick(&mut clock.get().0, step, frame.delta_time))
965 .expect("the scene frame entity carries a sequence clock"),
966 None => true,
967 };
968 let _ = world
969 .root_scope()
970 .query_one::<&mut SequenceStepped>(entity)
971 .map(|mut flag| flag.get().0 = stepped);
972 })
973}
974
975/// Run the scene's own per-frame behaviour when the sequence advanced, and
976/// count the frame.
977///
978/// The behaviour needs `&mut World`, which a frame callback does not have, so
979/// it is queued; the index is captured before it is counted on, so the
980/// behaviour is handed the frame it advances from.
981fn run_scene_advance(entity: Entity) -> OnFrame {
982 OnFrame::with_order(SCENE_ADVANCE_ORDER, move |world, _entity, frame| {
983 let stepped = world
984 .root_scope()
985 .query_one::<&SequenceStepped>(entity)
986 .is_ok_and(|mut flag| flag.get().0);
987 if !stepped {
988 return;
989 }
990 world.root_scope().queue().push(RunSceneAdvance {
991 entity,
992 frame: *frame,
993 });
994 frame.index = frame.index.wrapping_add(1);
995 })
996}
997
998/// Build the scene and its surface, and short-circuit the frame until both are
999/// ready.
1000///
1001/// The scene's world is spawned through the queue, so it lands on a later
1002/// `apply`; the surface is built out of that world. Each frame retries, and
1003/// the frames that cannot yet draw are skipped.
1004fn ensure_presented(app: Entity) -> OnFrame {
1005 OnFrame::with_order(READY_ORDER, move |world, _entity, _frame| {
1006 let resumed = world
1007 .root_scope()
1008 .query::<(Entity, &Resumed)>()
1009 .iter()
1010 .next()
1011 .is_some_and(|(_, state)| state.0);
1012 if !resumed {
1013 FrameSkip::skip(world);
1014 return;
1015 }
1016 if world
1017 .root_scope()
1018 .query_one::<&SceneEntity>(app)
1019 .map_or(true, |mut slot| slot.get().0.is_none())
1020 {
1021 present(world, app);
1022 FrameSkip::skip(world);
1023 return;
1024 }
1025 if presenter_entity(world, app).is_some() {
1026 return;
1027 }
1028 present(world, app);
1029 if presenter_entity(world, app).is_none() {
1030 FrameSkip::skip(world);
1031 }
1032 })
1033}
1034
1035/// Open a URL the UI asked for, in a new tab when it asked for one.
1036///
1037/// The UI cannot open it itself: a panel runs while the world is borrowed and
1038/// has no page to reach, so it publishes the request in the world and the frame
1039/// loop serves it here — the same split the fullscreen request follows. One
1040/// call covers every platform the example builds for.
1041fn open_url(url: &OpenUrl) {
1042 // The target hint only means anything in a browser, where it decides
1043 // between a new tab and the page the app is already on; elsewhere it is
1044 // ignored.
1045 let target = if url.new_tab { "_blank" } else { "_self" };
1046 let mut options = webbrowser::BrowserOptions::new();
1047 options.with_target_hint(target);
1048 if let Err(error) =
1049 webbrowser::open_browser_with_options(webbrowser::Browser::Default, &url.url, &options)
1050 {
1051 log::debug!("the URL {} could not be opened: {error}", url.url);
1052 }
1053}
1054
1055/// Serve the scene selector, the fullscreen button and the UI's URL requests.
1056///
1057/// A scene switch drops the scene's world, so nothing may still be borrowed
1058/// from it when the switch is served: the control state is read in one block
1059/// and acted on after.
1060fn serve_scene_controls(app: Entity) -> OnFrame {
1061 OnFrame::with_order(CONTROLS_ORDER, move |world, _entity, _frame| {
1062 if FrameSkip::is_skipped(world) {
1063 return;
1064 }
1065 let Some(scene_entity) = world
1066 .root_scope()
1067 .query_one::<&SceneEntity>(app)
1068 .ok()
1069 .and_then(|mut slot| slot.get().0)
1070 else {
1071 return;
1072 };
1073 let active = window(world).is_some_and(|window| fullscreen::active(&window));
1074 let (selector_pos, gpu_info_pos, switch, fullscreen) = {
1075 let Ok(mut nested) = world.root_scope().query_one::<&World>(scene_entity) else {
1076 return;
1077 };
1078 let (selector_pos, gpu_info_pos) = {
1079 let mut scene = world
1080 .root_scope()
1081 .query_one::<&Scene>(scene_entity)
1082 .expect("the scene component exists");
1083 (
1084 scene.get().selector_rect(nested.get()),
1085 scene.get().gpu_info_rect(nested.get()),
1086 )
1087 };
1088 let switch = world
1089 .root_scope()
1090 .query_one::<&mut Scene>(scene_entity)
1091 .ok()
1092 .and_then(|mut scene| scene.get().pending.take());
1093 let fullscreen = world
1094 .root_scope()
1095 .query_one::<&mut Scene>(scene_entity)
1096 .is_ok_and(|mut scene| scene.get().fullscreen_request(nested.get(), active));
1097 (selector_pos, gpu_info_pos, switch, fullscreen)
1098 };
1099 let _ = world
1100 .root_scope()
1101 .query_one::<&mut SelectorPos>(app)
1102 .map(|mut slot| slot.get().0 = selector_pos);
1103 let _ = world
1104 .root_scope()
1105 .query_one::<&mut GpuInfoPos>(app)
1106 .map(|mut slot| slot.get().0 = gpu_info_pos);
1107 if let Some(def) = switch {
1108 switch_scene(world, app, def);
1109 FrameSkip::skip(world);
1110 return;
1111 }
1112 if fullscreen && let Some(window) = window(world) {
1113 fullscreen::toggle(&window);
1114 }
1115 // The UI publishes the URLs it was asked to open in the scene's own
1116 // world, so it is drained there rather than here in the host.
1117 if let Ok(mut nested) = world.root_scope().query_one::<&World>(scene_entity) {
1118 for url in drain_open_urls(nested.get()) {
1119 open_url(&url);
1120 }
1121 }
1122 })
1123}
1124
1125/// Advance the scene by the frame's delta.
1126fn advance_scene(app: Entity) -> OnFrame {
1127 OnFrame::with_order(ADVANCE_ORDER, move |world, _entity, frame| {
1128 if FrameSkip::is_skipped(world) {
1129 return;
1130 }
1131 let Some(scene_entity) = world
1132 .root_scope()
1133 .query_one::<&SceneEntity>(app)
1134 .ok()
1135 .and_then(|mut slot| slot.get().0)
1136 else {
1137 return;
1138 };
1139 let Ok(mut nested) = world.root_scope().query_one::<&mut World>(scene_entity) else {
1140 return;
1141 };
1142 let _ = world
1143 .root_scope()
1144 .query_one::<&mut Scene>(scene_entity)
1145 .map(|mut scene| {
1146 scene.get().advance(&mut *nested.get(), frame.delta_time);
1147 });
1148 })
1149}
1150
1151/// Restate the scene's letterboxed region on the pass that draws it.
1152///
1153/// A window resize changes the region, and the library's present renders the
1154/// pass this frame, so this runs before it. The blit pass, when there is one,
1155/// keeps its full-target viewport. A skipped frame still restates the region:
1156/// keeping the pass's viewport in step with the window costs nothing and
1157/// leaves the pass right for the frame that draws again.
1158fn restate_viewport(app: Entity) -> OnFrame {
1159 OnFrame::with_order(VIEWPORT_ORDER, move |world, _entity, _frame| {
1160 let Some(scene_entity) = world
1161 .root_scope()
1162 .query_one::<&SceneEntity>(app)
1163 .ok()
1164 .and_then(|mut slot| slot.get().0)
1165 else {
1166 return;
1167 };
1168 let (scene_pass, viewport) = {
1169 let mut scene = world
1170 .root_scope()
1171 .query_one::<&Scene>(scene_entity)
1172 .expect("the scene entity carries a scene");
1173 (scene.get().pass, scene.get().viewport)
1174 };
1175 let Ok(mut nested) = world.root_scope().query_one::<&mut World>(scene_entity) else {
1176 return;
1177 };
1178 nested
1179 .get()
1180 .root_scope()
1181 .query_one::<&mut FramePass>(scene_pass)
1182 .map(|mut pass| pass.get().viewport = viewport)
1183 .expect("the scene's own pass exists");
1184 })
1185}
1186
1187/// Apply the commands the scene world queued while presenting.
1188///
1189/// The library's present renders the scene world, so its commands are applied
1190/// after it -- a later order -- rather than by the presenter itself. A skipped
1191/// frame still flushes: the commands queue up on the scene world either way,
1192/// and dropping none of them keeps the scene's own record intact.
1193fn flush_scene(app: Entity) -> OnFrame {
1194 OnFrame::with_order(FLUSH_ORDER, move |world, _entity, _frame| {
1195 let Some(scene_entity) = world
1196 .root_scope()
1197 .query_one::<&SceneEntity>(app)
1198 .ok()
1199 .and_then(|mut slot| slot.get().0)
1200 else {
1201 return;
1202 };
1203 let Ok(mut nested) = world.root_scope().query_one::<&mut World>(scene_entity) else {
1204 return;
1205 };
1206 let _ = world
1207 .root_scope()
1208 .query_one::<&mut Scene>(scene_entity)
1209 .map(|mut scene| {
1210 if let Err(error) = scene.get().end_frame(&mut *nested.get()) {
1211 log::error!("a command queued by a scene failed: {error}");
1212 }
1213 });
1214 })
1215}
1216
1217/// Request the GPU context once a window exists to present through.
1218///
1219/// The window is created by the host at the end of the resume callback, so the
1220/// request waits for a later frame; a `GpuState` that is no longer idle keeps
1221/// a later frame from starting it twice.
1222fn request_gpu(app: Entity) -> OnFrame {
1223 OnFrame::new(move |world, _entity, _frame| {
1224 if !world
1225 .root_scope()
1226 .query_one::<&GpuState>(app)
1227 .is_ok_and(|mut state| matches!(state.get(), GpuState::Idle))
1228 {
1229 return;
1230 }
1231 let Some(window) = window(world) else {
1232 return;
1233 };
1234 let Some(display) = world
1235 .root_scope()
1236 .query::<(Entity, &DisplayHandle)>()
1237 .iter()
1238 .next()
1239 .map(|(_, handle)| handle.0.clone())
1240 else {
1241 return;
1242 };
1243 let Ok(proxy) = world
1244 .root_scope()
1245 .query_one::<&WinitProxy<UserEvent>>(app)
1246 .map(|mut proxy| proxy.get().0.clone())
1247 else {
1248 return;
1249 };
1250 let _ = world
1251 .root_scope()
1252 .query_one::<&mut GpuState>(app)
1253 .map(|mut state| *state.get() = GpuState::Requested);
1254 // The display handle is owned, not borrowed: the instance it builds has
1255 // to outlive this callback. The descriptor is the environment-aware one
1256 // so `WGPU_BACKEND` still selects a backend.
1257 spawn(async move {
1258 let instance = wgpu::util::new_instance_with_webgpu_detection(
1259 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(display)),
1260 )
1261 .await;
1262 let surface = instance
1263 .create_surface(window)
1264 .expect("the window presents to a surface");
1265 match Gpu::request(instance, surface).await {
1266 Ok(gpu) => {
1267 let _ = proxy.send_event(UserEvent::Ready(gpu));
1268 }
1269 Err(error) => {
1270 let _ = proxy.send_event(UserEvent::Failed(error.to_string()));
1271 }
1272 }
1273 });
1274 })
1275}
1276
1277/// Serve the events the app posts to itself.
1278fn handle_user_event(app: Entity) -> OnUserEvent<UserEvent> {
1279 OnUserEvent::new(move |world, _entity, event| match event {
1280 UserEvent::Ready(gpu) => {
1281 let _ = world
1282 .root_scope()
1283 .query_one::<&mut GpuState>(app)
1284 .map(|mut state| {
1285 *state.get() = GpuState::Ready(gpu.clone());
1286 });
1287 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
1288 if world
1289 .root_scope()
1290 .query_one::<&McpEnabled>(app)
1291 .is_ok_and(|mut mcp| mcp.get().0)
1292 {
1293 start_mcp(world, app);
1294 }
1295 present(world, app);
1296 }
1297 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
1298 UserEvent::Command(command) => {
1299 if let Some(command) = command.take() {
1300 dispatch_command(world, command);
1301 }
1302 }
1303 UserEvent::Failed(error) => {
1304 log::error!("failed to start the renderer: {error}");
1305 let _ = world
1306 .root_scope()
1307 .query_one::<&mut ExitRequest>(app)
1308 .map(|mut request| request.get().0 = true);
1309 }
1310 })
1311}
1312
1313/// Re-state the scene and the surface for a new window size.
1314fn resize_scene(app: Entity) -> OnWindowEvent {
1315 OnWindowEvent::new(move |world, _entity, payload| {
1316 let WindowEvent::Resized(size) = &payload.event else {
1317 return;
1318 };
1319 if size.width == 0 || size.height == 0 {
1320 return;
1321 }
1322 let size = (size.width, size.height);
1323 let Some(scene_entity) = world
1324 .root_scope()
1325 .query_one::<&SceneEntity>(app)
1326 .ok()
1327 .and_then(|mut slot| slot.get().0)
1328 else {
1329 return;
1330 };
1331 let baseline = world
1332 .root_scope()
1333 .query_one::<&InitialScene>(app)
1334 .expect("the app entity carries its initial scene")
1335 .get()
1336 .0
1337 .baseline;
1338 let _ = world
1339 .root_scope()
1340 .query_one::<&mut Scene>(scene_entity)
1341 .map(|mut scene| scene.get().resize(size, baseline));
1342 let Some(presenter) = presenter_entity(world, app) else {
1343 return;
1344 };
1345 let resized = world
1346 .root_scope()
1347 .query_one::<&mut Presenter>(presenter)
1348 .map(|mut presenter| presenter.get().resize(world, size));
1349 if let Ok(Err(error)) = resized {
1350 log::error!("failed to resize the presentation path: {error}");
1351 }
1352 })
1353}
1354
1355/// Drop the swap chain the platform is about to invalidate.
1356#[cfg(target_os = "android")]
1357fn release_on_suspend(app: Entity) -> OnSuspended {
1358 OnSuspended::new(move |world, _entity, ()| {
1359 release(world, app);
1360 })
1361}
1362
1363/// Start the MCP stdio transport, which posts each command back to the loop.
1364///
1365/// It runs on its own thread because serving the protocol blocks; the proxy is
1366/// what carries a command back, because a world is not `Send`.
1367#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
1368fn start_mcp(world: &World, app: Entity) {
1369 let Ok(proxy) = world
1370 .root_scope()
1371 .query_one::<&WinitProxy<UserEvent>>(app)
1372 .map(|mut proxy| proxy.get().0.clone())
1373 else {
1374 return;
1375 };
1376 std::thread::Builder::new()
1377 .name("unlit3d-mcp-transport".to_owned())
1378 .spawn(move || {
1379 let sink = move |command| proxy.send_event(UserEvent::Command(Some(command))).is_ok();
1380 if let Err(error) = unlit_ecs::mcp::serve_stdio_with(sink) {
1381 log::error!("the MCP server stopped: {error}");
1382 }
1383 })
1384 .expect("the MCP transport thread starts");
1385}
1386
1387/// Queue one MCP command against the world it names.
1388///
1389/// Running it needs `&mut World`, so it is queued as a command and applied
1390/// between two event callbacks. The host answers `list_worlds` itself: it is
1391/// the only one that knows which entities carry a world and what they are
1392/// called.
1393#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
1394fn dispatch_command(world: &World, command: unlit_ecs::mcp::McpCommand) {
1395 use unlit_ecs::mcp::{WorldEntry, WorldId};
1396 if command.wants_worlds() {
1397 let mut worlds = vec![WorldEntry {
1398 world: WorldId::Host,
1399 name: "host",
1400 }];
1401 for (entity, _) in world.root_scope().query::<(Entity, &World)>().iter() {
1402 let Ok(mut name) = world.root_scope().query_one::<&WorldName>(entity) else {
1403 continue;
1404 };
1405 let name = name.get();
1406 worlds.push(WorldEntry {
1407 world: WorldId::handle(entity),
1408 name: name.0,
1409 });
1410 }
1411 command.answer_worlds(worlds);
1412 return;
1413 }
1414 match command.world().entity() {
1415 Some(entity) => world
1416 .root_scope()
1417 .queue()
1418 .push(RunInWorld { entity, command }),
1419 None => world.root_scope().queue().push(RunInHost { command }),
1420 }
1421}
1422
1423impl Scene {
1424 /// Build the scene `def` into a fresh world.
1425 ///
1426 /// Sync, and on the main thread: the ECS world is single-threaded, so it
1427 /// lives on the thread the event loop runs on. The world starts with the
1428 /// frame's GPU context and the renderer resource entity; the scene's build
1429 /// adds its sources, meshes, camera and entities, and returns the
1430 /// [`SceneControl`] that drives them.
1431 pub fn new(
1432 device: wgpu::Device,
1433 queue: wgpu::Queue,
1434 capabilities: DeviceCapabilities,
1435 size: (u32, u32),
1436 options: scenes::SceneOptions,
1437 def: &'static scenes::SceneDef,
1438 panels: PanelRects,
1439 ) -> (World, Self) {
1440 let mut world = World::new();
1441 let context = spawn_context(
1442 &mut world,
1443 device,
1444 queue,
1445 ResourceGraph::new(),
1446 capabilities,
1447 );
1448 let renderer = world
1449 .root_scope_mut()
1450 .spawn(bundle![Renderer::new(context)]);
1451 // The pass the scene draws in. Everything the scene builds hangs under
1452 // it — its sources, and the UI source mounted below — because a pass
1453 // records the sources in its own scope: the pass a source sits under is
1454 // the pass its drawing lands in. Its order is the first of the frame,
1455 // so a presenting pass mounted later draws after it.
1456 let load = RenderLoadOps {
1457 color: match def.clear {
1458 Some(color) => wgpu::LoadOp::Clear(color),
1459 None => color_clear(),
1460 },
1461 ..RenderLoadOps::default()
1462 };
1463 let pass = world.root_scope_mut().spawn(Pass {
1464 load,
1465 ..Pass::new(SCENE_PASS_ORDER)
1466 });
1467 // The scene's content is built for the region it will be drawn into,
1468 // not for the whole target: a camera built for the target's aspect
1469 // would be sheared back out to it by the viewport. A run that does not
1470 // letterbox — every capture — draws into the whole target, so its
1471 // content size is the target's own and nothing about it changes.
1472 let (viewport, content) = letterbox(def.baseline, size, options.letterbox);
1473 // The pass carries the region as well as the scene: the driver asks the
1474 // pass where its sources draw, so a viewport kept only on this struct
1475 // would never reach the renderer and the content would stretch to the
1476 // whole target.
1477 world
1478 .root_scope()
1479 .query_one::<&mut FramePass>(pass)
1480 .map(|mut pass| pass.get().viewport = viewport)
1481 .expect("the pass was just spawned");
1482
1483 // The UI source is mounted before the scene is built, so a scene's own
1484 // panels hang in the scope it draws: a source renders its own subtree,
1485 // and a panel spawned beside it would never be shown.
1486 let ui = (options.ui || options.selector).then(|| {
1487 let mut source_ui = UiSource::new();
1488 if options.reproducible && def.reproducible_ui {
1489 // egui fades a window in over the first frames and animates
1490 // widget transitions, all measured against the clock it is
1491 // handed. With no animation time every one of them is already
1492 // over, so the first frame is fully drawn and does not depend
1493 // on when it was taken.
1494 source_ui
1495 .context_mut()
1496 .all_styles_mut(|style| style.animation_time = 0.0);
1497 }
1498 world
1499 .scope_mut(pass)
1500 .expect("the pass was just spawned")
1501 .spawn(bundle![Source::new(source_ui)])
1502 });
1503 let control = (def.build)(&mut world, context, pass, ui, content, options);
1504 let SceneControl { advance, snapshot } = control;
1505
1506 // The shell's own state — the selector's switch, the fullscreen
1507 // request and its state, the frame-rate reading — lives in the shared
1508 // subtree. A panel reads and writes it from the UI source's scope,
1509 // which reaches the shared subtree and nothing beside it.
1510 let shared = world.shared();
1511 let (switch, fullscreen, fullscreen_active, frame_rate) = {
1512 let mut shared_view = world.scope_mut(shared).expect("the shared scope exists");
1513 (
1514 shared_view.spawn(bundle![SceneSwitch(None)]),
1515 shared_view.spawn(bundle![FullscreenRequest(false)]),
1516 shared_view.spawn(bundle![FullscreenActive(false)]),
1517 shared_view.spawn(bundle![FrameRate::default()]),
1518 )
1519 };
1520 // The scene's per-frame state and its own behaviour, as components: the
1521 // frame's behaviours advance them without `&mut Scene`. One entity
1522 // carries them so the behaviour that advances the scene and the state
1523 // it reads are reached together.
1524 let frame_entity = world.root_scope_mut().spawn(SceneFrame {
1525 advance,
1526 step: options.sequence_step,
1527 });
1528 // The frame's own dispatch: the input runs first and states no order,
1529 // since nothing else here reads what it dispatched. Stepping the index
1530 // and advancing by the frame it names are the one dependency: the
1531 // advance states an order after the step that records it.
1532 world
1533 .root_scope_mut()
1534 .spawn(bundle![dispatch_input_on_frame()]);
1535 world
1536 .root_scope_mut()
1537 .spawn(bundle![step_sequence(frame_entity)]);
1538 world
1539 .root_scope_mut()
1540 .spawn(bundle![run_scene_advance(frame_entity)]);
1541 // A scene world has no window: it starts whole and nothing short-
1542 // circuits it. Spawning the flag lets a scene behaviour do so.
1543 world.root_scope_mut().spawn(bundle![FrameSkip::default()]);
1544
1545 if let Some(ui) = ui
1546 && options.selector
1547 {
1548 mount_selector(&mut world, ui, switch, def, panels.selector);
1549 mount_frame_rate(&mut world, ui, frame_rate);
1550 let device = context
1551 .device(&world.root_scope())
1552 .expect("the device resource");
1553 mount_gpu_info(&mut world, ui, device, panels.gpu_info);
1554 // Every platform has a window the button can take fullscreen, so
1555 // it is drawn everywhere. A platform whose backend cannot — Android
1556 // already takes the whole screen for its activity — draws the
1557 // button and ignores the press.
1558 mount_fullscreen(&mut world, ui, fullscreen, fullscreen_active);
1559 // The API docs are a page every platform can open, so the link is
1560 // drawn everywhere. Mounted after the fullscreen button, which it
1561 // is placed under.
1562 mount_api_docs(&mut world, ui);
1563 }
1564
1565 (
1566 world,
1567 Self {
1568 renderer,
1569 size: content,
1570 viewport,
1571 pass,
1572 snapshot,
1573 frame_rate,
1574 frame: 0,
1575 switch,
1576 fullscreen,
1577 fullscreen_active,
1578 pending: None,
1579 },
1580 )
1581 }
1582
1583 /// Re-state the target's size, re-letterboxing the content for its shape.
1584 ///
1585 /// Called on every resize and on the first frame that knows the window's
1586 /// size, so the projection and the size the scene's behaviour is handed
1587 /// follow the target together. The region is the scene's own state here and
1588 /// reaches the pass that draws it from [`restate_viewport`], which is the one
1589 /// place holding both that pass and this scene.
1590 fn resize(&mut self, target: (u32, u32), baseline: Option<(u32, u32)>) {
1591 let (viewport, content) = letterbox(baseline, target, true);
1592 self.size = content;
1593 self.viewport = viewport;
1594 }
1595
1596 /// Advance the scene by `delta_time` seconds.
1597 ///
1598 /// A frame is the dispatch of the scene world's `OnFrame` behaviours. The
1599 /// input and the index state no order; the scene's own behaviour states one
1600 /// after the index, since it advances by the frame the index names. The
1601 /// index and the content size travel in the frame's context, so a behaviour
1602 /// reads them without a component of its own.
1603 pub fn advance(&mut self, world: &mut World, delta_time: f32) {
1604 // Measured before anything else, so the reading covers the whole frame
1605 // — input, behaviour, render and present — and not just the part
1606 // between here and the next call.
1607 world
1608 .root_scope()
1609 .query_one::<&mut FrameRate>(self.frame_rate)
1610 .map(|mut rate| rate.get().push(delta_time))
1611 .expect("the frame-rate component exists");
1612
1613 let mut frame = Frame {
1614 delta_time,
1615 index: self.frame,
1616 size: self.size,
1617 };
1618 dispatch_frame(world, &mut frame);
1619 self.frame = frame.index;
1620
1621 // The selector's request, read once so the frame loop can act on it.
1622 self.pending = world
1623 .root_scope()
1624 .query_one::<&mut SceneSwitch>(self.switch)
1625 .map(|mut s| s.get().0.take())
1626 .expect("the switch component exists");
1627 }
1628
1629 /// The selector window's rectangle the last time it was laid out, or
1630 /// `None` before it has been shown once.
1631 ///
1632 /// The window's rectangle is remembered by egui, in this scene's context;
1633 /// the shell reads it from here so a scene switch — which rebuilds that
1634 /// context — can rebuild the window in the same place and at the same size.
1635 fn selector_rect(&self, world: &World) -> Option<egui::Rect> {
1636 self.panel_rect(world, SELECTOR_WINDOW)
1637 }
1638
1639 /// The GPU-info panel's rectangle the last time it was laid out, or `None`
1640 /// before it has been shown once.
1641 ///
1642 /// Carried across a scene switch exactly like [`Self::selector_rect`]: the
1643 /// panel is a window the user may drag, and the switch rebuilds the context
1644 /// that remembers where it was left.
1645 fn gpu_info_rect(&self, world: &World) -> Option<egui::Rect> {
1646 self.panel_rect(world, GPU_INFO_AREA)
1647 }
1648
1649 /// The rectangle egui remembers for the window with `id`.
1650 ///
1651 /// Several sources share the world; only the UI one holds egui's window
1652 /// memory, so skip every other source's entity.
1653 fn panel_rect(&self, world: &World, id: &str) -> Option<egui::Rect> {
1654 world
1655 .root_scope()
1656 .query::<(Entity, &Source)>()
1657 .iter()
1658 .find_map(|(_, source)| {
1659 let ui = source.as_ref::<UiSource>().ok()?;
1660 ui.context()
1661 .memory(|memory| memory.area_rect(egui::Id::unique(id)))
1662 })
1663 }
1664
1665 /// Record whether the window is fullscreen and report whether the button
1666 /// asked for a change, clearing the request.
1667 ///
1668 /// Both are read and written once per frame by the windowed loop, which is
1669 /// what owns the window; the scene keeps no opinion of its own about
1670 /// fullscreen.
1671 fn fullscreen_request(&mut self, world: &World, active: bool) -> bool {
1672 let _ = world
1673 .root_scope()
1674 .query_one::<&mut FullscreenActive>(self.fullscreen_active)
1675 .map(|mut state| state.get().0 = active);
1676 world
1677 .root_scope()
1678 .query_one::<&mut FullscreenRequest>(self.fullscreen)
1679 .map(|mut request| std::mem::take(&mut request.get().0))
1680 .expect("the fullscreen request component exists")
1681 }
1682
1683 /// Render one frame into whatever target the caller bound.
1684 ///
1685 /// Only the snapshot tests draw through this: the windowed run acquires
1686 /// the swap chain's image and renders between the acquire and the present,
1687 /// so it needs both under one borrow.
1688 pub fn render(&mut self, world: &World) {
1689 world
1690 .root_scope()
1691 .query_one::<&mut Renderer>(self.renderer)
1692 .map(|mut renderer| renderer.get().render(world))
1693 .expect("the renderer is a resource entity");
1694 }
1695
1696 /// Finish the frame: apply whatever a behaviour component or panel queued.
1697 ///
1698 /// Nothing is cleared: every consumer reads the events from its own
1699 /// subscription, and the dispatch that reads them takes them.
1700 pub fn end_frame(&mut self, world: &mut World) -> Result<(), EntityAccessError> {
1701 world.apply()
1702 }
1703}
1704
1705/// The scene-selector window's title, which is also its egui area id: the
1706/// shell reads the window's remembered rectangle by this same id.
1707const SELECTOR_WINDOW: &str = "scenes";
1708
1709/// The width the selector window opens at, in points.
1710///
1711/// Narrow enough that the list leaves the scene visible behind it: the
1712/// description wraps onto a few lines instead of the window stretching across
1713/// the frame. A window a user has dragged keeps the width it was dragged to.
1714const SELECTOR_DEFAULT_WIDTH: f32 = 220.0;
1715
1716/// Shift a movable window by however far the screen corner it belongs to moved
1717/// when the screen changed shape.
1718///
1719/// egui applies a window's opening position on the pass that first shows it and
1720/// afterwards only clamps the remembered rectangle into the screen, so a window
1721/// seeded into a corner keeps its old spot when the display rotates and the
1722/// frame becomes a different shape. Shifting it by the corner's movement keeps
1723/// its distance from that corner — including a distance the user dragged it to —
1724/// instead of leaving it stranded off its edge.
1725///
1726/// The window is anchored to the corner `pivot` names, so its pivot position
1727/// (the point of the window sitting on that corner) is what moves. The screen
1728/// the last call saw is remembered under `id`, so the shift is computed once
1729/// per change and the returned position is applied with
1730/// [`egui::Window::current_pos`] for that one pass; every other pass returns
1731/// `None` and the window keeps the position egui remembers for it.
1732fn follow_screen_corner(
1733 ctx: &egui::Context,
1734 id: egui::Id,
1735 pivot: egui::Align2,
1736) -> Option<egui::Pos2> {
1737 let screen = ctx.content_rect();
1738 let screen_key = id.with("screen");
1739 let previous = ctx.data(|data| data.get_temp::<egui::Rect>(screen_key));
1740 ctx.data_mut(|data| {
1741 data.insert_temp(screen_key, screen);
1742 });
1743 let previous = previous?;
1744 if previous == screen {
1745 return None;
1746 }
1747 // The window has to have been laid out for its position to be known; on its
1748 // first pass it is seeded by the corner it belongs to, which is where this
1749 // pass would move it anyway.
1750 let rect = ctx.memory(|memory| memory.area_rect(id))?;
1751 let factor = pivot_factor(pivot);
1752 let moved = screen.lerp_inside(factor) - previous.lerp_inside(factor);
1753 Some(rect.lerp_inside(factor) + moved)
1754}
1755
1756/// The fraction of a window's size at which its pivot sits, per axis.
1757///
1758/// The pivot of a window seeded into a corner is that corner, so this is the
1759/// point of the window — and the matching point of the screen — that stays
1760/// fixed as the window's size changes.
1761fn pivot_factor(pivot: egui::Align2) -> egui::Vec2 {
1762 egui::vec2(pivot.x().to_factor(), pivot.y().to_factor())
1763}
1764
1765/// Where the selector window opens when no position was carried over — the
1766/// first build of a session.
1767///
1768/// The window's bottom-right corner sits one overlay margin from the screen's,
1769/// so the list opens out of the way of the frame-rate readout and the
1770/// fullscreen button in the top corners. The window is seeded by its
1771/// bottom-right pivot, so that corner stays put as the content decides the
1772/// window's size; a window a user has dragged is carried by its whole
1773/// rectangle, which a rebuild restores exactly.
1774fn selector_default_pos(ctx: &egui::Context) -> egui::Pos2 {
1775 ctx.content_rect().right_bottom() - egui::Vec2::splat(OVERLAY_MARGIN)
1776}
1777
1778/// Mount the windowed shell's scene-selector panel.
1779///
1780/// The panel lists every scene and writes its choice into the [`SceneSwitch`]
1781/// component, which the frame loop reads once after the frame's advance.
1782fn mount_selector(
1783 world: &mut World,
1784 ui: Entity,
1785 switch: Entity,
1786 current: &'static scenes::SceneDef,
1787 initial: Option<egui::Rect>,
1788) {
1789 world
1790 .scope_mut(ui)
1791 .expect("the UI source is alive")
1792 .spawn(bundle![UiPanel::new(move |world, _entity, ui| {
1793 // The id is set explicitly: `Window::new` derives it from the title's
1794 // `Atoms` text, whose hash differs from a plain string id, so the
1795 // shell's read of the remembered rect would never match.
1796 let id = egui::Id::unique(SELECTOR_WINDOW);
1797 // The window belongs to the bottom-right corner. A fresh one is seeded
1798 // by that corner, so the corner stays put while the first layout
1799 // measures the content; a carried rectangle is seeded by its own
1800 // bottom-right and given its size, so the first frame lays it out
1801 // exactly where the previous scene left it. `follow_screen_corner`
1802 // keeps the corner when the screen changes shape.
1803 let pivot = egui::Align2::RIGHT_BOTTOM;
1804 let window = egui::Window::new(SELECTOR_WINDOW).id(id).pivot(pivot);
1805 let window = match initial {
1806 Some(rect) => window
1807 .default_pos(rect.lerp_inside(pivot_factor(pivot)))
1808 .default_size(rect.size()),
1809 None => window
1810 .default_width(SELECTOR_DEFAULT_WIDTH)
1811 .default_pos(selector_default_pos(ui.ctx())),
1812 };
1813 // A titled window that is movable discards `current_pos`: egui
1814 // re-applies the pivot it had stored before the area began, so the
1815 // position given here would not stick. The reposition pass therefore
1816 // also turns the window immovable; every other pass is movable again
1817 // and a user's drag works as before.
1818 let window = match follow_screen_corner(ui.ctx(), id, pivot) {
1819 Some(pos) => window.current_pos(pos).movable(false),
1820 None => window,
1821 };
1822 window.show(ui.ctx(), |ui| {
1823 ui.label(format!("{} — {}", current.title, current.description));
1824 ui.separator();
1825 for scene in scenes::SCENES.iter().copied() {
1826 let selected = scene.id == current.id;
1827 if ui.selectable_label(selected, scene.title).clicked() {
1828 let _ = world
1829 .query_one::<&mut SceneSwitch>(switch)
1830 .map(|mut s| s.get().0 = Some(scene));
1831 }
1832 }
1833 });
1834 },),]);
1835}
1836
1837/// The frame rate the windowed shell reports, as a component so the panel that
1838/// displays it and the loop that measures it share one value.
1839///
1840/// It lives in the world like any other behaviour state rather than being
1841/// captured by the panel's closure: a panel may run several times in one frame
1842/// when egui redoes a layout, and a panel must not reach for its own
1843/// [`UiPanel`], so the measurement belongs in a sibling component.
1844#[derive(Default)]
1845struct FrameRate {
1846 /// The frames measured so far, capped at [`FrameRate::WINDOW`].
1847 ///
1848 /// A sliding window rather than a running mean: a scene that stalls once
1849 /// should not drag the reading down for the rest of the session, and a rate
1850 /// that has just changed should show up within a few frames.
1851 samples: VecDeque<f32>,
1852}
1853
1854impl FrameRate {
1855 /// How many frames the reading averages over.
1856 ///
1857 /// About a second at 60 Hz: long enough that the number does not flicker
1858 /// with every frame, short enough that a change is visible promptly.
1859 const WINDOW: usize = 60;
1860
1861 /// Record one frame's duration, in seconds.
1862 fn push(&mut self, delta: f32) {
1863 if self.samples.len() == Self::WINDOW {
1864 self.samples.pop_front();
1865 }
1866 self.samples.push_back(delta);
1867 }
1868
1869 /// The mean frame time over the window, in seconds, or `None` before any
1870 /// frame was measured.
1871 fn mean_seconds(&self) -> Option<f32> {
1872 if self.samples.is_empty() {
1873 return None;
1874 }
1875 let total: f32 = self.samples.iter().sum();
1876 Some(total / self.samples.len() as f32)
1877 }
1878
1879 /// The reading as `"60.0 fps · 16.7 ms"`.
1880 ///
1881 /// The frame time is shown next to the rate because the two answer
1882 /// different questions: the rate is what a display is judged against, while
1883 /// the milliseconds are what a budget is spent in.
1884 fn text(&self) -> String {
1885 match self.mean_seconds() {
1886 Some(seconds) if seconds > 0.0 => {
1887 format!("{:.1} fps · {:.1} ms", 1.0 / seconds, seconds * 1e3)
1888 }
1889 // The first frame has no previous one to measure against.
1890 _ => "— fps".to_owned(),
1891 }
1892 }
1893}
1894
1895/// The distance every overlay keeps from the edge of the screen.
1896///
1897/// Each corner overlay is anchored to its edge with this offset, so none of
1898/// them is drawn flush against the boundary — a readout or a button touching
1899/// the very edge of a phone's display reads as clipped, and the safe-area
1900/// insets the page already applies to the canvas are not visible to egui.
1901const OVERLAY_MARGIN: f32 = 8.0;
1902
1903/// The gap between the frame-rate readout and the fullscreen button when a
1904/// window is too narrow to hold them on the same row.
1905const OVERLAY_GAP: f32 = 8.0;
1906
1907/// The smallest a corner button is drawn, in points.
1908///
1909/// A corner button is an overlay on the scene, so it is kept small enough to
1910/// read as one — a control as tall as a panel's row competes with the frame it
1911/// annotates — while still being a comfortable target to click.
1912const OVERLAY_BUTTON_MIN_SIZE: egui::Vec2 = egui::Vec2::new(110.0, 32.0);
1913
1914/// A corner button's inner padding, symmetric so the label is centred within
1915/// the button it is grown to.
1916///
1917/// A bare [`egui::Button::min_size`] would leave the label sitting in a corner:
1918/// the button's atoms are aligned by the surrounding layout, which is not
1919/// centred. Growing the padding instead — the same on every side — is what
1920/// pushes the label to the middle of the larger button.
1921const OVERLAY_BUTTON_PADDING: egui::Vec2 = egui::Vec2::new(10.0, 7.0);
1922
1923/// The API docs the Pages workflow publishes beside the example page.
1924///
1925/// Absolute, because the browser and Android only open an `http(s)` URL — a
1926/// path relative to the page is not something either hands to a browser. The
1927/// site is the repository's, so a fork that publishes its own should point this
1928/// at its own Pages URL.
1929const API_DOCS_URL: &str = "https://beicause.github.io/unlit3d/docs/index.html";
1930
1931/// The label the API-docs button carries.
1932const API_DOCS_LABEL: &str = "API docs";
1933
1934/// The id of the frame-rate readout's area.
1935const FRAME_RATE_AREA: &str = "unlit3d::frame-rate";
1936
1937/// The id of the fullscreen button's area.
1938const FULLSCREEN_AREA: &str = "unlit3d::fullscreen";
1939
1940/// The id of the API-docs button's area.
1941const API_DOCS_AREA: &str = "unlit3d::api-docs";
1942
1943/// The GPU-info panel's title, shown in its title bar.
1944const GPU_INFO_TITLE: &str = "GPU";
1945
1946/// The id of the GPU-info panel's area, which is also the id egui remembers
1947/// the panel's rectangle under.
1948const GPU_INFO_AREA: &str = "unlit3d::gpu-info";
1949
1950/// The narrowest the GPU-info panel may be dragged, in points.
1951///
1952/// A fresh panel opens as wide as its widest fact needs to stay on one line,
1953/// so this floor is only reached by dragging. Without it a drag could squeeze
1954/// the panel down to a column of one letter: the facts are wrapped text in a
1955/// scroll area, so they follow the panel all the way down.
1956const GPU_INFO_MIN_WIDTH: f32 = 160.0;
1957
1958/// Slack added to the GPU-info panel's measured width.
1959///
1960/// A label wraps on a strict comparison against the width it is handed, and a
1961/// window rounds its size to a whole pixel, so a width that fits a fact exactly
1962/// can still come out a hair too narrow. A couple of points keep the longest
1963/// fact on its line when the rounding would otherwise break it.
1964const GPU_INFO_WIDTH_SLACK: f32 = 2.0;
1965
1966/// The share of the window's width the GPU-info panel may take, at most.
1967///
1968/// The panel takes its opening width from its content, and a device can name a
1969/// limit that is longer than the window is wide. This keeps such a panel from
1970/// hanging off the edge and leaves the scene visible beside it; a name that
1971/// still does not fit wraps, and the panel may be dragged wider by hand.
1972const GPU_INFO_MAX_WIDTH_FRACTION: f32 = 0.85;
1973
1974/// The share of the window's height the GPU-info panel may take.
1975///
1976/// The panel is an overlay on the scene, not a replacement for it: the limits
1977/// alone are several dozen rows, so the body scrolls within this share of the
1978/// window instead of covering it.
1979const GPU_INFO_HEIGHT_FRACTION: f32 = 0.6;
1980
1981/// Mount the frame-rate readout at the top of the window, centred.
1982///
1983/// An [`egui::Area`] rather than a window: it is pinned, not something the user
1984/// can drag away or accidentally resize, and `interactable(false)` keeps it
1985/// from swallowing a click meant for the scene underneath it.
1986fn mount_frame_rate(world: &mut World, ui: Entity, rate: Entity) {
1987 world
1988 .scope_mut(ui)
1989 .expect("the UI source is alive")
1990 .spawn(bundle![UiPanel::new(move |world, _entity, ui| {
1991 let text = world
1992 .query_one::<&FrameRate>(rate)
1993 .map(|mut rate| rate.get().text())
1994 .expect("the frame-rate component exists");
1995 frame_rate_readout(ui, &text);
1996 },),]);
1997}
1998
1999/// Draw the frame-rate readout, showing `text`, centred along the top edge of
2000/// `ui`'s screen.
2001///
2002/// The label is set to [`egui::Label::extend`], so the readout grows to fit its
2003/// text instead of wrapping it. An anchored area lays its contents out against
2004/// the size it remembered from the previous pass, so a reading that is wider
2005/// than the last one — the first real reading after the empty placeholder, or
2006/// any reading after the rate changes — would otherwise be broken across lines
2007/// in a narrow window.
2008///
2009/// A free function rather than the body of the panel's closure so a test can
2010/// drive it against a bare [`egui::Context`]: what it has to get right — one
2011/// line, whatever the previous reading was — is a property of the widget tree,
2012/// not of the world.
2013fn frame_rate_readout(ui: &mut egui::Ui, text: &str) {
2014 egui::Area::new(egui::Id::unique(FRAME_RATE_AREA))
2015 .anchor(egui::Align2::CENTER_TOP, [0.0, OVERLAY_MARGIN])
2016 .interactable(false)
2017 // The readout is an overlay, not a window: a scene panel dragged over
2018 // the same corner must not cover it, so it sits in the layer above the
2019 // one windows are drawn in.
2020 .order(egui::Order::Foreground)
2021 .show(ui.ctx(), |ui| {
2022 egui::Frame::popup(ui.style()).show(ui, |ui| {
2023 ui.add(egui::Label::new(egui::RichText::new(text).monospace()).extend());
2024 });
2025 });
2026}
2027
2028/// The device facts the GPU-info panel shows, read once when it is mounted.
2029///
2030/// Reading them once rather than every frame keeps the panel's per-frame pass
2031/// free of work the device cannot change: an adapter's name, the features it
2032/// was created with and the limits it reports are fixed for the lifetime of
2033/// the device, so the strings are built where the device is in hand and the
2034/// pass only lays them out.
2035struct GpuInfo {
2036 /// The adapter's name, shown as the panel's title.
2037 name: String,
2038 /// One line naming the backend, the kind of device and the driver.
2039 summary: String,
2040 /// The rest of the adapter's description, as label/value pairs.
2041 rows: Vec<(String, String)>,
2042 /// The names of the features the device was created with.
2043 features: Vec<&'static str>,
2044 /// The limits the device reports, as field-name/value pairs.
2045 limits: Vec<(String, String)>,
2046}
2047
2048impl GpuInfo {
2049 /// Read `device`'s adapter description, enabled features and limits.
2050 fn read(device: &wgpu::Device) -> Self {
2051 let info = device.adapter_info();
2052 let features = device.features();
2053 let limits = device.limits();
2054
2055 // The field names are the panel's labels, so they are spelled once and
2056 // `stringify!`d rather than repeated as string literals that could drift
2057 // from the fields they name.
2058 Self {
2059 name: info.name.clone(),
2060 summary: format!(
2061 "{} · {} · {}",
2062 info.backend.to_str(),
2063 device_type_name(info.device_type),
2064 info.driver,
2065 ),
2066 rows: vec![
2067 ("driver info".to_owned(), info.driver_info.clone()),
2068 ("vendor".to_owned(), format!("{:#06x}", info.vendor)),
2069 ("device".to_owned(), format!("{:#06x}", info.device)),
2070 ("pci bus".to_owned(), info.device_pci_bus_id.clone()),
2071 (
2072 "subgroup".to_owned(),
2073 format!("{}..{}", info.subgroup_min_size, info.subgroup_max_size),
2074 ),
2075 (
2076 "transient saves memory".to_owned(),
2077 match info.transient_saves_memory {
2078 Some(saves) => saves.to_string(),
2079 None => "unknown".to_owned(),
2080 },
2081 ),
2082 ],
2083 features: features.iter_names().map(|(name, _)| name).collect(),
2084 limits: limit_rows(&limits),
2085 }
2086 }
2087}
2088
2089/// The device's limits as label/value pairs, in the order the device reports
2090/// them.
2091///
2092/// A [`wgpu::Limits`] is serialisable, so the labels and values come from its
2093/// own serde description: the field names are the JSON object's keys and the
2094/// values are its entries. Nothing here names a limit, so a field wgpu adds is
2095/// shown without a change, and a field it renames is not silently dropped.
2096///
2097/// The keys come out in the JSON object's order, which serde_json keeps sorted
2098/// rather than in declaration order; the list is stable and complete either
2099/// way, and a reader looking a limit up benefits from the order.
2100///
2101/// Falls back to an empty list rather than failing: a panel that loses its
2102/// limits is a cosmetic loss, and serialising a plain struct of integers has
2103/// no way to fail.
2104fn limit_rows(limits: &wgpu::Limits) -> Vec<(String, String)> {
2105 let Ok(serde_json::Value::Object(fields)) = serde_json::to_value(limits) else {
2106 return Vec::new();
2107 };
2108 fields
2109 .into_iter()
2110 .map(|(name, value)| (name, json_scalar(&value)))
2111 .collect()
2112}
2113
2114/// One limit's value as text.
2115///
2116/// Every field of a [`wgpu::Limits`] is an integer, so a scalar is all there
2117/// is to render; the fallback covers a future field of another shape without
2118/// hiding it.
2119fn json_scalar(value: &serde_json::Value) -> String {
2120 match value {
2121 serde_json::Value::Number(number) => number.to_string(),
2122 other => other.to_string(),
2123 }
2124}
2125
2126/// The name of a [`wgpu::DeviceType`], for the panel's summary line.
2127///
2128/// [`wgpu::DeviceType`] has no `Display` impl, so its names are spelled here.
2129fn device_type_name(device_type: wgpu::DeviceType) -> &'static str {
2130 match device_type {
2131 wgpu::DeviceType::Other => "other",
2132 wgpu::DeviceType::IntegratedGpu => "integrated GPU",
2133 wgpu::DeviceType::DiscreteGpu => "discrete GPU",
2134 wgpu::DeviceType::VirtualGpu => "virtual GPU",
2135 wgpu::DeviceType::Cpu => "CPU",
2136 }
2137}
2138
2139/// Where the GPU-info panel opens when no position was carried over.
2140///
2141/// Like the selector, it is seeded by the corner it belongs in — here the
2142/// bottom-left, one overlay margin in — so that corner stays put as the
2143/// content decides the panel's size; a panel the user has dragged is carried
2144/// by its whole rectangle, which a rebuild restores exactly.
2145fn gpu_info_default_pos(ctx: &egui::Context) -> egui::Pos2 {
2146 ctx.content_rect().left_bottom() + egui::Vec2::new(OVERLAY_MARGIN, -OVERLAY_MARGIN)
2147}
2148
2149/// Mount the GPU-info panel, a draggable window in the bottom-left corner.
2150///
2151/// A window rather than an anchored area so the user can move it out of the
2152/// way. It is an ordinary window, in the layer the scene's own windows are
2153/// drawn in: the title bar then highlights exactly when it is the window on
2154/// top, the same as the selector's, and a scene panel dragged over it covers
2155/// it the same way.
2156///
2157/// The device is read here, once, where it is in hand; the panel then draws
2158/// from the strings that read produced.
2159fn mount_gpu_info(
2160 world: &mut World,
2161 ui: Entity,
2162 device: wgpu::Device,
2163 initial: Option<egui::Rect>,
2164) {
2165 let info = GpuInfo::read(&device);
2166 world
2167 .scope_mut(ui)
2168 .expect("the UI source is alive")
2169 .spawn(bundle![UiPanel::new(move |_world, _entity, ui| {
2170 gpu_info_panel(ui, &info, initial);
2171 },),]);
2172}
2173
2174/// One line of the GPU-info panel.
2175///
2176/// The panel sizes itself from these lines and then draws the same values, so a
2177/// line's wording and the font it is drawn in are chosen once: a width measured
2178/// from a differently-styled string would not be the width the panel draws with.
2179enum GpuInfoLine {
2180 /// The adapter's name or its one-line description, above the separator.
2181 Header(egui::WidgetText),
2182 /// A heading that opens a group of facts, set off by a gap above it.
2183 Heading(egui::WidgetText),
2184 /// One fact.
2185 Fact(egui::WidgetText),
2186}
2187
2188impl GpuInfoLine {
2189 /// The line's text and styling, as it is both measured and drawn.
2190 fn text(&self) -> &egui::WidgetText {
2191 match self {
2192 Self::Header(text) | Self::Heading(text) | Self::Fact(text) => text,
2193 }
2194 }
2195
2196 /// Whether the line is part of the header, above the separating rule.
2197 fn is_header(&self) -> bool {
2198 matches!(self, Self::Header(_))
2199 }
2200
2201 /// Whether the line opens a section, which is set off by a gap above it.
2202 fn is_heading(&self) -> bool {
2203 matches!(self, Self::Heading(_))
2204 }
2205}
2206
2207/// The lines the GPU-info panel shows, top to bottom.
2208///
2209/// The wording lives here and nowhere else: the width measurement and the
2210/// drawing both walk this list, so a fact cannot be measured as one string and
2211/// drawn as another, nor measured in one font and drawn in another.
2212fn gpu_info_lines(info: &GpuInfo) -> Vec<GpuInfoLine> {
2213 let mut lines = Vec::new();
2214 lines.push(GpuInfoLine::Header(
2215 egui::RichText::new(&info.name).strong().into(),
2216 ));
2217 lines.push(GpuInfoLine::Header(
2218 egui::RichText::new(&info.summary).monospace().into(),
2219 ));
2220 lines.push(GpuInfoLine::Heading(
2221 egui::RichText::new("Adapter").strong().into(),
2222 ));
2223 for (label, value) in &info.rows {
2224 lines.push(GpuInfoLine::Fact(format!("{label}: {value}").into()));
2225 }
2226 lines.push(GpuInfoLine::Heading(
2227 egui::RichText::new(format!("Features ({})", info.features.len()))
2228 .strong()
2229 .into(),
2230 ));
2231 if info.features.is_empty() {
2232 lines.push(GpuInfoLine::Fact("none".into()));
2233 } else {
2234 for feature in &info.features {
2235 lines.push(GpuInfoLine::Fact((*feature).into()));
2236 }
2237 }
2238 lines.push(GpuInfoLine::Heading(
2239 egui::RichText::new(format!("Limits ({})", info.limits.len()))
2240 .strong()
2241 .into(),
2242 ));
2243 for (name, value) in &info.limits {
2244 lines.push(GpuInfoLine::Fact(format!("{name} = {value}").into()));
2245 }
2246 lines
2247}
2248
2249/// How wide the GPU-info panel has to be to keep every line on one row.
2250///
2251/// The facts are strings the device chose, and its own names for its limits run
2252/// past fifty characters, so a fixed opening width would wrap them. Each line is
2253/// laid out unwrapped as it will be drawn, the widest one sets the content
2254/// width, and the window's frame and the body's scroll bar are added back on:
2255/// the panel's size, unlike the text's, includes both.
2256///
2257/// A free function rather than part of the panel so a test can ask what width a
2258/// given set of lines needs without drawing a pass.
2259fn gpu_info_width(ui: &egui::Ui, lines: &[GpuInfoLine]) -> f32 {
2260 let mut widest: f32 = 0.0;
2261 for line in lines {
2262 let width = line
2263 .text()
2264 .clone()
2265 .into_galley(
2266 ui,
2267 Some(egui::TextWrapMode::Extend),
2268 0.0,
2269 egui::FontSelection::Default,
2270 )
2271 .size()
2272 .x;
2273 widest = widest.max(width);
2274 }
2275 let style = ui.style();
2276 let frame = egui::Frame::window(style).total_margin().sum().x;
2277 let scroll_bar = style.spacing.scroll.allocated_width();
2278 widest + frame + scroll_bar + GPU_INFO_WIDTH_SLACK
2279}
2280
2281/// Draw the GPU-info panel for `info`, seeded at `initial` or the bottom-left
2282/// corner of `ui`'s screen.
2283///
2284/// A free function rather than the body of the panel's closure so a test can
2285/// drive it against a bare [`egui::Context`]: what it has to get right — where
2286/// it opens, the width it takes from its facts and the facts it prints — is a
2287/// property of the widget tree, not of the world.
2288fn gpu_info_panel(ui: &mut egui::Ui, info: &GpuInfo, initial: Option<egui::Rect>) {
2289 // The id is set explicitly, as in the selector: `Window::new` would derive
2290 // it from the title text, and the shell reads the remembered rectangle back
2291 // under this id.
2292 //
2293 // The panel is resizable in both directions, like the selector and the
2294 // scene's own windows: the limits alone are several dozen rows, and how
2295 // much of the scene the panel covers while they are read is the reader's
2296 // to decide. It opens as wide as its facts need, and dragging it narrower
2297 // is what wraps them.
2298 let id = egui::Id::unique(GPU_INFO_AREA);
2299 // The panel belongs to the bottom-left corner, so it is seeded and kept by
2300 // that corner: a fresh one opens there, a carried rectangle is seeded by its
2301 // own bottom-left and given its size, and `follow_screen_corner` moves it
2302 // when the screen changes shape.
2303 let pivot = egui::Align2::LEFT_BOTTOM;
2304 let window = egui::Window::new(GPU_INFO_TITLE)
2305 .id(id)
2306 .pivot(pivot)
2307 .min_width(GPU_INFO_MIN_WIDTH)
2308 .max_width(ui.ctx().content_rect().width() * GPU_INFO_MAX_WIDTH_FRACTION);
2309 let lines = gpu_info_lines(info);
2310 // The lines above the rule are the header; the rest are the facts, which
2311 // scroll, so the two runs are drawn into different places from the one
2312 // list.
2313 let header = lines.iter().take_while(|line| line.is_header()).count();
2314 let window = match initial {
2315 Some(rect) => window
2316 .default_pos(rect.lerp_inside(pivot_factor(pivot)))
2317 .default_size(rect.size()),
2318 None => window
2319 // The size is given as well as the position: an area is laid out
2320 // against the size it had before the first pass measures it, and
2321 // egui's default area size is wider than a narrow window, which
2322 // would push the seeded corner inward when the area is constrained
2323 // to the screen.
2324 .default_size(egui::Vec2::new(
2325 gpu_info_width(ui, &lines),
2326 ui.ctx().content_rect().height() * GPU_INFO_HEIGHT_FRACTION,
2327 ))
2328 .default_pos(gpu_info_default_pos(ui.ctx())),
2329 };
2330 // A titled window that is movable discards `current_pos`: egui re-applies
2331 // the pivot it had stored before the area began, so the position given here
2332 // would not stick. The reposition pass therefore also turns the window
2333 // immovable; every other pass is movable again and a user's drag works as
2334 // before.
2335 let window = match follow_screen_corner(ui.ctx(), id, pivot) {
2336 Some(pos) => window.current_pos(pos).movable(false),
2337 None => window,
2338 };
2339 window.show(ui.ctx(), |ui| {
2340 let (header_lines, body_lines) = lines.split_at(header);
2341 ui.vertical(|ui| {
2342 for line in header_lines {
2343 ui.label(line.text().clone());
2344 }
2345 ui.separator();
2346 egui::ScrollArea::vertical()
2347 .max_height(ui.ctx().content_rect().height() * GPU_INFO_HEIGHT_FRACTION)
2348 // Fill the panel's width so the facts wrap within it instead of
2349 // the panel sizing itself to the widest one; shrink to the
2350 // content's height so a device with few limits gets a short
2351 // panel, and dragging it taller then does not pad it out with
2352 // empty space.
2353 .auto_shrink([false, true])
2354 .show(ui, |ui| {
2355 for line in body_lines {
2356 if line.is_heading() {
2357 ui.add_space(OVERLAY_GAP);
2358 }
2359 ui.label(line.text().clone());
2360 }
2361 });
2362 });
2363 });
2364}
2365
2366/// Mount the fullscreen button in the window's top-right corner.
2367///
2368/// A button rather than the old "the first press asks for fullscreen": a press
2369/// on the canvas is not discoverable — a phone shows no hint that it would
2370/// work, and nothing tells a user who left fullscreen that they may ask again —
2371/// whereas a button is visible on every platform and keeps working after the
2372/// browser has left fullscreen. Nothing about the request is latched, so the
2373/// display can be entered and left as often as the button is pressed.
2374///
2375/// The click is recorded rather than served here: a panel runs while the world
2376/// is borrowed, and it has no window to ask; the frame loop reads the request
2377/// and calls [`fullscreen::toggle`], which is where a browser accepts it from.
2378fn mount_fullscreen(world: &mut World, ui: Entity, request: Entity, state: Entity) {
2379 world
2380 .scope_mut(ui)
2381 .expect("the UI source is alive")
2382 .spawn(bundle![UiPanel::new(move |world, _entity, ui| {
2383 let active = world
2384 .query_one::<&FullscreenActive>(state)
2385 .is_ok_and(|mut state| state.get().0);
2386 if fullscreen_button(ui, active) {
2387 let _ = world
2388 .query_one::<&mut FullscreenRequest>(request)
2389 .map(|mut request| request.get().0 = true);
2390 }
2391 },),]);
2392}
2393
2394/// Mount the link to the API docs in the window's top-right corner.
2395///
2396/// The link lives in the UI rather than in the page so the two corner overlays
2397/// are laid out by the same code and cannot collide: the page's own link knew
2398/// nothing about the button, which is what put the two on top of each other.
2399/// Mounted after [`mount_fullscreen`], because the link is placed under the
2400/// button it measures.
2401///
2402/// The click is recorded rather than served here: a panel runs while the world
2403/// is borrowed and has no page to reach; the frame loop reads the request and
2404/// opens it with [`webbrowser`], which covers every platform the example runs
2405/// on.
2406fn mount_api_docs(world: &mut World, ui: Entity) {
2407 world
2408 .scope_mut(ui)
2409 .expect("the UI source is alive")
2410 .spawn(bundle![UiPanel::new(|_world, _entity, ui| {
2411 api_docs_button(ui);
2412 }),]);
2413}
2414
2415/// The vertical offset a corner button's anchor is drawn at, given the area
2416/// above it in the same corner.
2417///
2418/// `above` and the button share the top row when the window is wide enough for
2419/// both to sit side by side. When it is not — the frame-rate readout is
2420/// centred, so on a portrait phone it reaches into the corner the fullscreen
2421/// button anchors to — the button drops to the row under `above` instead:
2422/// hiding the end of the reading under the button that is meant to sit beside
2423/// it on a roomier window does not help. The fallback keeps both legible and
2424/// separated at any width.
2425///
2426/// Both areas are anchored, and an anchored area is laid out against the
2427/// rectangle the previous pass remembered for it — which is what is compared
2428/// here, so the decision is made about the very positions egui will use.
2429fn overlay_button_top(ctx: &egui::Context, above: &str, id: &str) -> f32 {
2430 let above = ctx.memory(|memory| memory.area_rect(egui::Id::unique(above)));
2431 let button = ctx.memory(|memory| memory.area_rect(egui::Id::unique(id)));
2432 match (above, button) {
2433 // The area above stops clear of the button's column.
2434 (Some(above), Some(button)) if above.right() + OVERLAY_GAP < button.left() => {
2435 OVERLAY_MARGIN
2436 }
2437 // The area above reaches into the button's corner: stack beneath it.
2438 (Some(above), _) => above.bottom() + OVERLAY_GAP,
2439 // The button is the first thing drawn; the top row is where it belongs.
2440 (None, _) => OVERLAY_MARGIN,
2441 }
2442}
2443
2444/// Draw a button that owns a corner, and report whether it was pressed.
2445///
2446/// `id` names the area, so the button can be read back — the docs link is
2447/// placed under the fullscreen button by measuring it — and so two corners can
2448/// be tested apart. A free function rather than the body of a panel's closure
2449/// so a test can drive it against a bare [`egui::Context`].
2450fn corner_button(ui: &mut egui::Ui, id: &str, top: f32, label: egui::RichText) -> bool {
2451 egui::Area::new(egui::Id::unique(id))
2452 .anchor(egui::Align2::RIGHT_TOP, [-OVERLAY_MARGIN, top])
2453 // A corner button is an overlay, not a window: a scene panel dragged
2454 // over the corner must not cover it, so it sits in the layer above the
2455 // one windows are drawn in.
2456 .order(egui::Order::Foreground)
2457 .show(ui.ctx(), |ui| {
2458 egui::Frame::popup(ui.style())
2459 .show(ui, |ui| {
2460 ui.style_mut().spacing.button_padding = OVERLAY_BUTTON_PADDING;
2461 ui.add(
2462 egui::Button::new(label)
2463 .wrap_mode(egui::TextWrapMode::Extend)
2464 .min_size(OVERLAY_BUTTON_MIN_SIZE),
2465 )
2466 .clicked()
2467 })
2468 .inner
2469 })
2470 .inner
2471}
2472
2473/// Draw the fullscreen button, labelled for what pressing it does, and report
2474/// whether it was pressed.
2475///
2476/// The label follows the document rather than a local flag: the user can leave
2477/// fullscreen with the browser's own control — Escape, or the back gesture on a
2478/// phone — and the button has to offer to go back in afterwards. That is also
2479/// why it owns its corner rather than sharing a row with the readout: each
2480/// anchored area keeps to itself, and the button is only drawn on top of the
2481/// readout's line when the two would otherwise collide.
2482///
2483/// The glyphs are drawn from the default font — ⛶ to enter and ⏏ to leave —
2484/// with a word beside each so the meaning does not rest on a symbol alone. Both
2485/// labels are laid out to extend rather than wrap: the exit label is the wider
2486/// of the two, and an anchored area lays its contents out against the size it
2487/// remembered from the previous pass, so switching to it would otherwise break
2488/// it across two lines in the narrower area the enter label had established.
2489fn fullscreen_button(ui: &mut egui::Ui, active: bool) -> bool {
2490 let label = if active {
2491 "⏏ Exit fullscreen"
2492 } else {
2493 "⛶ Fullscreen"
2494 };
2495 let top = overlay_button_top(ui.ctx(), FRAME_RATE_AREA, FULLSCREEN_AREA);
2496 corner_button(
2497 ui,
2498 FULLSCREEN_AREA,
2499 top,
2500 egui::RichText::new(label).monospace(),
2501 )
2502}
2503
2504/// Draw the API-docs link under the fullscreen button, and report whether it
2505/// was pressed.
2506///
2507/// The click becomes egui's own URL command, which the UI source publishes in
2508/// the world for the frame loop to serve, rather than opening the page here: a
2509/// panel runs while the world is borrowed and cannot wait on one.
2510fn api_docs_button(ui: &mut egui::Ui) -> bool {
2511 let top = overlay_button_top(ui.ctx(), FULLSCREEN_AREA, API_DOCS_AREA);
2512 let label = egui::RichText::new(API_DOCS_LABEL).monospace();
2513 let clicked = corner_button(ui, API_DOCS_AREA, top, label);
2514 if clicked {
2515 ui.ctx().open_url(egui::OpenUrl::same_tab(API_DOCS_URL));
2516 }
2517 clicked
2518}
2519
2520/// The region a scene's content is drawn into and the size its camera is built
2521/// for, for a `target`-pixel render target.
2522///
2523/// `baseline` is the aspect to keep, or `None` for a scene with no aspect of
2524/// its own — a UI-only one — which draws into the whole target. `letterbox`
2525/// turns the whole mechanism off, which is what a capture wants: its
2526/// captures are the scene's own size and must stay comparable with the stored
2527/// snapshots.
2528fn letterbox(
2529 baseline: Option<(u32, u32)>,
2530 target: (u32, u32),
2531 letterbox: bool,
2532) -> (Option<ViewportRect>, (u32, u32)) {
2533 match baseline.filter(|_| letterbox) {
2534 Some(baseline) => (
2535 Some(scenes::baseline_viewport(baseline, target)),
2536 scenes::content_size(baseline, target),
2537 ),
2538 None => (None, target),
2539 }
2540}
2541
2542/// Accumulate `delta_time` into `clock` and report whether a `step`-long
2543/// interval has passed, subtracting it from the clock if so.
2544///
2545/// At most one interval is consumed per call, so a long stall advances a
2546/// fixed-sequence scene by one frame rather than skipping several. The
2547/// leftover stays in the clock, so the sequence keeps pace over time.
2548fn tick(clock: &mut f32, step: f32, delta_time: f32) -> bool {
2549 *clock += delta_time;
2550 if *clock >= step {
2551 *clock -= step;
2552 true
2553 } else {
2554 false
2555 }
2556}
2557
2558#[cfg(test)]
2559mod tests {
2560 use super::{
2561 API_DOCS_AREA, API_DOCS_URL, FRAME_RATE_AREA, FULLSCREEN_AREA, FrameRate, GPU_INFO_AREA,
2562 GPU_INFO_MIN_WIDTH, GpuInfo, OVERLAY_BUTTON_MIN_SIZE, OVERLAY_MARGIN, api_docs_button,
2563 egui, frame_rate_readout, fullscreen_button, gpu_info_panel, letterbox, limit_rows, tick,
2564 };
2565 // The scene-switch test below drives the app's own slots, so it is the only
2566 // part of this module that needs these names.
2567 #[cfg(not(target_arch = "wasm32"))]
2568 use super::{InitialScene, PresenterEntity, SceneEntity, scenes, switch_scene};
2569 #[cfg(not(target_arch = "wasm32"))]
2570 use unlit_ecs::bundle;
2571 #[cfg(not(target_arch = "wasm32"))]
2572 use unlit3d::prelude::World;
2573
2574 /// A scene switch drops the presentation path, which was built around the
2575 /// scene it replaced.
2576 ///
2577 /// The path's surface and the pass that presents through it belong to the
2578 /// dying scene's world, so the rebuilt scene builds a fresh one instead of
2579 /// presenting through a pass that went with the world it drew.
2580 #[cfg(not(target_arch = "wasm32"))]
2581 #[test]
2582 fn a_scene_switch_drops_the_presentation_path() {
2583 let mut world = World::new();
2584
2585 let old_scene = world.root_scope_mut().spawn(bundle![]);
2586 let old_presenter = world.root_scope_mut().spawn(bundle![]);
2587 let app = world.root_scope_mut().spawn(bundle![
2588 InitialScene(scenes::default()),
2589 PresenterEntity(Some(old_presenter)),
2590 SceneEntity(Some(old_scene)),
2591 ]);
2592
2593 let next = scenes::by_id("ui_only").expect("the example ships a UI-only scene");
2594 switch_scene(&world, app, next);
2595 world.apply().unwrap();
2596
2597 assert!(
2598 world
2599 .root_scope()
2600 .query_one::<&PresenterEntity>(app)
2601 .expect("the app keeps its presentation slot")
2602 .get()
2603 .0
2604 .is_none(),
2605 "the presentation path went with the scene it presented"
2606 );
2607 assert!(
2608 !world.root_scope().contains(old_presenter),
2609 "the presentation path's entity was despawned"
2610 );
2611 assert!(
2612 !world.root_scope().contains(old_scene),
2613 "the old scene was despawned"
2614 );
2615 assert_eq!(
2616 world
2617 .root_scope()
2618 .query_one::<&InitialScene>(app)
2619 .expect("the app keeps its initial scene")
2620 .get()
2621 .0
2622 .id,
2623 "ui_only"
2624 );
2625 }
2626
2627 /// An egui input for a `screen`-point screen carrying `events`.
2628 fn raw_input(screen: (f32, f32), events: Vec<egui::Event>) -> egui::RawInput {
2629 egui::RawInput {
2630 screen_rect: Some(egui::Rect::from_min_size(
2631 egui::Pos2::ZERO,
2632 egui::Vec2::new(screen.0, screen.1),
2633 )),
2634 events,
2635 ..Default::default()
2636 }
2637 }
2638
2639 /// Run one egui pass over a `screen`-point screen with `events`, drawing the
2640 /// fullscreen button in the state `active`, and report what it drew.
2641 ///
2642 /// What the pass drew is both what pressing the button did — the returned
2643 /// flag — and every galley it laid out, which is how a test reads the
2644 /// label without reaching into egui's internals.
2645 fn fullscreen_pass(
2646 ctx: &egui::Context,
2647 screen: (f32, f32),
2648 active: bool,
2649 events: Vec<egui::Event>,
2650 ) -> (bool, Vec<String>) {
2651 let input = egui::RawInput {
2652 screen_rect: Some(egui::Rect::from_min_size(
2653 egui::Pos2::ZERO,
2654 egui::Vec2::new(screen.0, screen.1),
2655 )),
2656 events,
2657 ..Default::default()
2658 };
2659 let mut clicked = false;
2660 let mut output = ctx.run_ui(input, |ui| {
2661 clicked = fullscreen_button(ui, active);
2662 });
2663 // egui panics if a texture delta is dropped unapplied; a real frame
2664 // hands it to the integration, and this test discards it.
2665 output.textures_delta.clear();
2666 let labels = output
2667 .shapes
2668 .iter()
2669 .filter_map(|clipped| match &clipped.shape {
2670 egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
2671 _ => None,
2672 })
2673 .collect();
2674 (clicked, labels)
2675 }
2676
2677 /// The centre of the area with `id`, which a previous pass has to have
2678 /// drawn for this to answer.
2679 fn area_centre(ctx: &egui::Context, id: &str) -> egui::Pos2 {
2680 area_rect(ctx, id).center()
2681 }
2682
2683 /// The rectangle of the area with `id`, which a previous pass has to have
2684 /// drawn for this to answer.
2685 fn area_rect(ctx: &egui::Context, id: &str) -> egui::Rect {
2686 ctx.memory(|memory| {
2687 memory
2688 .area_rect(egui::Id::unique(id))
2689 .expect("the area was drawn")
2690 })
2691 }
2692
2693 /// The pointer events that begin a drag at `pos`: the pointer arrives and
2694 /// the button goes down.
2695 fn drag_start(pos: egui::Pos2) -> Vec<egui::Event> {
2696 vec![
2697 egui::Event::PointerMoved(pos),
2698 egui::Event::PointerButton {
2699 pos,
2700 button: egui::PointerButton::Primary,
2701 pressed: true,
2702 modifiers: egui::Modifiers::default(),
2703 },
2704 ]
2705 }
2706
2707 /// The pointer events that move a held pointer to `to`. The movement has
2708 /// to be a pass of its own: egui measures a drag as the delta between two
2709 /// passes, not between two events of one.
2710 fn drag_move(to: egui::Pos2) -> Vec<egui::Event> {
2711 vec![egui::Event::PointerMoved(to)]
2712 }
2713
2714 /// The pointer events that release a held pointer at `pos`.
2715 fn drag_release(pos: egui::Pos2) -> Vec<egui::Event> {
2716 vec![egui::Event::PointerButton {
2717 pos,
2718 button: egui::PointerButton::Primary,
2719 pressed: false,
2720 modifiers: egui::Modifiers::default(),
2721 }]
2722 }
2723
2724 /// The pointer events that tap `pos`: a press and, on a later pass, the
2725 /// release that egui reports as a click.
2726 fn tap(pos: egui::Pos2) -> (Vec<egui::Event>, Vec<egui::Event>) {
2727 let button = |pressed| egui::Event::PointerButton {
2728 pos,
2729 button: egui::PointerButton::Primary,
2730 pressed,
2731 modifiers: egui::Modifiers::default(),
2732 };
2733 (
2734 vec![egui::Event::PointerMoved(pos), button(true)],
2735 vec![button(false)],
2736 )
2737 }
2738
2739 /// Run one egui pass over a `screen`-point screen with `events`, drawing
2740 /// the GPU-info panel for `info` where `initial` says, and report every
2741 /// galley it laid out.
2742 fn gpu_info_pass(
2743 ctx: &egui::Context,
2744 screen: (f32, f32),
2745 info: &GpuInfo,
2746 initial: Option<egui::Rect>,
2747 events: Vec<egui::Event>,
2748 ) -> Vec<String> {
2749 let input = egui::RawInput {
2750 screen_rect: Some(egui::Rect::from_min_size(
2751 egui::Pos2::ZERO,
2752 egui::Vec2::new(screen.0, screen.1),
2753 )),
2754 events,
2755 ..Default::default()
2756 };
2757 let mut output = ctx.run_ui(input, |ui| {
2758 gpu_info_panel(ui, info, initial);
2759 });
2760 // egui panics if a texture delta is dropped unapplied; a real frame
2761 // hands it to the integration, and this test discards it.
2762 output.textures_delta.clear();
2763 output
2764 .shapes
2765 .iter()
2766 .filter_map(|clipped| match &clipped.shape {
2767 egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
2768 _ => None,
2769 })
2770 .collect()
2771 }
2772
2773 /// Run one egui pass over a `screen`-point screen, drawing the GPU-info
2774 /// panel for `info`, and report every galley it laid out with the number of
2775 /// rows it was broken into. A fact on a single row is a fact the panel was
2776 /// wide enough to keep on one line.
2777 fn gpu_info_rows(
2778 ctx: &egui::Context,
2779 screen: (f32, f32),
2780 info: &GpuInfo,
2781 initial: Option<egui::Rect>,
2782 ) -> Vec<(String, usize)> {
2783 let mut output = ctx.run_ui(raw_input(screen, Vec::new()), |ui| {
2784 gpu_info_panel(ui, info, initial);
2785 });
2786 // egui panics if a texture delta is dropped unapplied; a real frame
2787 // hands it to the integration, and this test discards it.
2788 output.textures_delta.clear();
2789 output
2790 .shapes
2791 .iter()
2792 .filter_map(|clipped| match &clipped.shape {
2793 egui::Shape::Text(text) => {
2794 Some((text.galley.text().to_owned(), text.galley.rows.len()))
2795 }
2796 _ => None,
2797 })
2798 .collect()
2799 }
2800
2801 #[test]
2802 fn the_limit_list_comes_from_the_limits_own_description() {
2803 // The labels are wgpu's own field names, so the test names the ones the
2804 // type is known to carry rather than a copy of the whole list.
2805 let rows = limit_rows(&wgpu::Limits::defaults());
2806 assert!(
2807 rows.len() > 50,
2808 "a limits struct has dozens of fields, got {}",
2809 rows.len()
2810 );
2811 for expected in [
2812 ("maxBindGroups", "4"),
2813 ("maxTextureDimension1D", "8192"),
2814 ("maxBufferSize", "268435456"),
2815 ] {
2816 assert!(
2817 rows.iter()
2818 .any(|(name, value)| name == expected.0 && value == expected.1),
2819 "the limits do not show {expected:?}"
2820 );
2821 }
2822 }
2823
2824 /// The facts a GPU-info panel is asked to show, small enough to read.
2825 fn sample_gpu_info() -> GpuInfo {
2826 GpuInfo {
2827 name: "Test Adapter".to_owned(),
2828 summary: "vulkan · discrete GPU · test driver".to_owned(),
2829 rows: vec![("driver info".to_owned(), "1.2.3".to_owned())],
2830 features: vec!["DEPTH_CLIP_CONTROL", "TEXTURE_COMPRESSION_BC"],
2831 limits: vec![("max_bind_groups".to_owned(), "4".to_owned())],
2832 }
2833 }
2834
2835 #[test]
2836 fn the_gpu_info_panel_opens_in_the_bottom_left_corner() {
2837 // The panel is an overlay on the scene, so the first build has to put
2838 // it in its corner and keep it inside the window.
2839 for screen in [(1280.0, 577.0), (800.0, 600.0), (390.0, 844.0)] {
2840 let ctx = egui::Context::default();
2841 let info = sample_gpu_info();
2842 // Two passes: the first measures the content, the second lays the
2843 // window out against that measurement.
2844 let _ = gpu_info_pass(&ctx, screen, &info, None, Vec::new());
2845 let _ = gpu_info_pass(&ctx, screen, &info, None, Vec::new());
2846
2847 let panel = area_rect(&ctx, GPU_INFO_AREA);
2848 assert!(
2849 (panel.left() - OVERLAY_MARGIN).abs() < 1.0,
2850 "at {screen:?} the panel {panel:?} does not keep the left margin"
2851 );
2852 assert!(
2853 (screen.1 - panel.bottom() - OVERLAY_MARGIN).abs() < 1.0,
2854 "at {screen:?} the panel {panel:?} does not keep the bottom margin"
2855 );
2856 assert!(
2857 panel.right() <= screen.0,
2858 "at {screen:?} the panel {panel:?} runs off the right"
2859 );
2860 assert!(
2861 panel.width() >= GPU_INFO_MIN_WIDTH,
2862 "at {screen:?} the panel {panel:?} is narrower than its floor"
2863 );
2864 }
2865 }
2866
2867 #[test]
2868 fn the_gpu_info_panel_follows_its_corner_when_the_screen_changes_shape() {
2869 // The panel opens against the bottom-left corner. When the display
2870 // rotates, the frame becomes a different shape and egui only clamps the
2871 // remembered rectangle, so the panel has to be moved by how far that
2872 // corner moved — otherwise it stays where the old, now-rotated screen
2873 // put it.
2874 let ctx = egui::Context::default();
2875 let info = sample_gpu_info();
2876 for (from, to) in [
2877 ((1280.0, 577.0), (577.0, 1280.0)),
2878 ((800.0, 600.0), (600.0, 800.0)),
2879 ] {
2880 // Two passes: the first measures the content, the second lays the
2881 // window out against that measurement.
2882 let _ = gpu_info_pass(&ctx, from, &info, None, Vec::new());
2883 let _ = gpu_info_pass(&ctx, from, &info, None, Vec::new());
2884
2885 let _ = gpu_info_pass(&ctx, to, &info, None, Vec::new());
2886 let panel = area_rect(&ctx, GPU_INFO_AREA);
2887 assert!(
2888 (panel.left() - OVERLAY_MARGIN).abs() < 1.0
2889 && (to.1 - panel.bottom() - OVERLAY_MARGIN).abs() < 1.0,
2890 "after {from:?} -> {to:?} the panel {panel:?} left its corner"
2891 );
2892 }
2893 }
2894
2895 #[test]
2896 fn the_gpu_info_panel_opens_wide_enough_for_its_facts() {
2897 // A limit name is the device's own, and some run past fifty characters.
2898 // A panel that opened at a fixed width would wrap them, so every fact
2899 // has to come out on one row.
2900 let info = sample_gpu_info();
2901 for screen in [(1280.0, 577.0), (800.0, 600.0), (390.0, 844.0)] {
2902 let ctx = egui::Context::default();
2903 // Two passes: the first measures the content, the second lays the
2904 // window out against that measurement.
2905 let _ = gpu_info_rows(&ctx, screen, &info, None);
2906 for (text, rows) in gpu_info_rows(&ctx, screen, &info, None) {
2907 assert_eq!(
2908 rows, 1,
2909 "at {screen:?} the panel wrapped {text:?} onto {rows} rows"
2910 );
2911 }
2912 }
2913 }
2914
2915 #[test]
2916 fn the_gpu_info_panel_wraps_a_fact_wider_than_the_window() {
2917 // The panel's opening width comes from its facts, so a fact wider than
2918 // the window cannot be kept on one row: it is wrapped inside the window
2919 // instead of the panel hanging off the edge.
2920 let mut info = sample_gpu_info();
2921 info.limits
2922 .push(("max_long_limit_name".to_owned(), "x".repeat(200)));
2923 let screen = (800.0, 600.0);
2924 let ctx = egui::Context::default();
2925 let _ = gpu_info_rows(&ctx, screen, &info, None);
2926 let lines = gpu_info_rows(&ctx, screen, &info, None);
2927
2928 let panel = area_rect(&ctx, GPU_INFO_AREA);
2929 assert!(
2930 panel.right() <= screen.0,
2931 "the panel {panel:?} runs off the right of the {screen:?} screen"
2932 );
2933 assert!(
2934 lines
2935 .iter()
2936 .any(|(text, rows)| text.starts_with("max_long_limit_name") && *rows > 1),
2937 "the oversized fact was not wrapped inside the window"
2938 );
2939 }
2940
2941 #[test]
2942 fn the_gpu_info_panel_can_be_dragged_by_its_title() {
2943 // The panel is a window, not a pinned overlay: a user has to be able to
2944 // move it off whatever it covers. The drag is a gesture spread over
2945 // several passes, as egui sees a real one.
2946 let ctx = egui::Context::default();
2947 let info = sample_gpu_info();
2948 let screen = (800.0, 600.0);
2949 let _ = gpu_info_pass(&ctx, screen, &info, None, Vec::new());
2950 let _ = gpu_info_pass(&ctx, screen, &info, None, Vec::new());
2951 let opened = area_rect(&ctx, GPU_INFO_AREA);
2952
2953 // Grab the title bar and pull the window up and to the right.
2954 let from = egui::pos2(opened.center().x, opened.top() + 8.0);
2955 let to = from + egui::vec2(60.0, -40.0);
2956 let _ = gpu_info_pass(&ctx, screen, &info, None, drag_start(from));
2957 let _ = gpu_info_pass(&ctx, screen, &info, None, drag_move(to));
2958 let _ = gpu_info_pass(&ctx, screen, &info, None, drag_release(to));
2959
2960 let moved = area_rect(&ctx, GPU_INFO_AREA);
2961 assert!(
2962 (moved.min - opened.min - (to - from)).length() < 1.0,
2963 "the panel did not follow the drag: opened {opened:?}, moved {moved:?}"
2964 );
2965 }
2966
2967 #[test]
2968 fn the_gpu_info_panel_prints_the_adapter_features_and_limits() {
2969 let ctx = egui::Context::default();
2970 let info = sample_gpu_info();
2971 let _ = gpu_info_pass(&ctx, (800.0, 600.0), &info, None, Vec::new());
2972 let text = gpu_info_pass(&ctx, (800.0, 600.0), &info, None, Vec::new()).join("\n");
2973
2974 for expected in [
2975 "Test Adapter",
2976 "vulkan · discrete GPU · test driver",
2977 "driver info: 1.2.3",
2978 "Features (2)",
2979 "DEPTH_CLIP_CONTROL",
2980 "TEXTURE_COMPRESSION_BC",
2981 "Limits (1)",
2982 "max_bind_groups = 4",
2983 ] {
2984 assert!(
2985 text.contains(expected),
2986 "the panel does not show {expected:?}"
2987 );
2988 }
2989 }
2990
2991 #[test]
2992 fn the_gpu_info_panel_says_none_when_no_feature_is_enabled() {
2993 let ctx = egui::Context::default();
2994 let mut info = sample_gpu_info();
2995 info.features.clear();
2996 let _ = gpu_info_pass(&ctx, (800.0, 600.0), &info, None, Vec::new());
2997 let text = gpu_info_pass(&ctx, (800.0, 600.0), &info, None, Vec::new()).join("\n");
2998 assert!(text.contains("Features (0)"), "the count is still stated");
2999 assert!(text.contains("none"), "an empty feature list is stated");
3000 }
3001
3002 #[test]
3003 fn the_fullscreen_button_offers_the_action_that_is_not_in_effect() {
3004 let ctx = egui::Context::default();
3005 // Two passes, because an anchored area is laid out against the size the
3006 // previous pass left it.
3007 for active in [false, true] {
3008 let _ = fullscreen_pass(&ctx, (800.0, 600.0), active, Vec::new());
3009 let (clicked, labels) = fullscreen_pass(&ctx, (800.0, 600.0), active, Vec::new());
3010 assert!(!clicked, "nothing was pressed");
3011 let label = labels.join(" ");
3012 if active {
3013 assert!(
3014 label.contains("Exit fullscreen"),
3015 "leaving is offered while fullscreen, got {label:?}"
3016 );
3017 } else {
3018 assert!(
3019 label.contains("Fullscreen") && !label.contains("Exit"),
3020 "entering is offered while windowed, got {label:?}"
3021 );
3022 }
3023 }
3024 }
3025
3026 #[test]
3027 fn the_fullscreen_button_reports_a_press() {
3028 let ctx = egui::Context::default();
3029 let screen = (800.0, 600.0);
3030 // An anchored area is laid out at the position the previous pass gave
3031 // it, and it is only pinned to its corner from the pass after the
3032 // first, so the tap is aimed at where two passes have put it.
3033 let _ = fullscreen_pass(&ctx, screen, false, Vec::new());
3034 let _ = fullscreen_pass(&ctx, screen, false, Vec::new());
3035 let (press, release) = tap(area_centre(&ctx, FULLSCREEN_AREA));
3036
3037 // A release with no press before it is not a click, so the request is
3038 // not raised by a stray event.
3039 let (clicked, _) = fullscreen_pass(&ctx, screen, false, release.clone());
3040 assert!(!clicked, "a release alone is not a click");
3041 // Neither is a press the user has not let go of yet.
3042 let (clicked, _) = fullscreen_pass(&ctx, screen, false, press);
3043 assert!(!clicked, "a press alone is not a click");
3044 let (clicked, _) = fullscreen_pass(&ctx, screen, false, release);
3045 assert!(clicked, "the release inside the button is a click");
3046 }
3047
3048 #[test]
3049 fn the_fullscreen_button_grows_to_its_wider_label() {
3050 let ctx = egui::Context::default();
3051 // The area remembers the size of the narrower enter label, which is
3052 // what it is laid out against when the exit label is first drawn.
3053 let _ = fullscreen_pass(&ctx, (800.0, 600.0), false, Vec::new());
3054 let (_, enter_labels) = fullscreen_pass(&ctx, (800.0, 600.0), false, Vec::new());
3055 assert_eq!(enter_labels.len(), 1, "the enter label is one line");
3056 let enter = area_rect(&ctx, FULLSCREEN_AREA);
3057 let _ = fullscreen_pass(&ctx, (800.0, 600.0), true, Vec::new());
3058 let leaving = area_rect(&ctx, FULLSCREEN_AREA);
3059 // A wrapped label makes the button a row taller, which is the bug: the
3060 // exit label is wider than the enter one, not taller than it.
3061 assert!(
3062 (leaving.height() - enter.height()).abs() < 1.0,
3063 "the exit label wrapped: {enter:?} became {leaving:?}"
3064 );
3065 assert!(
3066 leaving.width() > enter.width(),
3067 "the exit label is the wider of the two: {enter:?} to {leaving:?}"
3068 );
3069 }
3070
3071 /// The screens the corner overlays are laid out on: wide enough for all
3072 /// three to share a row, and narrow ones — a portrait phone — where they
3073 /// have to stack.
3074 const OVERLAY_SCREENS: [(f32, f32); 4] = [
3075 (1280.0, 577.0),
3076 (800.0, 600.0),
3077 (390.0, 844.0),
3078 (320.0, 480.0),
3079 ];
3080
3081 /// Lay the three corner overlays out on `screen` until their anchored
3082 /// positions settle, and return their rectangles.
3083 ///
3084 /// Each of them anchors against what the previous pass remembered, so one
3085 /// pass proves nothing: the layout is only settled once every area has a
3086 /// geometry for the next one to read.
3087 fn corner_rects(
3088 ctx: &egui::Context,
3089 screen: (f32, f32),
3090 ) -> (egui::Rect, egui::Rect, egui::Rect) {
3091 let text = "60.0 fps · 16.7 ms";
3092 for _ in 0..3 {
3093 let input = egui::RawInput {
3094 screen_rect: Some(egui::Rect::from_min_size(
3095 egui::Pos2::ZERO,
3096 egui::Vec2::new(screen.0, screen.1),
3097 )),
3098 ..Default::default()
3099 };
3100 let mut output = ctx.run_ui(input, |ui| {
3101 frame_rate_readout(ui, text);
3102 fullscreen_button(ui, false);
3103 api_docs_button(ui);
3104 });
3105 output.textures_delta.clear();
3106 }
3107 (
3108 area_rect(ctx, FRAME_RATE_AREA),
3109 area_rect(ctx, FULLSCREEN_AREA),
3110 area_rect(ctx, API_DOCS_AREA),
3111 )
3112 }
3113
3114 #[test]
3115 fn the_corner_overlays_do_not_cover_each_other() {
3116 // The readout sits centred at the top, the fullscreen button anchors to
3117 // the top-right corner and the docs link under it. On a window wide
3118 // enough for them they share the top row, and on a narrow one — a
3119 // portrait phone — the button drops under the readout rather than hide
3120 // the end of the reading. The link then follows the button down.
3121 for screen in OVERLAY_SCREENS {
3122 let ctx = egui::Context::default();
3123 let (readout, button, docs) = corner_rects(&ctx, screen);
3124 for (first, second) in [(readout, button), (readout, docs), (button, docs)] {
3125 assert!(
3126 !first.intersects(second),
3127 "at {screen:?} {first:?} and {second:?} share screen space"
3128 );
3129 }
3130
3131 // The readout is centred, its top a margin from the window's top.
3132 assert!(
3133 (readout.center().x - screen.0 / 2.0).abs() < 1.0,
3134 "at {screen:?} the readout {readout:?} is not centred"
3135 );
3136 assert!(
3137 (readout.top() - OVERLAY_MARGIN).abs() < 1.0,
3138 "at {screen:?} the readout {readout:?} does not keep the top margin"
3139 );
3140
3141 // Both corner buttons keep the margin from the right edge and stay
3142 // on screen, the link under the button.
3143 for (name, rect) in [("button", button), ("docs", docs)] {
3144 assert!(
3145 (screen.0 - rect.right() - OVERLAY_MARGIN).abs() < 1.0,
3146 "at {screen:?} the {name} {rect:?} does not keep the right margin"
3147 );
3148 assert!(
3149 rect.bottom() <= screen.1,
3150 "at {screen:?} the {name} {rect:?} runs off the bottom"
3151 );
3152 }
3153 assert!(
3154 docs.top() >= button.bottom(),
3155 "at {screen:?} the docs link {docs:?} is not under the button {button:?}"
3156 );
3157
3158 // Wide windows hold the readout and the button on the top row;
3159 // narrow ones stack the button under the readout.
3160 if screen.0 >= 800.0 {
3161 assert!(
3162 (button.top() - OVERLAY_MARGIN).abs() < 1.0,
3163 "at {screen:?} the button {button:?} should share the top row"
3164 );
3165 } else {
3166 assert!(
3167 button.top() >= readout.bottom(),
3168 "at {screen:?} the button {button:?} should drop under the readout {readout:?}"
3169 );
3170 }
3171 }
3172 }
3173
3174 #[test]
3175 fn the_docs_button_asks_for_the_api_docs() {
3176 // The docs link goes through egui's own URL command, which the UI
3177 // source publishes in the world; nothing here opens a page.
3178 let ctx = egui::Context::default();
3179 let screen = (800.0, 600.0);
3180 // The link is only under the button once a pass has settled both.
3181 for _ in 0..2 {
3182 let _ = corner_rects(&ctx, screen);
3183 }
3184 let (press, release) = tap(area_centre(&ctx, API_DOCS_AREA));
3185
3186 let mut output = ctx.run_ui(raw_input(screen, press), |ui| {
3187 api_docs_button(ui);
3188 });
3189 assert!(
3190 output.platform_output.commands.is_empty(),
3191 "a press alone asks for nothing"
3192 );
3193 output.textures_delta.clear();
3194
3195 let mut output = ctx.run_ui(raw_input(screen, release), |ui| {
3196 api_docs_button(ui);
3197 });
3198 let commands = std::mem::take(&mut output.platform_output.commands);
3199 output.textures_delta.clear();
3200 let opened: Vec<(String, bool)> = commands
3201 .iter()
3202 .filter_map(|command| match command {
3203 egui::OutputCommand::OpenUrl(open) => Some((open.url.clone(), open.new_tab)),
3204 _ => None,
3205 })
3206 .collect();
3207 assert_eq!(
3208 opened,
3209 vec![(API_DOCS_URL.to_owned(), false)],
3210 "the release asks for the API docs in this tab"
3211 );
3212 }
3213
3214 #[test]
3215 fn the_fullscreen_button_is_at_least_a_touch_target() {
3216 // A control a phone has to be hit with a finger must not shrink to a
3217 // row of text: the button is grown to the declared minimum, including
3218 // the padding that centres the label, on every state and width.
3219 for screen in [(1280.0, 577.0), (390.0, 844.0)] {
3220 for active in [false, true] {
3221 let ctx = egui::Context::default();
3222 for _ in 0..2 {
3223 let _ = fullscreen_pass(&ctx, screen, active, Vec::new());
3224 }
3225 let button = area_rect(&ctx, FULLSCREEN_AREA);
3226 assert!(
3227 button.width() >= OVERLAY_BUTTON_MIN_SIZE.x
3228 && button.height() >= OVERLAY_BUTTON_MIN_SIZE.y,
3229 "at {screen:?} {active} the button {button:?} is below the touch-target size"
3230 );
3231 }
3232 }
3233 }
3234
3235 /// Every line count the readout laid a text galley out with, for a screen
3236 /// of `screen` points showing each of `texts` in turn.
3237 ///
3238 /// The readout is anchored, and an anchored area lays its contents out
3239 /// against the size it remembered from the previous pass, so one pass
3240 /// proves nothing: the failure it is prone to only shows once the text
3241 /// outgrows what the last pass left room for. `texts` is therefore a
3242 /// sequence of readings, one per pass, and the first pass — egui's
3243 /// invisible sizing pass — draws no text at all and contributes nothing.
3244 fn readout_lines(screen: (f32, f32), texts: &[&str]) -> Vec<usize> {
3245 let ctx = egui::Context::default();
3246 let mut lines = Vec::new();
3247 for text in texts {
3248 let input = egui::RawInput {
3249 screen_rect: Some(egui::Rect::from_min_size(
3250 egui::Pos2::ZERO,
3251 egui::Vec2::new(screen.0, screen.1),
3252 )),
3253 ..Default::default()
3254 };
3255 let mut output = ctx.run_ui(input, |ui| frame_rate_readout(ui, text));
3256 // egui panics if a texture delta is dropped unapplied; a real frame
3257 // hands it to the integration, and this test discards it.
3258 output.textures_delta.clear();
3259 for clipped in &output.shapes {
3260 if let egui::Shape::Text(text) = &clipped.shape {
3261 lines.push(text.galley.rows.len());
3262 }
3263 }
3264 }
3265 lines
3266 }
3267
3268 #[test]
3269 fn the_frame_rate_readout_stays_on_one_line() {
3270 // The reading is re-laid out every frame, starting from the empty
3271 // placeholder, and it grows a digit as the average settles: the readout
3272 // must hold one line through all of it, at any window width.
3273 for screen in [(1600.0, 900.0), (640.0, 480.0), (320.0, 240.0)] {
3274 for texts in [
3275 vec!["60.0 fps · 16.7 ms"; 4],
3276 vec!["— fps"; 4],
3277 vec![
3278 "— fps",
3279 "60.0 fps · 16.7 ms",
3280 "60.0 fps · 16.7 ms",
3281 "100.0 fps · 10.0 ms",
3282 ],
3283 ] {
3284 let lines = readout_lines(screen, &texts);
3285 assert!(
3286 !lines.is_empty(),
3287 "the readout drew nothing at {screen:?} {texts:?}"
3288 );
3289 assert_eq!(
3290 lines,
3291 vec![1; lines.len()],
3292 "screen {screen:?} text {texts:?}"
3293 );
3294 }
3295 }
3296 }
3297
3298 #[test]
3299 fn a_clock_reaches_its_step_only_after_enough_time() {
3300 let mut clock = 0.0;
3301 let step = 0.5;
3302
3303 // Half the interval is not enough.
3304 assert!(!tick(&mut clock, step, 0.25));
3305 // The interval is reached exactly.
3306 assert!(tick(&mut clock, step, 0.25));
3307 // The clock keeps the leftover rather than resetting, so the next
3308 // interval is reached in the time that is left.
3309 assert!(!tick(&mut clock, step, 0.4));
3310 assert!(tick(&mut clock, step, 0.1));
3311 }
3312
3313 #[test]
3314 fn a_long_stall_advances_one_step_and_keeps_the_rest() {
3315 let mut clock = 0.0;
3316 // A two-second stall over a half-second step consumes one step and
3317 // leaves the surplus for later, rather than skipping frames.
3318 assert!(tick(&mut clock, 0.5, 2.0));
3319 assert_eq!(clock, 1.5, "the surplus stays in the clock");
3320
3321 // The surplus is spent over the next three calls, one step each.
3322 assert!(tick(&mut clock, 0.5, 0.0));
3323 assert!(tick(&mut clock, 0.5, 0.0));
3324 assert!(tick(&mut clock, 0.5, 0.0));
3325 assert_eq!(clock, 0.0, "the surplus is used up");
3326 assert!(!tick(&mut clock, 0.5, 0.0));
3327 }
3328
3329 #[test]
3330 fn the_frame_rate_reads_nothing_until_a_frame_was_measured() {
3331 let rate = FrameRate::default();
3332 assert_eq!(rate.mean_seconds(), None);
3333 assert_eq!(rate.text(), "— fps");
3334 }
3335
3336 #[test]
3337 fn the_frame_rate_averages_its_window_and_reports_both_units() {
3338 let mut rate = FrameRate::default();
3339 // A steady 16 ms a frame is a little over 60 fps.
3340 for _ in 0..FrameRate::WINDOW {
3341 rate.push(0.016);
3342 }
3343 let mean = rate.mean_seconds().expect("a measured frame");
3344 assert!((mean - 0.016).abs() < 1e-6);
3345 assert_eq!(rate.text(), "62.5 fps · 16.0 ms");
3346 }
3347
3348 #[test]
3349 fn the_frame_rate_window_slides_rather_than_averaging_the_session() {
3350 let mut rate = FrameRate::default();
3351 // A long slow stretch, then enough fast frames to fill the window
3352 // again: the reading has to follow, or a scene that stalled once would
3353 // read slow for the rest of the session.
3354 for _ in 0..FrameRate::WINDOW * 2 {
3355 rate.push(0.1);
3356 }
3357 for _ in 0..FrameRate::WINDOW {
3358 rate.push(0.01);
3359 }
3360 assert_eq!(rate.samples.len(), FrameRate::WINDOW);
3361 let mean = rate.mean_seconds().expect("a measured frame");
3362 assert!((mean - 0.01).abs() < 1e-6, "got {mean}");
3363 }
3364
3365 #[test]
3366 fn only_a_scene_with_a_baseline_and_a_window_letterboxes() {
3367 // The windowed path keeps a declared baseline's aspect.
3368 let (viewport, content) = letterbox(Some((960, 720)), (1600, 600), true);
3369 assert_eq!(content, (800, 600), "the camera is built for the region");
3370 let viewport = viewport.expect("a letterbox is stated");
3371 assert_eq!((viewport.width, viewport.height), (800.0, 600.0));
3372 assert_eq!(viewport.x, 400.0, "centred horizontally");
3373
3374 // A capture never does: it draws at the scene's own size.
3375 assert_eq!(
3376 letterbox(Some((960, 720)), (1600, 600), false),
3377 (None, (1600, 600))
3378 );
3379
3380 // A scene with no aspect of its own draws into the whole target, which
3381 // is what a UI-only scene wants at any size.
3382 assert_eq!(letterbox(None, (1600, 600), true), (None, (1600, 600)));
3383
3384 // A target of the baseline's own shape needs no bars.
3385 let (viewport, content) = letterbox(Some((960, 720)), (960, 720), true);
3386 assert_eq!(content, (960, 720));
3387 let viewport = viewport.expect("a letterbox is stated");
3388 assert_eq!((viewport.x, viewport.y), (0.0, 0.0));
3389 }
3390}