The actual process that the program goes through to initialize the game and then update it every frame is relatively straightforward, but it has a lot of moving parts that are difficult to piece together by reading the source code. With the way the project is currently organized, walking the process by following the function calls requires jumping across many different files. To help me better internalize this process I decided to write this blog post to explain how Mirador's engine is initialized and updated.
When you run cargo run
, the program begins with minimal state. The event_loop
and app
are created, but the app starts without a window or game state. This separation allows the event loop to establish itself before any rendering resources are allocated.
The app only gains its window and state when the event loop calls the resumed
method. This method handles the transition from initialization to active execution by creating the window and initializing the AppState
. The state initialization is where renderers and game logic are set up - this is the foundation that everything else builds upon.
The event loop serves as the central nervous system of the application. It manages the lifecycle of the window and coordinates all system events. The initialization process follows a specific sequence that begins with creating the event loop and application instance, then transitions to window creation when the application is resumed.
async fn run() {
let event_loop = EventLoop::new().unwrap();
let mut app = App::new();
event_loop.run_app(&mut app).expect("Failed to run app");
}
This simple structure belies the complexity beneath. The event loop must handle multiple types of events:
The separation between event loop creation and window initialization is deliberate. This two-phase approach allows the system to establish its event handling infrastructure before allocating expensive rendering resources.
The resumed
method represents the critical transition point where the application moves from initialization to active execution. It creates a maximized window and then calls the set_window
method to handle the actual initialization of all game systems.
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
// Create window with maximized state
let window_attributes = Window::default_attributes().with_maximized(true);
let window = event_loop.create_window(window_attributes).unwrap();
// Initialize the application state asynchronously
pollster::block_on(self.set_window(window));
}
The set_window
method orchestrates the creation of the WGPU surface and the initialization of the AppState
. This is where the real work begins - the method creates the surface from the window, determines the appropriate dimensions based on whether the window is maximized, and then initializes the application state asynchronously.
The AppState
initialization is where the real work begins. This is where renderers are created, game logic is initialized, and all the systems that will drive the application are set up. The initialization process follows a specific order dictated by dependencies between systems.
The WGPU renderer initialization represents the most computationally expensive part of the startup process. It involves creating the GPU adapter, device, and queue, configuring the surface, and setting up all the rendering pipelines. This step must complete before any other rendering-dependent systems can be initialized.
Following the renderer initialization, the text renderer is created. This system handles all UI text rendering and requires access to the WGPU device and queue. The text renderer loads fonts and prepares the text rendering infrastructure that will be used throughout the application's lifetime.
The game state is initialized next, providing the foundation for all game logic. This includes the player, enemy, maze systems, and audio manager. The game state serves as the central repository for all mutable game data and coordinates interactions between different game systems.
UI systems are initialized after the core game state is established. The pause menu and upgrade menu are created with references to the WGPU device and queue, allowing them to render their own UI elements. These systems provide the interface layer that allows players to interact with the game state.
Finally, benchmarking components are initialized to provide performance monitoring capabilities. The profiler tracks timing information for different pipeline stages, while the frame rate counter monitors rendering performance throughout the application's lifetime.
This initialization process establishes the foundation for everything that follows. Each component must be created in the correct order, as dependencies exist between systems. The renderer needs the window, the text renderer needs the renderer for asset loading, and the UI managers need both the renderer and game state.
Once fully initialized, the app enters its main processing loop. It waits for events from the event loop, processes them, then calls handle_redraw()
to begin the next frame. This pattern continues throughout the program's lifetime.
The event processing loop follows a specific pattern designed to maintain responsiveness while ensuring consistency. The system uses the ApplicationHandler
trait to implement event handling, which provides a structured approach to processing different types of events.
The input processing system must handle multiple types of input simultaneously while maintaining consistency. The system processes device events for mouse movement and window events for keyboard and mouse input, ensuring that all input is captured and routed to the appropriate systems. Mouse movement is handled through device events and is only processed when the game is in an active state and mouse capture is enabled. This allows the player to control the camera orientation while preventing unwanted movement during menu interactions or loading screens.
fn device_event(&mut self, _event_loop: &ActiveEventLoop, _device_id: DeviceId, event: DeviceEvent) {
if let DeviceEvent::MouseMotion { delta } = event {
if let Some(state) = self.state.as_mut() {
if (state.game_state.current_screen == CurrentScreen::Game
|| state.game_state.current_screen == CurrentScreen::ExitReached)
&& state.game_state.capture_mouse {
// Allow mouse movement in both Game and ExitReached screens
state.game_state.player.mouse_movement(delta.0, delta.1);
}
state.triage_mouse(window);
}
}
}
Keyboard input is processed through window events and includes both movement controls and system commands. The system distinguishes between movement keys, which are handled continuously, and action keys, which are processed immediately when pressed. This separation allows for responsive gameplay while maintaining clean input handling.
The input system must coordinate between multiple components:
This coordination requires careful state management to ensure that input events are processed in the correct order and that state changes are propagated to all relevant systems.
The redraw process triggers the rendering pipeline. The handle_redraw()
method orchestrates the complete rendering pipeline, handling different game screens, updating game state, and managing UI overlays.
The rendering pipeline uses a state machine approach to determine what to render. The system examines the current game screen and routes rendering to the appropriate handler. This ensures that the correct visual elements are displayed regardless of the current game state. Loading screens are handled by a specialized renderer that displays maze generation progress and loading bars. The loading renderer updates the maze visualization in real-time as the generation algorithm progresses, providing visual feedback to the player during the initialization process. Title screens are handled by a dedicated title renderer that displays the game's branding and provides the entry point for new games. The title screen serves as the main menu and handles transitions to the loading screen when the player begins a new game. Game screens render the main 3D environment, including the maze geometry, player, enemy, and UI overlays. The game renderer manages multiple rendering passes to create the complete scene, including depth buffering, lighting, and post-processing effects. Pause and upgrade menus are rendered as overlays on top of the current game state. These menus use their own rendering systems to create semi-transparent backgrounds and interactive elements that don't interfere with the underlying game rendering.
The actual rendering process follows a specific sequence designed to maximize performance. The system creates a command encoder to batch GPU operations, updates the canvas surface with the current game state, and then executes multiple render passes to create the final frame.
// Prepare rendering commands
let mut encoder = state.wgpu_renderer.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
// Update canvas surface
let (surface_view, surface_texture) = state.wgpu_renderer.update_canvas(
window,
&mut encoder,
&state.game_state,
&mut state.text_renderer,
state.start_time,
)?;
// Submit commands and present
state.wgpu_renderer.queue.submit(Some(encoder.finish()));
surface_texture.present();
state.wgpu_renderer.device.poll(wgpu::Maintain::Poll);
Text rendering occurs first, ensuring that all UI text is properly positioned and styled. The text renderer prepares vertex buffers for all visible text elements and renders them using specialized shaders that handle font rendering and text layout.
Game object rendering follows, drawing the 3D environment, player, and enemy. This pass uses the main rendering pipeline with depth buffering and lighting calculations to create the immersive game world.
UI overlays are rendered last to ensure they appear on top of all other elements. The pause menu and upgrade menu use their own rendering systems that create semi-transparent backgrounds and interactive elements.
The system submits all rendering commands to the GPU and presents the completed frame. Device polling ensures that GPU operations are properly synchronized and resources are cleaned up between frames.
This rendering sequence ensures that:
The rendering pipeline must balance several competing concerns:
Frame Rate Consistency: The system targets 60 FPS, which requires each frame to complete within 16.67 milliseconds. This includes:The pipeline can be divided into two main phases: application initialization and the per-frame event loop.
1. Event Loop and App Creation (run_app
in src/main.rs
): The program begins by creating the event loop and the main application instance. At this stage, no window or rendering resources are allocated.
2. Window and State Initialization (resumed
in src/app/mod.rs
): When the event loop calls the resumed
method, the application creates a maximized window and initializes all core state, including rendering and game logic. This setup is performed asynchronously to ensure all resources are ready before entering the main loop.
1. Event Processing
window_event
): Handles window changes like resize or close.device_event
): Captures input from mouse and keyboard.2. Frame Preparation
handle_frame_timing
, key_state.update
): Calculates delta time and updates accumulated input state.update_game_ui
, audio_manager.update
): Applies input to game state and updates UI and audio systems accordingly.3. Rendering Pipeline
clear_render_target
, render_stars
, render_game_objects
): Clears the screen, renders the starfield, maze, and enemies.render_timer_bar_overlay
, render_stamina_bar_overlay
, render_compass
): Draws gameplay overlays like the timer, stamina bar, and compass.handle_score_and_level_text
, render_text
, text_renderer.prepare
, text_renderer.render
): Handles layout and rendering for all UI text and debug info.pause_menu.render
, upgrade_menu.render
): Draws pause and upgrade menu overlays when active.4. Finalization
queue.submit
, surface_texture.present
): Submits all GPU commands and presents the final frame to the screen.window.request_redraw
): Requests the next frame, continuing the loop.The pipeline described above is intentionally condensed to aid comprehension. For clarity, a more detailed and precise sequence of operations that occurs during each frame is outlined below:
Event Loop Phase (repeats every frame):1. Window Event Processing (window_event
in src/app/event_handler.rs
): Handles window-related events as they arrive, such as resizing, closing, and focus changes. This step ensures the application responds promptly to changes in the window's state.
2. Device Event Processing (device_event
in src/app/event_handler.rs
): Processes device events, including input from the keyboard and mouse. Input state is updated immediately when events are received.
3. Frame Timing (handle_frame_timing
in src/app/update.rs
): Delta time is calculated and frame timing is updated before rendering begins.
4. Handle Redraw (handle_redraw
in src/app/update.rs
): Updates the state based on the current game screen to prepare all the information that the renderer needs to draw the next frame.
key_state.update
in src/app/update.rs
): Input state is processed and accumulated input from the previous event processing step is applied to game state.update_game_ui
in src/app/update.rs
): Game UI systems are updated based on the current game state and input.audio_manager.update
in src/app/update.rs
): Audio systems are updated based on game state.clear_render_target
in src/renderer/wgpu_lib.rs
): The render target is cleared with the game's background color.render_stars
in src/renderer/wgpu_lib.rs
): Animated starfield background is rendered to create the game's atmosphere.render_game_objects
in src/renderer/wgpu_lib.rs
): The core game elements are rendered:game_renderer.render_game
in src/renderer/game_renderer/mod.rs
): 3D maze walls and floor with depth testingenemy_renderer.render
in src/renderer/game_renderer/enemy.rs
): Enemy sprites that face the playerrender_timer_bar_overlay
in src/renderer/wgpu_lib.rs
): Time remaining indicator is rendered at the top of the screen.render_stamina_bar_overlay
in src/renderer/wgpu_lib.rs
): Player stamina indicator is rendered below the timer bar.render_compass
in src/renderer/wgpu_lib.rs
): Directional compass overlay is rendered to guide the player.handle_score_and_level_text
in src/renderer/text.rs
): Score and level text elements are positioned and sized.render_text
in src/renderer/wgpu_lib.rs
): All game UI text elements are rendered to the screen.create_text_buffer
in src/app/update.rs
): Debug information is prepared and rendered when the debug panel is visible.text_renderer.prepare
in src/app/update.rs
): All UI text elements are prepared for rendering with updated layouts and styles.text_renderer.render
in src/app/update.rs
): All visible text elements are rendered to the screen using specialized font shaders.pause_menu.render
in src/app/update.rs
): Semi-transparent overlay and pause menu buttons are rendered when the game is paused.upgrade_menu.render
in src/app/update.rs
): Semi-transparent overlay and upgrade menu buttons are rendered when the upgrade menu is active.queue.submit
in src/app/update.rs
): GPU commands are submitted to the GPU for execution.surface_texture.present
in src/app/update.rs
): The completed frame is presented to the display.window.request_redraw
in src/app/update.rs
): The application requests the next redraw, continuing the cycle.This flow repeats continuously throughout the application's lifetime, with each iteration representing one frame of the game.
The pipeline must maintain proper synchronization between different systems. Frame timing calculations ensure that all systems operate with consistent timing information, while FPS monitoring provides feedback on rendering performance.
fn handle_frame_timing(&mut self, current_time: Instant) {
if let Some(state) = self.state.as_mut() {
let duration = current_time.duration_since(state.game_state.last_fps_time);
state.elapsed_time = current_time.duration_since(state.start_time);
state.game_state.frame_count += 1;
if duration.as_secs_f32() >= 1.0 {
state.game_state.current_fps = state.game_state.frame_count;
state.game_state.frame_count = 0;
state.game_state.last_fps_time = current_time;
}
let delta_time = current_time.duration_since(state.game_state.last_frame_time).as_secs_f32();
state.game_state.delta_time = delta_time;
state.game_state.last_frame_time = current_time;
}
}
The system calculates delta time between frames to ensure smooth gameplay regardless of frame rate variations. This timing information is used by all game systems that need to update based on elapsed time, such as player movement, enemy AI, and animation systems.
FPS monitoring tracks rendering performance and provides debugging information when performance issues arise. The system maintains a rolling average of frame times and can adapt rendering quality based on performance metrics.
This synchronization ensures that:
The pipeline must be robust enough to handle various failure scenarios. The system includes comprehensive error handling for GPU context loss, input system failures, and rendering errors. GPU context loss is a common issue on Windows systems and requires the renderer to reinitialize all GPU resources. The system detects context loss and automatically recreates the rendering pipeline without requiring application restart. Input system failures are handled gracefully by resetting input state and preventing cascading failures. The system logs errors for debugging while maintaining application stability. Rendering failures are handled by attempting to recover the renderer and continuing with reduced functionality if recovery fails. This ensures that the application remains responsive even when rendering encounters problems.
The pipeline includes built-in performance monitoring to identify bottlenecks. The system tracks timing information for each pipeline stage, allowing developers to identify performance issues and optimize specific components. Performance metrics include frame time, event processing time, state update time, render time, and GPU wait time. These metrics are collected continuously and can be analyzed to identify performance bottlenecks. The monitoring system allows the application to:
The rendering pipeline serves as the backbone of Mirador's architecture. It provides the foundation for all game systems and ensures that the application remains responsive and visually consistent.
Separation of Concerns: Each pipeline stage has a specific responsibility:This simple flow - event processing followed by state-based rendering - provides the foundation for Mirador's real-time graphics. The separation between event handling and rendering allows the system to respond to input while maintaining consistent visual output. In the code base this process is scattered across dozens of files making it hard to keep it all in your head at once. Hopefully this blog post makes it a little easier to understand the procedure that is repeated 60 times a second to produce the experience that is Mirador.