mirage-engine 0.2.0

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
//! Running a game with no window, for tools and tests.
//!
//! A [`Session`] owns a game and an offscreen target, and advances it one
//! frame at a time: no window, no event loop, no presentation. It draws
//! exactly as a window would, and one stream of moves drives both the
//! controls the game reads and its UI.
//!
//! Needs the `offscreen` feature, native only and off by default; absent
//! on `wasm32`.
//!
//! ```no_run
//! use mirage_engine::headless::Session;
//! use mirage_engine::prelude::*;
//!
//! # struct Probe;
//! # meshes! { enum Only { Cube } }
//! # impl Game for Probe {
//! #     type Meshes = Only;
//! #     type Sounds = NoSounds;
//! #     type InputActions = NoInputActions;
//! #     type Skyboxes = NoSkyboxes;
//! #     type SurfaceStyles = NoSurfaceStyles;
//! #     type PostEffects = NoPostEffects;
//! #     fn tick(&mut self, _ctx: &mut TickContext<'_, Self>) {}
//! #     fn frame(&mut self, ctx: &mut FrameContext<'_, Self>) {
//! #         ctx.draw(Cube.at(Vec3::ZERO));
//! #     }
//! # }
//! let mut session = Session::new(Config::new("probe"), UVec2::new(320, 200), |_ctx| Ok(Probe))?;
//!
//! for _ in 0..60 {
//!     let started = std::time::Instant::now();
//!     let stats = session.step();
//!     println!("{} draws took {:?}", stats.draw_calls(), started.elapsed());
//! }
//!
//! let pixels = session.pixels()?;
//! assert_eq!(pixels.len(), 320 * 200 * 4);
//! # Ok::<(), mirage_engine::Error>(())
//! ```

use core::num::NonZeroU32;
use core::time::Duration;

use crate::gpu::{FaultSlot, Target};
use crate::input::{Controls, Devices, Pads};
use crate::math::{UVec2, Vec2};
use crate::platform::threads::{Commands, GameThread, Kept, Stated, Workers};
use crate::renderer::{RenderStats, Renderer};
use crate::sound::{Output, SoundOutput};
use crate::time::FrameTime;
use crate::ui::Painter;
use crate::{Config, Cursor, Error, Game, InitContext, PointerDelta, WheelDelta};

use driven::Driven;

pub use crate::input::Switch;

/// The format a session draws and reads back in: `8-bit` RGBA,
/// sRGB-encoded, as a window would present it — what the tone map leaves,
/// never the high-dynamic-range frame behind it.
const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;

/// What startup returns on a machine with no graphics adapter to draw
/// with, which is the one startup error a test may skip over.
pub(crate) const NO_ADAPTER: &str = "no usable graphics adapter";

/// The format the UI and a screen past the tone map draw in: the same
/// pixels taken as the encoded values they hold, which is the space egui
/// blends in.
const OVERLAY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;

/// A game running against an offscreen target, one frame per [`Session::step`].
///
/// The session owns the game for its whole life, exactly as [`run`](crate::run)
/// does, and borrows nothing from the caller. The caller is its player:
/// [`Session::set_pointer`], [`Session::press`], [`Session::release`],
/// [`Session::pointer_delta`], [`Session::wheel_delta`] and
/// [`Session::type_text`] are its controls. Each
/// move reaches what the game reads, the UI, or both: the UI is given the
/// `egui::Event` a window's own event reaches it as, and reads no
/// [`PointerDelta`] of its own; text reaches the UI
/// alone. A rebind lasts
/// the session and is written to no store, and so does everything the game
/// saves — every key returns its fallback until the session itself saves
/// one.
pub struct Session<G: Game> {
    game: GameThread<G>,
    /// What the ticks have driven the clock to, and what the steps have;
    /// the later of the two is the time a frame reads.
    simulated: Duration,
    drawn: Duration,
    devices: Devices,
    renderer: Renderer,
    painter: Painter,
    output: SoundOutput,
    device: wgpu::Device,
    queue: wgpu::Queue,
    faults: KeptFault,
    color: wgpu::Texture,
    target: Target,
    size: UVec2,
    cursor: Cursor,
    frame_interval: Duration,
    /// Whether the snapshot the game reads is spent: a control has moved
    /// since it was taken, or the step that read it has drawn.
    stale: bool,
}

