mirage_engine/headless/mod.rs
1//! Running a game with no window, for tools and tests.
2//!
3//! A [`Session`] owns a game and an offscreen target, and advances it one
4//! frame at a time: no window, no event loop, no presentation. It draws
5//! exactly as a window would, and one stream of moves drives both the
6//! controls the game reads and its UI.
7//!
8//! Needs the `offscreen` feature, native only and off by default; absent
9//! on `wasm32`.
10//!
11//! ```no_run
12//! use mirage_engine::headless::Session;
13//! use mirage_engine::prelude::*;
14//!
15//! # struct Probe;
16//! # meshes! { enum Only { Cube } }
17//! # impl Game for Probe {
18//! # type Meshes = Only;
19//! # type Sounds = NoSounds;
20//! # type InputActions = NoInputActions;
21//! # type Skyboxes = NoSkyboxes;
22//! # type SurfaceStyles = NoSurfaceStyles;
23//! # type PostEffects = NoPostEffects;
24//! # fn tick(&mut self, _ctx: &mut TickContext<'_, Self>) {}
25//! # fn frame(&mut self, ctx: &mut FrameContext<'_, Self>) {
26//! # ctx.draw(Cube.at(Vec3::ZERO));
27//! # }
28//! # }
29//! let mut session = Session::new(Config::new("probe"), UVec2::new(320, 200), |_ctx| Ok(Probe))?;
30//!
31//! for _ in 0..60 {
32//! let started = std::time::Instant::now();
33//! let stats = session.step();
34//! println!("{} draws took {:?}", stats.draw_calls(), started.elapsed());
35//! }
36//!
37//! let pixels = session.pixels()?;
38//! assert_eq!(pixels.len(), 320 * 200 * 4);
39//! # Ok::<(), mirage_engine::Error>(())
40//! ```
41
42use core::num::NonZeroU32;
43use core::time::Duration;
44
45use crate::gpu::{FaultSlot, Target};
46use crate::input::{Controls, Devices, Pads};
47use crate::math::{UVec2, Vec2};
48use crate::platform::threads::{Commands, GameThread, Kept, Stated, Workers};
49use crate::renderer::{RenderStats, Renderer};
50use crate::sound::{Output, SoundOutput};
51use crate::time::FrameTime;
52use crate::ui::Painter;
53use crate::{Config, Cursor, Error, Game, InitContext, PointerDelta, WheelDelta};
54
55use driven::Driven;
56
57pub use crate::input::Switch;
58
59/// The format a session draws and reads back in: `8-bit` RGBA,
60/// sRGB-encoded, as a window would present it — what the tone map leaves,
61/// never the high-dynamic-range frame behind it.
62const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
63
64/// What startup returns on a machine with no graphics adapter to draw
65/// with, which is the one startup error a test may skip over.
66pub(crate) const NO_ADAPTER: &str = "no usable graphics adapter";
67
68/// The format the UI and a screen past the tone map draw in: the same
69/// pixels taken as the encoded values they hold, which is the space egui
70/// blends in.
71const OVERLAY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
72
73/// A game running against an offscreen target, one frame per [`Session::step`].
74///
75/// The session owns the game for its whole life, exactly as [`run`](crate::run)
76/// does, and borrows nothing from the caller. The caller is its player:
77/// [`Session::set_pointer`], [`Session::press`], [`Session::release`],
78/// [`Session::pointer_delta`], [`Session::wheel_delta`] and
79/// [`Session::type_text`] are its controls. Each
80/// move reaches what the game reads, the UI, or both: the UI is given the
81/// `egui::Event` a window's own event reaches it as, and reads no
82/// [`PointerDelta`] of its own; text reaches the UI
83/// alone. A rebind lasts
84/// the session and is written to no store, and so does everything the game
85/// saves — every key returns its fallback until the session itself saves
86/// one.
87pub struct Session<G: Game> {
88 game: GameThread<G>,
89 /// What the ticks have driven the clock to, and what the steps have;
90 /// the later of the two is the time a frame reads.
91 simulated: Duration,
92 drawn: Duration,
93 devices: Devices,
94 renderer: Renderer,
95 painter: Painter,
96 output: SoundOutput,
97 device: wgpu::Device,
98 queue: wgpu::Queue,
99 faults: KeptFault,
100 color: wgpu::Texture,
101 target: Target,
102 size: UVec2,
103 cursor: Cursor,
104 frame_interval: Duration,
105 /// Whether the snapshot the game reads is spent: a control has moved
106 /// since it was taken, or the step that read it has drawn.
107 stale: bool,
108}
109
110impl<G: Game> Session<G> {
111 /// Acquires a GPU, builds an offscreen target `size` physical pixels
112 /// across, and runs `init` against it, as [`run`](crate::run) does
113 /// without a window.
114 ///
115 /// `size` replaces the configuration's window size, which means nothing
116 /// without a window; the clear color and the rest of `config` apply as
117 /// usual. Fails if no GPU is available, if `size` has a zero side, if a
118 /// style of the game's does not compile, or if `init` does.
119 pub fn new(
120 config: Config,
121 size: UVec2,
122 init: impl FnOnce(&mut InitContext<'_, G>) -> Result<G, Error>,
123 ) -> Result<Self, Error> {
124 if size.x == 0 || size.y == 0 {
125 return Err(Error::msg(format!(
126 "a headless target needs a non-zero size, got {}x{}",
127 size.x, size.y
128 )));
129 }
130 crate::platform::install_diagnostics();
131
132 let Windowless {
133 device,
134 queue,
135 faults,
136 } = Windowless::acquired()?;
137 let color = device.create_texture(&wgpu::TextureDescriptor {
138 label: Some("mirage-engine headless target"),
139 size: wgpu::Extent3d {
140 width: size.x,
141 height: size.y,
142 depth_or_array_layers: 1,
143 },
144 mip_level_count: 1,
145 sample_count: 1,
146 dimension: wgpu::TextureDimension::D2,
147 format: TARGET_FORMAT,
148 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
149 view_formats: &[OVERLAY_FORMAT],
150 });
151 let view = |format| {
152 color.create_view(&wgpu::TextureViewDescriptor {
153 format: Some(format),
154 ..Default::default()
155 })
156 };
157 let target = Target::new(view(TARGET_FORMAT), view(OVERLAY_FORMAT), size);
158
159 let files = pollster::block_on(crate::platform::read_sources(config.asset_sources()))?;
160 let renderer = pollster::block_on(Renderer::new(
161 &device,
162 &queue,
163 TARGET_FORMAT,
164 &config,
165 crate::surface_style::Declarations::of::<G::SurfaceStyles>(),
166 crate::post_effect::Declarations::of::<G::PostEffects>(),
167 ))?;
168 let painter = Painter::offscreen(&device, OVERLAY_FORMAT, size);
169 let output = SoundOutput::new(Output::silent());
170 let devices = Devices::new(
171 Pads::silent(),
172 config
173 .double_click_interval()
174 .unwrap_or(crate::platform::DOUBLE_CLICK_INTERVAL),
175 );
176 // A session keeps no store: the game thread's own maps hold the
177 // bindings and the saves for its whole life.
178 let kept = Kept {
179 bindings: None,
180 saves: None,
181 window_size: size,
182 mix_rate: output.rate(),
183 workers: Workers::here_again(),
184 };
185 let game = GameThread::start(config, files, kept, init)?;
186
187 Ok(Self {
188 game,
189 simulated: Duration::ZERO,
190 drawn: Duration::ZERO,
191 devices,
192 renderer,
193 painter,
194 output,
195 device,
196 queue,
197 faults: KeptFault::over(faults),
198 color,
199 target,
200 size,
201 cursor: Cursor::default(),
202 frame_interval: Duration::ZERO,
203 stale: false,
204 })
205 }
206
207 /// Runs one fixed simulation step: [`Game::tick`] with
208 /// [`Config::tick_interval`], or the step the game last set.
209 ///
210 /// Headless time moves only when called; a step set during a tick is
211 /// taken from the next tick on. A tick covers the span it simulates, so
212 /// it reads the clock at the end of that span and the
213 /// [`step`](Session::step) after it draws at that same instant.
214 pub fn tick(&mut self) {
215 let controls = self.close_snapshot();
216 self.game.take(self.stated(), controls);
217 let dt = self.game.start_ticks();
218 // The tick covers the span it is about to simulate, so it reads the
219 // clock at the end of that span, which is where the step after it
220 // draws from.
221 self.simulated += dt;
222 self.game.ticks(NonZeroU32::MIN, dt, self.simulated);
223 }
224
225 /// The same session, with the time each [`Session::step`] covers set
226 /// to `interval`.
227 ///
228 /// A session's clock is the caller's: a tick advances it by its own
229 /// interval, and a step by this one. What a frame reads as
230 /// [`FrameContext::elapsed`](crate::FrameContext::elapsed) is the later
231 /// of the two, so a caller that ticks and steps over the same span
232 /// counts it once, as a window would. A session starts at
233 /// `Duration::ZERO`, which leaves every step at the same instant.
234 ///
235 /// The UI paces its own animations by that clock, so an interval is
236 /// what moves a fade or a highlight of the UI's own with no window.
237 pub fn with_frame_interval(mut self, interval: Duration) -> Self {
238 self.frame_interval = interval;
239 self
240 }
241
242 /// Sets the time each later [`Session::step`] covers; see
243 /// [`Session::with_frame_interval`].
244 pub fn set_frame_interval(&mut self, interval: Duration) {
245 self.frame_interval = interval;
246 }
247
248 /// Runs one frame: [`Game::frame`] records its draws, and the engine
249 /// draws exactly those into the target, replacing what was there.
250 ///
251 /// Nothing is timed for you — wrap the call to measure it. Drawing
252 /// never ticks: [`Session::tick`] is the only thing that simulates. A
253 /// step advances the clock by
254 /// [`with_frame_interval`](Session::with_frame_interval), which is the
255 /// `dt` the frame reads.
256 ///
257 /// A floating UI window just opened may need a few more `step` calls
258 /// before it draws; a capture taken right after may not show it.
259 pub fn step(&mut self) -> FrameStats {
260 self.faults.read();
261 let elapsed = self.simulated.max(self.drawn);
262 #[cfg(feature = "ui")]
263 self.painter.set_clock(elapsed);
264 let controls = self.close_snapshot();
265 self.game.take(self.stated(), controls);
266
267 let time = FrameTime {
268 dt: self.frame_interval,
269 elapsed,
270 alpha: 0.0,
271 };
272 let (handed, commands) = self.game.frame(time, self.painter.take_input());
273 // A session keeps no store, so the bindings and the saves text a
274 // flush changed go nowhere; the game thread's maps hold them.
275 let Commands {
276 meshes,
277 skies,
278 ui,
279 sound,
280 ..
281 } = commands;
282 self.renderer.take(
283 &self.device,
284 &self.queue,
285 meshes,
286 skies,
287 ui,
288 &mut self.painter,
289 );
290 self.output.play(sound);
291 self.cursor = handed.cursor;
292 self.painter.keep(handed.ui, handed.cursor);
293 self.drawn += self.frame_interval;
294 self.stale = true;
295 FrameStats(self.renderer.render_to(
296 &self.device,
297 &self.queue,
298 &self.target,
299 &handed.draws,
300 &mut self.painter,
301 ))
302 }
303
304 /// Moves the pointer to `at`, in physical pixels from the target's top
305 /// left, which is what
306 /// [`FrameContext::pointer`](crate::FrameContext::pointer) reads.
307 ///
308 /// The next tick or step reads it, and it stays there until this moves
309 /// it again. The distance it moves is what a
310 /// [`PointerDelta`] binding reads, exactly as a
311 /// cursor's is, and the UI reads the same move.
312 pub fn set_pointer(&mut self, at: Vec2) {
313 self.feed(Driven::Pointed(at));
314 }
315
316 /// Presses `control`, which every action bound to it reads as down, and
317 /// which the UI reads as the same press.
318 ///
319 /// The next tick and the step after it read the press as an edge, and no
320 /// tick or step after them reads that edge again. The control stays down
321 /// until [`Session::release`].
322 pub fn press(&mut self, control: impl Into<Switch>) {
323 self.feed(Driven::Switched(control.into(), true));
324 }
325
326 /// Releases `control`, which the next tick and the step after it read as
327 /// an edge the same way.
328 pub fn release(&mut self, control: impl Into<Switch>) {
329 self.feed(Driven::Switched(control.into(), false));
330 }
331
332 /// Moves `lane` of the pointer by `pixels`, counted right and up as
333 /// [`Session::set_pointer`] counts them.
334 ///
335 /// An axis bound to that lane reads it through its own scale, a
336 /// fraction of what it moved. The UI reads nothing of it. The next tick
337 /// or step reads it: a lane reports a distance since the last reading,
338 /// never a place.
339 pub fn pointer_delta(&mut self, lane: PointerDelta, pixels: f32) {
340 self.feed(Driven::Moved(lane, pixels));
341 }
342
343 /// Turns `lane` of the wheel by `notches`, counted right and away: one
344 /// notch is one step of a mouse wheel, the same on every target.
345 ///
346 /// An axis bound to that lane reads it through its own scale, a
347 /// fraction of what it turned, and the UI scrolls by what a window
348 /// reports for those notches. The next tick or step reads it: a lane
349 /// reports a distance since the last reading, never a place.
350 pub fn wheel_delta(&mut self, lane: WheelDelta, notches: f32) {
351 self.feed(Driven::Turned(lane, notches));
352 }
353
354 /// Types `text` into the UI, wherever it holds the keyboard.
355 ///
356 /// The next [`Session::step`] reads it. The game's own actions read
357 /// nothing of it: text belongs to the UI layer, and an action reads
358 /// controls.
359 #[cfg(feature = "ui")]
360 pub fn type_text(&mut self, text: &str) {
361 self.feed(Driven::Typed(text.to_owned()));
362 }
363
364 /// Drives one move into the devices the game reads and the UI both.
365 fn feed(&mut self, driven: Driven) {
366 driven.drive(&mut self.devices);
367 #[cfg(feature = "ui")]
368 if let Some(event) = driven.ui_event(&self.devices, self.painter.pixels_per_point()) {
369 self.painter.feed(event);
370 }
371 self.stale = true;
372 }
373
374 /// What this session states of itself to every tick and every frame.
375 fn stated(&self) -> Stated {
376 Stated {
377 window_size: self.size,
378 sound_unlocked: self.output.unlocked(),
379 }
380 }
381
382 /// Starts a tick or a step as the loop behind a window starts one of its
383 /// own: closes a snapshot where the controls have moved since the last
384 /// one, which the game thread is handed once, or nothing where none did.
385 fn close_snapshot(&mut self) -> Option<Controls> {
386 core::mem::take(&mut self.stale)
387 .then(|| self.devices.sample(self.simulated.max(self.drawn)))
388 }
389
390 /// Waits until the GPU has finished every submitted frame.
391 ///
392 /// [`Session::step`] returns at submission; fence each step to cap work
393 /// in flight at one frame (vsync's bound), so step times measure frame
394 /// cost, not queue depth.
395 pub fn wait_for_gpu(&self) -> Result<(), Error> {
396 wait_for_gpu(&self.device)
397 }
398
399 /// Reads the target back: `4 * width * height` bytes of sRGB-encoded RGBA,
400 /// row by row from the top left, with no padding between rows.
401 ///
402 /// Every call waits for the GPU and allocates, which suits tests, not
403 /// measurement done every frame. Before the first [`Session::step`] the
404 /// pixels are undefined.
405 pub fn pixels(&self) -> Result<Vec<u8>, Error> {
406 let row = self.size.x * 4;
407 let padded_row = row.next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
408 let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
409 label: Some("mirage-engine headless readback"),
410 size: u64::from(padded_row) * u64::from(self.size.y),
411 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
412 mapped_at_creation: false,
413 });
414
415 let mut encoder = self
416 .device
417 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
418 label: Some("mirage-engine headless readback"),
419 });
420 encoder.copy_texture_to_buffer(
421 wgpu::TexelCopyTextureInfo {
422 texture: &self.color,
423 mip_level: 0,
424 origin: wgpu::Origin3d::ZERO,
425 aspect: wgpu::TextureAspect::All,
426 },
427 wgpu::TexelCopyBufferInfo {
428 buffer: &readback,
429 layout: wgpu::TexelCopyBufferLayout {
430 offset: 0,
431 bytes_per_row: Some(padded_row),
432 rows_per_image: Some(self.size.y),
433 },
434 },
435 wgpu::Extent3d {
436 width: self.size.x,
437 height: self.size.y,
438 depth_or_array_layers: 1,
439 },
440 );
441 self.queue.submit([encoder.finish()]);
442
443 readback.slice(..).map_async(wgpu::MapMode::Read, |_| {});
444 wait_for_gpu(&self.device)?;
445
446 let mapped = readback.slice(..).get_mapped_range();
447 let pixels = mapped
448 .chunks(padded_row as usize)
449 .flat_map(|padded| &padded[..row as usize])
450 .copied()
451 .collect();
452 drop(mapped);
453 readback.unmap();
454
455 Ok(pixels)
456 }
457
458 /// The target's size in physical pixels, as
459 /// [`FrameContext::window_size`](crate::FrameContext::window_size)
460 /// reports it to the game.
461 pub fn size(&self) -> UVec2 {
462 self.size
463 }
464
465 /// Whether the game has requested the end of the run through
466 /// [`FrameContext::close`](crate::FrameContext::close), which a
467 /// window would have closed on.
468 ///
469 /// A session has no loop to end: it runs every tick and step the caller
470 /// calls for, and this stays true.
471 pub fn closed(&self) -> bool {
472 self.game.closing()
473 }
474
475 /// The fault the session's device reported, which a run behind a window
476 /// would have ended on: that the device was lost, or that it ran out of
477 /// memory.
478 ///
479 /// The text is the log line a run behind a window would write, never the
480 /// text a player reads, which states the adapter a window run drew on.
481 /// Each
482 /// [`Session::step`] reads the device, and this reports the first fault
483 /// it read, every call, as [`Session::closed`] does: a session has no
484 /// loop to end, and the caller stops when it chooses. A call the device
485 /// refused leaves nothing here; the engine logs it at error level.
486 pub fn fault(&self) -> Option<Error> {
487 self.faults.error()
488 }
489
490 /// The cursor the last [`Session::step`] drew the pointer as: what the
491 /// frame set through
492 /// [`FrameContext::set_cursor`](crate::FrameContext::set_cursor), or
493 /// the UI's where the UI sets a cursor of its own.
494 ///
495 /// [`Cursor::Arrow`] before the first step, and where a step's frame
496 /// set none. A cursor the UI sets that [`Cursor`] does not hold reads
497 /// as the nearest one it holds. [`Cursor::Held`] reads back like any
498 /// other: a session has no window to hold a pointer in, so
499 /// [`Session::set_pointer`] places it as it always does.
500 pub fn cursor(&self) -> Cursor {
501 self.cursor
502 }
503
504 /// The game being driven, for code that reads it between steps.
505 pub fn game(&self) -> &G {
506 self.game.game()
507 }
508
509 /// The game being driven, for code that changes it between steps.
510 pub fn game_mut(&mut self) -> &mut G {
511 self.game.game_mut()
512 }
513}
514
515/// Counts one [`Session::step`] turned the game's draws into.
516///
517/// Material variety never splits a batch, so these counts measure the
518/// frame's mesh and slot collapse, not its color variety. Translucent draws
519/// count with the rest, batched in a sorted pass of their own.
520#[derive(Clone, Copy, Debug)]
521pub struct FrameStats(RenderStats);
522
523impl FrameStats {
524 /// Instanced draw count the frame became.
525 pub fn draw_calls(&self) -> u32 {
526 self.0.draw_calls
527 }
528
529 /// Instance count those draws covered, one per drawn material slot.
530 pub fn instances(&self) -> u32 {
531 self.0.instances
532 }
533}
534
535/// Blocks until the GPU has finished the work submitted so far — allowed
536/// here because the `offscreen` feature never builds for the web.
537pub(crate) fn wait_for_gpu(device: &wgpu::Device) -> Result<(), Error> {
538 device
539 .poll(wgpu::PollType::wait_indefinitely())
540 .map(|_| ())
541 .map_err(|error| Error::msg(format!("the GPU never finished the work: {error}")))
542}
543
544/// A device with no window behind it: the queue it is written through, and
545/// the slot every fault it reports reaches.
546struct Windowless {
547 device: wgpu::Device,
548 queue: wgpu::Queue,
549 faults: FaultSlot,
550}
551
552impl Windowless {
553 /// Acquires such a device, blocking until it has one — allowed here for
554 /// the same reason [`wait_for_gpu`] is.
555 fn acquired() -> Result<Self, Error> {
556 pollster::block_on(async {
557 let instance =
558 wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
559 let adapter = instance
560 .request_adapter(&wgpu::RequestAdapterOptions {
561 power_preference: wgpu::PowerPreference::HighPerformance,
562 ..Default::default()
563 })
564 .await
565 .map_err(|error| Error::msg(format!("{NO_ADAPTER}: {error}")))?;
566
567 let (device, queue) = adapter
568 .request_device(&wgpu::DeviceDescriptor {
569 label: Some("mirage-engine headless"),
570 ..Default::default()
571 })
572 .await
573 .map_err(|error| {
574 Error::msg(format!("the graphics adapter refused a device: {error}"))
575 })?;
576 let faults = FaultSlot::watching(&device);
577
578 Ok(Self {
579 device,
580 queue,
581 faults,
582 })
583 })
584 }
585}
586
587/// The first fault a session's device reported, kept for a caller that reads
588/// it when it chooses.
589struct KeptFault {
590 slot: FaultSlot,
591 kept: Option<Error>,
592}
593
594impl KeptFault {
595 /// Nothing read yet from `slot`.
596 fn over(slot: FaultSlot) -> Self {
597 Self { slot, kept: None }
598 }
599
600 /// Reads the slot, keeping the first fault it holds.
601 fn read(&mut self) {
602 let Some(fault) = self.slot.taken() else {
603 return;
604 };
605 self.kept
606 .get_or_insert_with(|| Error::msg(fault.to_string()));
607 }
608
609 /// The error the run would end with, where a step has read a fault.
610 fn error(&self) -> Option<Error> {
611 self.kept.clone()
612 }
613}
614
615mod driven;
616
617#[cfg(test)]
618mod tests;