engine/
engine.rs

1// SPDX-FileCopyrightText: 2024 Jens Pitkänen <jens.pitkanen@helsinki.fi>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5use arrayvec::ArrayVec;
6use platform::{
7    thread_pool::ThreadPool, EngineCallbacks, Event, Instant, Platform, AUDIO_SAMPLE_RATE,
8};
9
10use crate::{
11    allocators::LinearAllocator,
12    input::{EventQueue, QueuedEvent},
13    mixer::Mixer,
14    multithreading::{self, parallelize},
15    resources::{FileReader, ResourceDatabase, ResourceLoader},
16};
17
18/// Parameters affecting the memory usage of the engine, used in
19/// [`Engine::new`].
20///
21/// Note that while this does cover most persistent memory allocations made by
22/// the engine during initialization, it doesn't (currently) cover everything.
23/// For example, the memory required by asset metadata is entirely dependent on
24/// the amount of assets in the resource database.
25#[derive(Clone, Copy)]
26pub struct EngineLimits {
27    /// The size of the frame arena allocator, in bytes. The frame arena is used
28    /// for per-frame memory allocations in rendering, audio playback, and
29    /// game-specific uses.
30    ///
31    /// Defaults to 8 MiB (`8 * 1024 * 1024`).
32    pub frame_arena_size: usize,
33    /// The maximum amount of concurrently loaded resource chunks. This count,
34    /// multiplied by [`CHUNK_SIZE`](crate::resources::CHUNK_SIZE), is the
35    /// amount of bytes allocated for non-VRAM based asset memory, like audio
36    /// clips being played.
37    ///
38    /// Defaults to 128.
39    pub resource_database_loaded_chunks_count: u32,
40    /// The maximum amount of concurrently loaded sprite chunks. This, depending
41    /// on the platform, will control the amount of VRAM required by the engine.
42    /// Each sprite chunk's memory requirements depend on the platform, but each
43    /// chunk contains sprite data with the format and resolution defined by
44    /// [`SPRITE_CHUNK_FORMAT`](crate::resources::SPRITE_CHUNK_FORMAT) and
45    /// [`SPRITE_CHUNK_DIMENSIONS`](crate::resources::SPRITE_CHUNK_DIMENSIONS).
46    ///
47    /// Defaults to 1024.
48    ///
49    /// Rationale for the default, just for reference: 1024 sprite chunks with
50    /// 128x128 resolution, if stored in a tightly packed sprite atlas, would
51    /// fit exactly in 4096x4096, which is a low enough resolution to be
52    /// supported pretty much anywhere with hardware acceleration (Vulkan's
53    /// minimum allowed limit is 4096, so any Vulkan-backed platform could
54    /// provide this).
55    pub resource_database_loaded_sprite_chunks_count: u32,
56    /// The maximum amount of queued resource database reading operations. This
57    /// will generally increase disk read performance by having file reading
58    /// operations always queued up, but costs memory and might cause lagspikes
59    /// if there's too many chunks to load during a particular frame.
60    ///
61    /// Defaults to 128.
62    pub resource_database_read_queue_capacity: usize,
63    /// The size of the buffer used to read data from the resource database, in
64    /// bytes. Must be at least [`ResourceDatabase::largest_chunk_source`], but
65    /// ideally many times larger, to avoid capping out the buffer before the
66    /// read queue is even full.
67    ///
68    /// Defaults to 8 MiB (`8 * 1024 * 1024`).
69    pub resource_database_buffer_size: usize,
70    /// The amount of channels the engine's [`Mixer`] has. Each channel can be
71    /// individually controlled volume-wise, and all played sounds play on a
72    /// specific channel.
73    ///
74    /// Tip: create an enum for your game's audio channels, and use that enum
75    /// when playing back sounds, to have easily refactorable and semantically
76    /// meaningful channels. This count should cover all of the enum variants,
77    /// e.g. 3 for an enum with 3 variants for 0, 1, and 2.
78    ///
79    /// Defaults to 1.
80    pub audio_channel_count: usize,
81    /// The maximum amount of concurrently playing sounds. If more than this
82    /// amount of sounds are playing at a time, new sounds might displace old
83    /// sounds, or be ignored completely, depending on the parameters of the
84    /// sound playback function.
85    ///
86    /// Defaults to 64.
87    pub audio_concurrent_sounds_count: usize,
88    /// The amount of samples of audio rendered each frame. Note that this isn't
89    /// a traditional "buffer size", where increasing this would increase
90    /// latency: the engine can render a lot of audio ahead of time, to avoid
91    /// audio cutting off even if the game has lagspikes. In a normal 60 FPS
92    /// situation, this length could be 48000, but only the first 800 samples
93    /// would be used each frame. The sample rate of the audio is
94    /// [`AUDIO_SAMPLE_RATE`].
95    ///
96    /// Note that this window should be at least long enough to cover audio for
97    /// two frames, to avoid audio cutting off due to the platform's audio
98    /// callbacks outpacing the once-per-frame audio rendering we do. For a
99    /// pessimistic 30 FPS, this would be 3200. The default length is half a
100    /// second, i.e. `AUDIO_SAMPLE_RATE / 2`.
101    pub audio_window_length: usize,
102}
103
104impl EngineLimits {
105    /// The default configuration for the engine used in its unit tests.
106    pub const DEFAULT: EngineLimits = EngineLimits {
107        frame_arena_size: 8 * 1024 * 1024,
108        resource_database_loaded_chunks_count: 128,
109        resource_database_loaded_sprite_chunks_count: 512,
110        resource_database_read_queue_capacity: 128,
111        resource_database_buffer_size: 8 * 1024 * 1024,
112        audio_channel_count: 1,
113        audio_concurrent_sounds_count: 64,
114        audio_window_length: (AUDIO_SAMPLE_RATE / 2) as usize,
115    };
116}
117
118impl Default for EngineLimits {
119    fn default() -> Self {
120        EngineLimits::DEFAULT
121    }
122}
123
124/// The top-level structure of the game engine which owns all the runtime state
125/// of the game engine and has methods for running the engine.
126pub struct Engine<'a> {
127    /// Database of the non-code parts of the game, e.g. sprites.
128    pub resource_db: ResourceDatabase,
129    /// Queue of loading tasks which insert loaded chunks into the `resource_db`
130    /// occasionally.
131    pub resource_loader: ResourceLoader,
132    /// Linear allocator for any frame-internal dynamic allocation needs. Reset
133    /// at the start of each frame.
134    pub frame_arena: LinearAllocator<'a>,
135    /// Thread pool for splitting compute-heavy workloads to multiple threads.
136    pub thread_pool: ThreadPool,
137    /// Mixer for playing back audio.
138    pub audio_mixer: Mixer,
139    /// Queued up events from the platform layer. Discarded after
140    /// being used by the game to trigger an action via
141    /// [`InputDeviceState`](crate::input::InputDeviceState), or after
142    /// a timeout if not.
143    pub event_queue: EventQueue,
144}
145
146impl Engine<'_> {
147    /// Creates a new instance of the engine.
148    ///
149    /// - `platform`: the platform implementation to be used for this instance
150    ///   of the engine.
151    /// - `arena`: an arena for all the persistent memory the engine requires,
152    ///   e.g. the resource database.
153    /// - `limits`: defines the limits for the various subsystems of the engine,
154    ///   for dialing in the appropriate tradeoffs between memory usage and game
155    ///   requirements.
156    pub fn new(
157        platform: &dyn Platform,
158        arena: &'static LinearAllocator,
159        limits: EngineLimits,
160    ) -> Self {
161        profiling::function_scope!();
162        let mut thread_pool = multithreading::create_thread_pool(arena, platform, 1)
163            .expect("engine arena should have enough memory for the thread pool");
164
165        // Name all the threads
166        let dummy_slice = &mut [(); 1024][..thread_pool.thread_count()];
167        parallelize(&mut thread_pool, dummy_slice, |_, _| {
168            profiling::register_thread!("engine thread pool");
169        });
170        profiling::register_thread!("engine main");
171
172        let frame_arena = LinearAllocator::new(arena, limits.frame_arena_size)
173            .expect("should have enough memory for the frame arena");
174
175        let db_file = platform
176            .open_file("resources.db")
177            .expect("resources.db should exist and be readable");
178
179        let mut res_reader = FileReader::new(
180            arena,
181            db_file,
182            limits.resource_database_buffer_size,
183            limits.resource_database_read_queue_capacity,
184        )
185        .expect("engine arena should have enough memory for the resource db file reader");
186
187        let resource_db = ResourceDatabase::new(
188            platform,
189            arena,
190            &mut res_reader,
191            limits.resource_database_loaded_chunks_count,
192            limits.resource_database_loaded_sprite_chunks_count,
193        )
194        .expect("engine arena should have enough memory for the resource database");
195
196        let resource_loader = ResourceLoader::new(arena, res_reader, &resource_db)
197            .expect("engine arena should have enough memory for the resource loader");
198
199        let audio_mixer = Mixer::new(
200            arena,
201            limits.audio_channel_count,
202            limits.audio_concurrent_sounds_count,
203            limits.audio_window_length,
204        )
205        .expect("engine arena should have enough memory for the audio mixer");
206
207        Engine {
208            resource_db,
209            resource_loader,
210            frame_arena,
211            audio_mixer,
212            thread_pool,
213            event_queue: ArrayVec::new(),
214        }
215    }
216}
217
218impl EngineCallbacks for Engine<'_> {
219    fn run_frame(
220        &mut self,
221        platform: &dyn Platform,
222        run_game_frame: &mut dyn FnMut(Instant, &dyn Platform, &mut Self),
223    ) {
224        profiling::function_scope!();
225
226        let timestamp = platform.now();
227        self.frame_arena.reset();
228        self.resource_loader
229            .finish_reads(&mut self.resource_db, platform, 128);
230        self.resource_db.chunks.increment_ages();
231        self.resource_db.sprite_chunks.increment_ages();
232        self.audio_mixer.update_audio_sync(timestamp, platform);
233
234        run_game_frame(timestamp, platform, self);
235
236        self.audio_mixer.render_audio(
237            &mut self.thread_pool,
238            platform,
239            &self.resource_db,
240            &mut self.resource_loader,
241        );
242        self.resource_loader.dispatch_reads(platform);
243        self.event_queue
244            .retain(|queued| !queued.timed_out(timestamp));
245
246        profiling::finish_frame!();
247    }
248
249    fn event(&mut self, event: Event, timestamp: Instant) {
250        profiling::function_scope!();
251        self.event_queue.push(QueuedEvent { event, timestamp });
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use platform::{
258        ActionCategory, Button, EngineCallbacks, Event, InputDevice, Instant, Platform,
259    };
260
261    use crate::{
262        allocators::LinearAllocator,
263        geom::Rect,
264        input::{ActionKind, ActionState, InputDeviceState},
265        renderer::DrawQueue,
266        resources::{audio_clip::AudioClipHandle, sprite::SpriteHandle, ResourceDatabase},
267        static_allocator,
268        test_platform::TestPlatform,
269    };
270
271    use super::{Engine, EngineLimits};
272
273    #[repr(usize)]
274    enum TestInput {
275        Act,
276        _Count,
277    }
278
279    struct SmokeTestGame {
280        test_input: InputDeviceState<{ TestInput::_Count as usize }>,
281        test_sprite: SpriteHandle,
282        test_audio: AudioClipHandle,
283        test_counter: u32,
284    }
285
286    impl SmokeTestGame {
287        fn new(device: InputDevice, button: Button, resources: &ResourceDatabase) -> Self {
288            let test_sprite = resources.find_sprite("player").unwrap();
289            let test_audio = resources.find_audio_clip("whack").unwrap();
290            let test_input = InputDeviceState {
291                device,
292                actions: [
293                    // TestInput::Act
294                    ActionState {
295                        kind: ActionKind::Instant,
296                        mapping: Some(button),
297                        disabled: false,
298                        pressed: false,
299                    },
300                ],
301            };
302            Self {
303                test_input,
304                test_sprite,
305                test_audio,
306                test_counter: 0,
307            }
308        }
309
310        fn run_frame(&mut self, _: Instant, platform: &dyn Platform, engine: &mut Engine) {
311            let scale_factor = platform.draw_scale_factor();
312            let mut draw_queue =
313                DrawQueue::new(&engine.frame_arena, 100_000, scale_factor).unwrap();
314
315            self.test_input.update(&mut engine.event_queue);
316            let action_test = self.test_input.actions[TestInput::Act as usize].pressed;
317
318            if action_test {
319                engine
320                    .audio_mixer
321                    .play_clip(0, self.test_audio, true, &engine.resource_db);
322                self.test_counter += 1;
323            }
324
325            let test_sprite = engine.resource_db.get_sprite(self.test_sprite);
326            let mut offset = 0.0;
327            for mip in 0..9 {
328                if self.test_counter % 9 > mip {
329                    continue;
330                }
331                let scale = 1. / 2i32.pow(mip) as f32;
332                let w = 319.0 * scale;
333                let h = 400.0 * scale;
334                let draw_success = test_sprite.draw(
335                    Rect::xywh(offset, 0.0, w, h),
336                    0,
337                    &mut draw_queue,
338                    &engine.resource_db,
339                    &mut engine.resource_loader,
340                );
341                assert!(draw_success);
342                offset += w + 20.0;
343            }
344
345            draw_queue.dispatch_draw(&engine.frame_arena, platform);
346        }
347    }
348
349    /// Initializes the engine and simulates 4 seconds of running the engine,
350    /// with a burst of mashing the "ActPrimary" button in the middle.
351    fn run_smoke_test(platform: &TestPlatform, persistent_arena: &'static LinearAllocator) {
352        let device = platform.input_devices()[0];
353        let button = platform
354            .default_button_for_action(ActionCategory::ActPrimary, device)
355            .unwrap();
356
357        let mut engine = Engine::new(
358            platform,
359            persistent_arena,
360            EngineLimits {
361                audio_window_length: 128,
362                ..EngineLimits::DEFAULT
363            },
364        );
365
366        let mut game = SmokeTestGame::new(device, button, &engine.resource_db);
367        let mut run_frame = |timestamp: Instant, platform: &dyn Platform, engine: &mut Engine| {
368            game.run_frame(timestamp, platform, engine);
369        };
370
371        let fps = 10;
372        for current_frame in 0..(4 * fps) {
373            platform.set_elapsed_millis(current_frame * 1000 / fps);
374
375            if 2 * fps < current_frame && current_frame < 3 * fps {
376                // every three frames, either press down or release the button
377                if current_frame % 3 == 0 {
378                    engine.event(
379                        if current_frame % 2 == 0 {
380                            Event::DigitalInputPressed(device, button)
381                        } else {
382                            Event::DigitalInputReleased(device, button)
383                        },
384                        platform.now(),
385                    );
386                }
387            }
388
389            engine.run_frame(platform, &mut run_frame);
390        }
391    }
392
393    #[test]
394    #[cfg(not(target_os = "emscripten"))]
395    fn smoke_test_multithreaded() {
396        static PERSISTENT_ARENA: &LinearAllocator = static_allocator!(64 * 1024 * 1024);
397        run_smoke_test(&TestPlatform::new(true), PERSISTENT_ARENA);
398    }
399
400    #[test]
401    #[ignore = "the emscripten target doesn't support multithreading"]
402    #[cfg(target_os = "emscripten")]
403    fn smoke_test_multithreaded() {}
404
405    #[test]
406    fn smoke_test_singlethreaded() {
407        static PERSISTENT_ARENA: &LinearAllocator = static_allocator!(64 * 1024 * 1024);
408        run_smoke_test(&TestPlatform::new(false), PERSISTENT_ARENA);
409    }
410}