impl<G: Game> Session<G> {
    /// Acquires a GPU, builds an offscreen target `size` physical pixels
    /// across, and runs `init` against it, as [`run`](crate::run) does
    /// without a window.
    ///
    /// `size` replaces the configuration's window size, which means nothing
    /// without a window; the clear color and the rest of `config` apply as
    /// usual. Fails if no GPU is available, if `size` has a zero side, if a
    /// style of the game's does not compile, or if `init` does.
    pub fn new(
        config: Config,
        size: UVec2,
        init: impl FnOnce(&mut InitContext<'_, G>) -> Result<G, Error>,
    ) -> Result<Self, Error> {
        if size.x == 0 || size.y == 0 {
            return Err(Error::msg(format!(
                "a headless target needs a non-zero size, got {}x{}",
                size.x, size.y
            )));
        }
        crate::platform::install_diagnostics();

        let Windowless {
            device,
            queue,
            faults,
        } = Windowless::acquired()?;
        let color = device.create_texture(&wgpu::TextureDescriptor {
            label: Some("mirage-engine headless target"),
            size: wgpu::Extent3d {
                width: size.x,
                height: size.y,
                depth_or_array_layers: 1,
            },
            mip_level_count: 1,
            sample_count: 1,
            dimension: wgpu::TextureDimension::D2,
            format: TARGET_FORMAT,
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
            view_formats: &[OVERLAY_FORMAT],
        });
        let view = |format| {
            color.create_view(&wgpu::TextureViewDescriptor {
                format: Some(format),
                ..Default::default()
            })
        };
        let target = Target::new(view(TARGET_FORMAT), view(OVERLAY_FORMAT), size);

        let files = pollster::block_on(crate::platform::read_sources(config.asset_sources()))?;
        let renderer = pollster::block_on(Renderer::new(
            &device,
            &queue,
            TARGET_FORMAT,
            &config,
            crate::surface_style::Declarations::of::<G::SurfaceStyles>(),
            crate::post_effect::Declarations::of::<G::PostEffects>(),
        ))?;
        let painter = Painter::offscreen(&device, OVERLAY_FORMAT, size);
        let output = SoundOutput::new(Output::silent());
        let devices = Devices::new(
            Pads::silent(),
            config
                .double_click_interval()
                .unwrap_or(crate::platform::DOUBLE_CLICK_INTERVAL),
        );
        // A session keeps no store: the game thread's own maps hold the
        // bindings and the saves for its whole life.
        let kept = Kept {
            bindings: None,
            saves: None,
            window_size: size,
            mix_rate: output.rate(),
            workers: Workers::here_again(),
        };
        let game = GameThread::start(config, files, kept, init)?;

        Ok(Self {
            game,
            simulated: Duration::ZERO,
            drawn: Duration::ZERO,
            devices,
            renderer,
            painter,
            output,
            device,
            queue,
            faults: KeptFault::over(faults),
            color,
            target,
            size,
            cursor: Cursor::default(),
            frame_interval: Duration::ZERO,
            stale: false,
        })
    }

    /// Runs one fixed simulation step: [`Game::tick`] with
    /// [`Config::tick_interval`], or the step the game last set.
    ///
    /// Headless time moves only when called; a step set during a tick is
    /// taken from the next tick on. A tick covers the span it simulates, so
    /// it reads the clock at the end of that span and the
    /// [`step`](Session::step) after it draws at that same instant.
    pub fn tick(&mut self) {
        let controls = self.close_snapshot();
        self.game.take(self.stated(), controls);
        let dt = self.game.start_ticks();
        // The tick covers the span it is about to simulate, so it reads the
        // clock at the end of that span, which is where the step after it
        // draws from.
        self.simulated += dt;
        self.game.ticks(NonZeroU32::MIN, dt, self.simulated);
    }

    /// The same session, with the time each [`Session::step`] covers set
    /// to `interval`.
    ///
    /// A session's clock is the caller's: a tick advances it by its own
    /// interval, and a step by this one. What a frame reads as
    /// [`FrameContext::elapsed`](crate::FrameContext::elapsed) is the later
    /// of the two, so a caller that ticks and steps over the same span
    /// counts it once, as a window would. A session starts at
    /// `Duration::ZERO`, which leaves every step at the same instant.
    ///
    /// The UI paces its own animations by that clock, so an interval is
    /// what moves a fade or a highlight of the UI's own with no window.
    pub fn with_frame_interval(mut self, interval: Duration) -> Self {
        self.frame_interval = interval;
        self
    }

    /// Sets the time each later [`Session::step`] covers; see
    /// [`Session::with_frame_interval`].
    pub fn set_frame_interval(&mut self, interval: Duration) {
        self.frame_interval = interval;
    }

    /// Runs one frame: [`Game::frame`] records its draws, and the engine
    /// draws exactly those into the target, replacing what was there.
    ///
    /// Nothing is timed for you — wrap the call to measure it. Drawing
    /// never ticks: [`Session::tick`] is the only thing that simulates. A
    /// step advances the clock by
    /// [`with_frame_interval`](Session::with_frame_interval), which is the
    /// `dt` the frame reads.
    ///
    /// A floating UI window just opened may need a few more `step` calls
    /// before it draws; a capture taken right after may not show it.
    pub fn step(&mut self) -> FrameStats {
        self.faults.read();
        let elapsed = self.simulated.max(self.drawn);
        #[cfg(feature = "ui")]
        self.painter.set_clock(elapsed);
        let controls = self.close_snapshot();
        self.game.take(self.stated(), controls);

        let time = FrameTime {
            dt: self.frame_interval,
            elapsed,
            alpha: 0.0,
        };
        let (handed, commands) = self.game.frame(time, self.painter.take_input());
        // A session keeps no store, so the bindings and the saves text a
        // flush changed go nowhere; the game thread's maps hold them.
        let Commands {
            meshes,
            skies,
            ui,
            sound,
            ..
        } = commands;
        self.renderer.take(
            &self.device,
            &self.queue,
            meshes,
            skies,
            ui,
            &mut self.painter,
        );
        self.output.play(sound);
        self.cursor = handed.cursor;
        self.painter.keep(handed.ui, handed.cursor);
        self.drawn += self.frame_interval;
        self.stale = true;
        FrameStats(self.renderer.render_to(
            &self.device,
            &self.queue,
            &self.target,
            &handed.draws,
            &mut self.painter,
        ))
    }

    /// Moves the pointer to `at`, in physical pixels from the target's top
    /// left, which is what
    /// [`FrameContext::pointer`](crate::FrameContext::pointer) reads.
    ///
    /// The next tick or step reads it, and it stays there until this moves
    /// it again. The distance it moves is what a
    /// [`PointerDelta`] binding reads, exactly as a
    /// cursor's is, and the UI reads the same move.
    pub fn set_pointer(&mut self, at: Vec2) {
        self.feed(Driven::Pointed(at));
    }

    /// Presses `control`, which every action bound to it reads as down, and
    /// which the UI reads as the same press.
    ///
    /// The next tick and the step after it read the press as an edge, and no
    /// tick or step after them reads that edge again. The control stays down
    /// until [`Session::release`].
    pub fn press(&mut self, control: impl Into<Switch>) {
        self.feed(Driven::Switched(control.into(), true));
    }

    /// Releases `control`, which the next tick and the step after it read as
    /// an edge the same way.
    pub fn release(&mut self, control: impl Into<Switch>) {
        self.feed(Driven::Switched(control.into(), false));
    }

    /// Moves `lane` of the pointer by `pixels`, counted right and up as
    /// [`Session::set_pointer`] counts them.
    ///
    /// An axis bound to that lane reads it through its own scale, a
    /// fraction of what it moved. The UI reads nothing of it. The next tick
    /// or step reads it: a lane reports a distance since the last reading,
    /// never a place.
    pub fn pointer_delta(&mut self, lane: PointerDelta, pixels: f32) {
        self.feed(Driven::Moved(lane, pixels));
    }

    /// Turns `lane` of the wheel by `notches`, counted right and away: one
    /// notch is one step of a mouse wheel, the same on every target.
    ///
    /// An axis bound to that lane reads it through its own scale, a
    /// fraction of what it turned, and the UI scrolls by what a window
    /// reports for those notches. The next tick or step reads it: a lane
    /// reports a distance since the last reading, never a place.
    pub fn wheel_delta(&mut self, lane: WheelDelta, notches: f32) {
        self.feed(Driven::Turned(lane, notches));
    }

    /// Types `text` into the UI, wherever it holds the keyboard.
    ///
    /// The next [`Session::step`] reads it. The game's own actions read
    /// nothing of it: text belongs to the UI layer, and an action reads
    /// controls.
    #[cfg(feature = "ui")]
    pub fn type_text(&mut self, text: &str) {
        self.feed(Driven::Typed(text.to_owned()));
    }

    /// Drives one move into the devices the game reads and the UI both.
    fn feed(&mut self, driven: Driven) {
        driven.drive(&mut self.devices);
        #[cfg(feature = "ui")]
        if let Some(event) = driven.ui_event(&self.devices, self.painter.pixels_per_point()) {
            self.painter.feed(event);
        }
        self.stale = true;
    }

    /// What this session states of itself to every tick and every frame.
    fn stated(&self) -> Stated {
        Stated {
            window_size: self.size,
            sound_unlocked: self.output.unlocked(),
        }
    }

    /// Starts a tick or a step as the loop behind a window starts one of its
    /// own: closes a snapshot where the controls have moved since the last
    /// one, which the game thread is handed once, or nothing where none did.
    fn close_snapshot(&mut self) -> Option<Controls> {
        core::mem::take(&mut self.stale)
            .then(|| self.devices.sample(self.simulated.max(self.drawn)))
    }

    /// Waits until the GPU has finished every submitted frame.
    ///
    /// [`Session::step`] returns at submission; fence each step to cap work
    /// in flight at one frame (vsync's bound), so step times measure frame
    /// cost, not queue depth.
    pub fn wait_for_gpu(&self) -> Result<(), Error> {
        wait_for_gpu(&self.device)
    }

    /// Reads the target back: `4 * width * height` bytes of sRGB-encoded RGBA,
    /// row by row from the top left, with no padding between rows.
    ///
    /// Every call waits for the GPU and allocates, which suits tests, not
    /// measurement done every frame. Before the first [`Session::step`] the
    /// pixels are undefined.
    pub fn pixels(&self) -> Result<Vec<u8>, Error> {
        let row = self.size.x * 4;
        let padded_row = row.next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
        let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("mirage-engine headless readback"),
            size: u64::from(padded_row) * u64::from(self.size.y),
            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
            mapped_at_creation: false,
        });

