Skip to main content

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;