diff --git a/Cargo.lock b/Cargo.lock index b2d21a5..b5144aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4511,6 +4511,7 @@ dependencies = [ "chrono", "crdts", "criterion", + "crossbeam-channel", "futures-lite", "glam 0.29.3", "iroh", diff --git a/crates/app/src/executor.rs b/crates/app/src/executor.rs index e18a043..13b8d9f 100644 --- a/crates/app/src/executor.rs +++ b/crates/app/src/executor.rs @@ -30,7 +30,7 @@ pub use crate::input::event_buffer::InputEventBuffer; /// Application handler state machine enum AppHandler { - Initializing { app: App }, + Initializing { app: Option }, Running { window: Arc, bevy_window_entity: Entity, @@ -38,17 +38,62 @@ enum AppHandler { }, } -impl AppHandler { - fn initialize(&mut self, event_loop: &ActiveEventLoop) { - // Only initialize if we're in the Initializing state - if !matches!(self, AppHandler::Initializing { .. }) { - return; - } +// Helper functions to reduce duplication +fn send_window_created(app: &mut App, window: Entity) { + app.world_mut() + .resource_mut::>() + .write(WindowCreated { window }); +} - // Take ownership of the app (replace with placeholder temporarily) - let temp_state = std::mem::replace(self, AppHandler::Initializing { app: App::new() }); - let AppHandler::Initializing { app } = temp_state else { unreachable!() }; - let mut bevy_app = app; +fn send_window_resized( + app: &mut App, + window: Entity, + physical_size: winit::dpi::PhysicalSize, + scale_factor: f64, +) { + app.world_mut() + .resource_mut::>() + .write(WindowResized { + window, + width: physical_size.width as f32 / scale_factor as f32, + height: physical_size.height as f32 / scale_factor as f32, + }); +} + +fn send_scale_factor_changed(app: &mut App, window: Entity, scale_factor: f64) { + app.world_mut() + .resource_mut::>() + .write(WindowScaleFactorChanged { + window, + scale_factor, + }); +} + +fn send_window_closing(app: &mut App, window: Entity) { + app.world_mut() + .resource_mut::>() + .write(WindowClosing { window }); +} + +impl AppHandler { + /// Initialize the window and transition to Running state. + /// + /// This is called on the first `resumed()` event from winit. + /// Subsequent `resumed()` calls are ignored to prevent double initialization. + /// + /// # Errors + /// Returns an error if window creation or RawHandleWrapper creation fails. + fn initialize(&mut self, event_loop: &ActiveEventLoop) -> Result<(), String> { + // Only initialize if we're in the Initializing state with an app + let AppHandler::Initializing { app: app_opt } = self else { + warn!("initialize() called on non-Initializing state, ignoring"); + return Ok(()); + }; + + let Some(mut bevy_app) = app_opt.take() else { + warn!("initialize() called twice, ignoring"); + return Ok(()); + }; // Insert InputEventBuffer resource bevy_app.insert_resource(InputEventBuffer::default()); @@ -72,31 +117,32 @@ impl AppHandler { .with_inner_size(winit::dpi::LogicalSize::new(1280, 720)); let winit_window = event_loop.create_window(window_attributes) - .expect("Failed to create window"); + .map_err(|e| format!("Failed to create window: {}", e))?; let winit_window = Arc::new(winit_window); info!("Created window before app.finish()"); let physical_size = winit_window.inner_size(); let scale_factor = winit_window.scale_factor(); - // Create window entity with all required components + // Create window entity with all required components (use logical size) let mut window = bevy::window::Window { title: "Marathon".to_string(), resolution: WindowResolution::new( - physical_size.width, - physical_size.height, + physical_size.width / scale_factor as u32, + physical_size.height / scale_factor as u32, ), mode: WindowMode::Windowed, position: WindowPosition::Automatic, focused: true, ..Default::default() }; - window.resolution.set_scale_factor_override(Some(scale_factor as f32)); + // Set scale factor explicitly + window.resolution.set_scale_factor(scale_factor as f32); // Create WindowWrapper and RawHandleWrapper for renderer let window_wrapper = WindowWrapper::new(winit_window.clone()); let raw_handle_wrapper = RawHandleWrapper::new(&window_wrapper) - .expect("Failed to create RawHandleWrapper"); + .map_err(|e| format!("Failed to create RawHandleWrapper: {}", e))?; let window_entity = bevy_app.world_mut().spawn(( window, @@ -105,19 +151,10 @@ impl AppHandler { )).id(); info!("Created window entity {}", window_entity); - // Send WindowCreated event - bevy_app.world_mut() - .resource_mut::>() - .write(WindowCreated { window: window_entity }); - - // Send WindowResized event - bevy_app.world_mut() - .resource_mut::>() - .write(WindowResized { - window: window_entity, - width: physical_size.width as f32 / scale_factor as f32, - height: physical_size.height as f32 / scale_factor as f32, - }); + // Send initialization events + send_window_created(&mut bevy_app, window_entity); + send_window_resized(&mut bevy_app, window_entity, physical_size, scale_factor); + send_scale_factor_changed(&mut bevy_app, window_entity, scale_factor); // Now finish the app - the renderer will initialize with the window bevy_app.finish(); @@ -130,16 +167,54 @@ impl AppHandler { bevy_window_entity: window_entity, bevy_app, }; + + Ok(()) + } + + /// Clean shutdown of the app and window + fn shutdown(&mut self, event_loop: &ActiveEventLoop) { + if let AppHandler::Running { + bevy_window_entity, + ref mut bevy_app, + .. + } = self + { + info!("Shutting down gracefully"); + + // Send WindowClosing event + send_window_closing(bevy_app, *bevy_window_entity); + + // Run one final update to process close event + bevy_app.update(); + + // Cleanup + bevy_app.finish(); + bevy_app.cleanup(); + } + + event_loop.exit(); } } impl ApplicationHandler for AppHandler { fn resumed(&mut self, event_loop: &ActiveEventLoop) { // Initialize on first resumed() call - self.initialize(event_loop); + if let Err(e) = self.initialize(event_loop) { + error!("Failed to initialize: {}", e); + event_loop.exit(); + return; + } info!("App resumed"); } + // TODO(@siennathesane): Implement suspended() callback for mobile platforms. + // On iOS/Android, the app can be backgrounded (suspended). We should: + // - Stop requesting redraws to save battery + // - Potentially release GPU resources + // - Log the suspended state for debugging + // Note: RedrawRequested events won't fire while suspended anyway, + // so the unbounded loop naturally pauses. + fn window_event( &mut self, event_loop: &ActiveEventLoop, @@ -161,41 +236,35 @@ impl ApplicationHandler for AppHandler { match event { WinitWindowEvent::CloseRequested => { - info!("Window close requested"); - event_loop.exit(); + self.shutdown(event_loop); } WinitWindowEvent::Resized(physical_size) => { // Notify Bevy of window resize let scale_factor = window.scale_factor(); - bevy_app.world_mut() - .resource_mut::>() - .write(WindowResized { - window: *bevy_window_entity, - width: physical_size.width as f32 / scale_factor as f32, - height: physical_size.height as f32 / scale_factor as f32, - }); + send_window_resized(bevy_app, *bevy_window_entity, physical_size, scale_factor); } WinitWindowEvent::RedrawRequested => { // Collect input events from platform bridge let input_events = desktop::drain_as_input_events(); - // Write events to InputEventBuffer resource - bevy_app.world_mut().resource_mut::().events = input_events; + // Reuse buffer capacity instead of replacing (optimization) + { + let mut buffer = bevy_app.world_mut().resource_mut::(); + buffer.events.clear(); + buffer.events.extend(input_events); + } // Run one Bevy ECS update (unbounded) bevy_app.update(); // Check if app should exit - if let Some(exit) = bevy_app.should_exit() { - info!("App exit requested: {:?}", exit); - event_loop.exit(); + if bevy_app.should_exit().is_some() { + self.shutdown(event_loop); + return; } - // Clear input buffer for next frame - bevy_app.world_mut().resource_mut::().clear(); - // Request next frame immediately (unbounded loop) window.request_redraw(); } @@ -205,29 +274,70 @@ impl ApplicationHandler for AppHandler { } fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { - // Request redraw to keep loop running + // Ensure we keep rendering even if no window events arrive. + // This is needed for unbounded mode with ControlFlow::Poll to maintain + // maximum frame rate by continuously requesting redraws. if let AppHandler::Running { ref window, .. } = self { window.request_redraw(); } } } -/// Run the application executor +/// Run the application executor with a custom event loop. +/// +/// This executor owns the winit event loop directly (instead of using Bevy's WinitPlugin) +/// and drives both the window and Bevy's ECS in unbounded mode for maximum performance. +/// +/// # Unbounded Execution +/// +/// The executor runs as fast as possible using `ControlFlow::Poll`, which means: +/// - The window event loop runs continuously without waiting for events +/// - Bevy's ECS `app.update()` is called on every frame +/// - Both rendering and game logic run unbounded (no frame rate cap) +/// +/// This provides maximum performance but will drain battery quickly. +/// See the TODO comment about adaptive frame rate limiting for production use. +/// +/// # Window Lifecycle +/// +/// 1. Event loop starts and calls `resumed()` (winit requirement) +/// 2. Window and window entity are created BEFORE `app.finish()` +/// 3. Bevy's renderer initializes during `app.finish()` with the window available +/// 4. App transitions to Running state and begins the render loop +/// +/// # Errors +/// +/// Returns an error if: +/// - The event loop cannot be created +/// - Window creation fails during initialization +/// - The event loop encounters a fatal error +/// +/// # Example +/// +/// ```no_run +/// use bevy::prelude::*; +/// +/// let app = App::new(); +/// // ... configure app with plugins and systems ... +/// +/// executor::run(app).expect("Failed to run executor"); +/// ``` pub fn run(app: App) -> Result<(), Box> { let event_loop = EventLoop::new()?; - // TODO: Add battery power detection and adaptive frame/tick rate limiting + // TODO(@siennathesane): Add battery power detection and adaptive frame/tick rate limiting // When on battery: reduce to 60fps cap, lower ECS tick rate // When plugged in: run unbounded for maximum performance + // See GitHub issue #TBD for implementation details // Run as fast as possible (unbounded) event_loop.set_control_flow(ControlFlow::Poll); info!("Starting executor (unbounded mode)"); - // Create handler in Initializing state + // Create handler in Initializing state with the app // It will transition to Running state on first resumed() callback - let mut handler = AppHandler::Initializing { app }; + let mut handler = AppHandler::Initializing { app: Some(app) }; event_loop.run_app(&mut handler)?; diff --git a/crates/libmarathon/Cargo.toml b/crates/libmarathon/Cargo.toml index 20bc0f8..479d713 100644 --- a/crates/libmarathon/Cargo.toml +++ b/crates/libmarathon/Cargo.toml @@ -27,6 +27,7 @@ blake3 = "1.5" rand = "0.8" tokio.workspace = true blocking = "1.6" +crossbeam-channel = "0.5" iroh = { workspace = true, features = ["discovery-local-network"] } iroh-gossip.workspace = true diff --git a/crates/libmarathon/src/platform/desktop/winit_bridge.rs b/crates/libmarathon/src/platform/desktop/winit_bridge.rs index e19cd0e..a258cd1 100644 --- a/crates/libmarathon/src/platform/desktop/winit_bridge.rs +++ b/crates/libmarathon/src/platform/desktop/winit_bridge.rs @@ -4,8 +4,9 @@ //! to engine-agnostic InputEvents. use crate::engine::{InputEvent, KeyCode, Modifiers, MouseButton, TouchPhase}; +use crossbeam_channel::{Receiver, Sender, unbounded}; use glam::Vec2; -use std::sync::Mutex; +use std::sync::{Mutex, OnceLock}; use winit::event::{ElementState, MouseButton as WinitMouseButton, MouseScrollDelta, WindowEvent}; use winit::keyboard::PhysicalKey; @@ -31,11 +32,15 @@ pub enum RawWinitEvent { }, } -/// Thread-safe buffer for winit events +/// Lock-free channel for winit events /// -/// The winit event loop pushes events here. -/// The engine drains them each frame. -static BUFFER: Mutex> = Mutex::new(Vec::new()); +/// The winit event loop sends events here. +/// The engine receives them each frame. +static EVENT_CHANNEL: OnceLock<(Sender, Receiver)> = OnceLock::new(); + +fn get_event_channel() -> &'static (Sender, Receiver) { + EVENT_CHANNEL.get_or_init(|| unbounded()) +} /// Current input state for tracking drags and modifiers static INPUT_STATE: Mutex = Mutex::new(InputState { @@ -83,13 +88,13 @@ pub fn push_window_event(event: &WindowEvent) { MouseButton::Middle => input_state.middle_pressed = *state == ElementState::Pressed, } - if let Ok(mut buf) = BUFFER.lock() { - buf.push(RawWinitEvent::MouseButton { - button: mouse_button, - state: *state, - position, - }); - } + // Send to lock-free channel (never blocks or fails) + let (sender, _) = get_event_channel(); + let _ = sender.send(RawWinitEvent::MouseButton { + button: mouse_button, + state: *state, + position, + }); } } @@ -101,9 +106,8 @@ pub fn push_window_event(event: &WindowEvent) { // Generate drag events for any pressed buttons if input_state.left_pressed || input_state.right_pressed || input_state.middle_pressed { - if let Ok(mut buf) = BUFFER.lock() { - buf.push(RawWinitEvent::CursorMoved { position: pos }); - } + let (sender, _) = get_event_channel(); + let _ = sender.send(RawWinitEvent::CursorMoved { position: pos }); } } } @@ -112,13 +116,12 @@ pub fn push_window_event(event: &WindowEvent) { // Only handle physical keys if let PhysicalKey::Code(key_code) = key_event.physical_key { if let Ok(input_state) = INPUT_STATE.lock() { - if let Ok(mut buf) = BUFFER.lock() { - buf.push(RawWinitEvent::Keyboard { - key: key_code, - state: key_event.state, - modifiers: input_state.modifiers, - }); - } + let (sender, _) = get_event_channel(); + let _ = sender.send(RawWinitEvent::Keyboard { + key: key_code, + state: key_event.state, + modifiers: input_state.modifiers, + }); } } } @@ -141,12 +144,11 @@ pub fn push_window_event(event: &WindowEvent) { }; if let Ok(input_state) = INPUT_STATE.lock() { - if let Ok(mut buf) = BUFFER.lock() { - buf.push(RawWinitEvent::MouseWheel { - delta: scroll_delta, - position: input_state.last_position, - }); - } + let (sender, _) = get_event_channel(); + let _ = sender.send(RawWinitEvent::MouseWheel { + delta: scroll_delta, + position: input_state.last_position, + }); } } @@ -157,17 +159,15 @@ pub fn push_window_event(event: &WindowEvent) { /// Drain all buffered winit events and convert to InputEvents /// /// Call this from your engine's input processing to consume events. +/// This uses a lock-free channel so it never blocks and can't silently drop events. pub fn drain_as_input_events() -> Vec { - BUFFER - .lock() - .ok() - .map(|mut b| { - std::mem::take(&mut *b) - .into_iter() - .filter_map(raw_to_input_event) - .collect() - }) - .unwrap_or_default() + let (_, receiver) = get_event_channel(); + + // Drain all events from the channel + receiver + .try_iter() + .filter_map(raw_to_input_event) + .collect() } /// Convert a raw winit event to an engine InputEvent