        let mut encoder = self
            .device
            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
                label: Some("mirage-engine headless readback"),
            });
        encoder.copy_texture_to_buffer(
            wgpu::TexelCopyTextureInfo {
                texture: &self.color,
                mip_level: 0,
                origin: wgpu::Origin3d::ZERO,
                aspect: wgpu::TextureAspect::All,
            },
            wgpu::TexelCopyBufferInfo {
                buffer: &readback,
                layout: wgpu::TexelCopyBufferLayout {
                    offset: 0,
                    bytes_per_row: Some(padded_row),
                    rows_per_image: Some(self.size.y),
                },
            },
            wgpu::Extent3d {
                width: self.size.x,
                height: self.size.y,
                depth_or_array_layers: 1,
            },
        );
        self.queue.submit([encoder.finish()]);

        readback.slice(..).map_async(wgpu::MapMode::Read, |_| {});
        wait_for_gpu(&self.device)?;

        let mapped = readback.slice(..).get_mapped_range();
        let pixels = mapped
            .chunks(padded_row as usize)
            .flat_map(|padded| &padded[..row as usize])
            .copied()
            .collect();
        drop(mapped);
        readback.unmap();

        Ok(pixels)
    }

    /// The target's size in physical pixels, as
    /// [`FrameContext::window_size`](crate::FrameContext::window_size)
    /// reports it to the game.
    pub fn size(&self) -> UVec2 {
        self.size
    }

    /// Whether the game has requested the end of the run through
    /// [`FrameContext::close`](crate::FrameContext::close), which a
    /// window would have closed on.
    ///
    /// A session has no loop to end: it runs every tick and step the caller
    /// calls for, and this stays true.
    pub fn closed(&self) -> bool {
        self.game.closing()
    }

    /// The fault the session's device reported, which a run behind a window
    /// would have ended on: that the device was lost, or that it ran out of
    /// memory.
    ///
    /// The text is the log line a run behind a window would write, never the
    /// text a player reads, which states the adapter a window run drew on.
    /// Each
    /// [`Session::step`] reads the device, and this reports the first fault
    /// it read, every call, as [`Session::closed`] does: a session has no
    /// loop to end, and the caller stops when it chooses. A call the device
    /// refused leaves nothing here; the engine logs it at error level.
    pub fn fault(&self) -> Option<Error> {
        self.faults.error()
    }

    /// The cursor the last [`Session::step`] drew the pointer as: what the
    /// frame set through
    /// [`FrameContext::set_cursor`](crate::FrameContext::set_cursor), or
    /// the UI's where the UI sets a cursor of its own.
    ///
    /// [`Cursor::Arrow`] before the first step, and where a step's frame
    /// set none. A cursor the UI sets that [`Cursor`] does not hold reads
    /// as the nearest one it holds. [`Cursor::Held`] reads back like any
    /// other: a session has no window to hold a pointer in, so
    /// [`Session::set_pointer`] places it as it always does.
    pub fn cursor(&self) -> Cursor {
        self.cursor
    }

    /// The game being driven, for code that reads it between steps.
    pub fn game(&self) -> &G {
        self.game.game()
    }

    /// The game being driven, for code that changes it between steps.
    pub fn game_mut(&mut self) -> &mut G {
        self.game.game_mut()
    }
}

/// Counts one [`Session::step`] turned the game's draws into.
///
/// Material variety never splits a batch, so these counts measure the
/// frame's mesh and slot collapse, not its color variety. Translucent draws
/// count with the rest, batched in a sorted pass of their own.
#[derive(Clone, Copy, Debug)]
pub struct FrameStats(RenderStats);

impl FrameStats {
    /// Instanced draw count the frame became.
    pub fn draw_calls(&self) -> u32 {
        self.0.draw_calls
    }

    /// Instance count those draws covered, one per drawn material slot.
    pub fn instances(&self) -> u32 {
        self.0.instances
    }
}

/// Blocks until the GPU has finished the work submitted so far — allowed
/// here because the `offscreen` feature never builds for the web.
pub(crate) fn wait_for_gpu(device: &wgpu::Device) -> Result<(), Error> {
    device
        .poll(wgpu::PollType::wait_indefinitely())
        .map(|_| ())
        .map_err(|error| Error::msg(format!("the GPU never finished the work: {error}")))
}

/// A device with no window behind it: the queue it is written through, and
/// the slot every fault it reports reaches.
struct Windowless {
    device: wgpu::Device,
    queue: wgpu::Queue,
    faults: FaultSlot,
}

impl Windowless {
    /// Acquires such a device, blocking until it has one — allowed here for
    /// the same reason [`wait_for_gpu`] is.
    fn acquired() -> Result<Self, Error> {
        pollster::block_on(async {
            let instance =
                wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
            let adapter = instance
                .request_adapter(&wgpu::RequestAdapterOptions {
                    power_preference: wgpu::PowerPreference::HighPerformance,
                    ..Default::default()
                })
                .await
                .map_err(|error| Error::msg(format!("{NO_ADAPTER}: {error}")))?;

            let (device, queue) = adapter
                .request_device(&wgpu::DeviceDescriptor {
                    label: Some("mirage-engine headless"),
                    ..Default::default()
                })
                .await
                .map_err(|error| {
                    Error::msg(format!("the graphics adapter refused a device: {error}"))
                })?;
            let faults = FaultSlot::watching(&device);

            Ok(Self {
                device,
                queue,
                faults,
            })
        })
    }
}

/// The first fault a session's device reported, kept for a caller that reads
/// it when it chooses.
struct KeptFault {
    slot: FaultSlot,
    kept: Option<Error>,
}

impl KeptFault {
    /// Nothing read yet from `slot`.
    fn over(slot: FaultSlot) -> Self {
        Self { slot, kept: None }
    }

    /// Reads the slot, keeping the first fault it holds.
    fn read(&mut self) {
        let Some(fault) = self.slot.taken() else {
            return;
        };
        self.kept
            .get_or_insert_with(|| Error::msg(fault.to_string()));
    }

    /// The error the run would end with, where a step has read a fault.
    fn error(&self) -> Option<Error> {
        self.kept.clone()
    }
}

mod driven;

#[cfg(test)]
mod tests;