From 7f75e2bfedfc9612cc26e861ba2394bc0b8b026e Mon Sep 17 00:00:00 2001 From: Joe Meyer Date: Mon, 23 Mar 2026 23:20:25 -0500 Subject: [PATCH 1/4] Init --- Cargo.toml | 11 ++ crates/bevy_winit/src/state.rs | 49 +++++- crates/bevy_winit/src/winit_config.rs | 51 ++++++ examples/README.md | 1 + examples/window/frame_limiter.rs | 224 ++++++++++++++++++++++++++ 5 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 examples/window/frame_limiter.rs diff --git a/Cargo.toml b/Cargo.toml index 86de67af7ce0c..078cbe172e7ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4146,6 +4146,17 @@ description = "Demonstrates creating an animated custom cursor from an image" category = "Window" wasm = true +[[example]] +name = "frame_limiter" +path = "examples/window/frame_limiter.rs" +doc-scrape-examples = true + +[package.metadata.example.frame_limiter] +name = "Frame Limiter" +description = "Demonstrates capping Bevy's framerate in the winit event loop" +category = "Window" +wasm = true + [[example]] name = "low_power" path = "examples/window/low_power.rs" diff --git a/crates/bevy_winit/src/state.rs b/crates/bevy_winit/src/state.rs index 4af560e16045a..982899a31f160 100644 --- a/crates/bevy_winit/src/state.rs +++ b/crates/bevy_winit/src/state.rs @@ -92,7 +92,8 @@ pub(crate) struct WinitAppRunnerState { ), >, )>, - /// time at which next tick is scheduled to run when `update_mode` is [`UpdateMode::Reactive`] + /// Scheduled start time for the next timed update when `update_mode` is + /// [`UpdateMode::Reactive`] or [`UpdateMode::ContinuousCapped`]. scheduled_tick_start: Option, } @@ -487,7 +488,10 @@ impl ApplicationHandler for WinitAppRunnerState { if self.app_exit.is_none() && (self.startup_forced_updates > 0 - || matches!(self.update_mode, UpdateMode::Reactive { .. }) + || matches!( + self.update_mode, + UpdateMode::Reactive { .. } | UpdateMode::ContinuousCapped { .. } + ) || self.window_event_received || headless_or_all_invisible()) { @@ -704,6 +708,24 @@ impl WinitAppRunnerState { self.redraw_requested = true; } } + UpdateMode::ContinuousCapped { wait } => { + if self.wait_elapsed { + self.redraw_requested = true; + + let begin_instant = + self.scheduled_tick_start.unwrap_or(begin_frame_time); + if let Some(next) = begin_instant.checked_add(wait) { + let now = Instant::now(); + if next < now { + event_loop.set_control_flow(ControlFlow::Poll); + self.scheduled_tick_start = Some(now); + } else { + event_loop.set_control_flow(ControlFlow::WaitUntil(next)); + self.scheduled_tick_start = Some(next); + } + } + } + } UpdateMode::Reactive { wait, .. } => { // Set the next timeout, starting from the instant we were scheduled to begin if self.wait_elapsed { @@ -749,6 +771,7 @@ impl WinitAppRunnerState { || self.window_event_received || self.device_event_received } + UpdateMode::ContinuousCapped { .. } => self.wait_elapsed, UpdateMode::Reactive { react_to_device_events, react_to_user_events, @@ -957,7 +980,8 @@ pub(crate) fn react_to_scale_factor_change( #[cfg(test)] mod tests { - use bevy_app::Update; + use bevy_app::{App, Update}; + use core::time::Duration; use super::*; @@ -1066,4 +1090,23 @@ mod tests { (app, window_entity) } + + #[test] + fn continuous_capped_mode_ignores_events_before_timeout() { + let mut runner_state = WinitAppRunnerState::new(App::new()); + runner_state.lifecycle = AppLifecycle::Running; + runner_state.window_event_received = true; + runner_state.device_event_received = true; + runner_state.user_event_received = true; + + assert!(!runner_state.should_update(UpdateMode::ContinuousCapped { + wait: Duration::from_millis(8), + })); + + runner_state.wait_elapsed = true; + + assert!(runner_state.should_update(UpdateMode::ContinuousCapped { + wait: Duration::from_millis(8), + })); + } } diff --git a/crates/bevy_winit/src/winit_config.rs b/crates/bevy_winit/src/winit_config.rs index 75c0ae2eaa429..87ed5d91cef95 100644 --- a/crates/bevy_winit/src/winit_config.rs +++ b/crates/bevy_winit/src/winit_config.rs @@ -58,6 +58,39 @@ impl WinitSettings { } } + /// The application will update continuously at a capped framerate. + /// + /// Uses [`ContinuousCapped`](UpdateMode::ContinuousCapped) regardless of whether windows have + /// focus. + /// + /// # Panics + /// + /// Panics if `wait` is zero. + pub fn continuous_capped(wait: Duration) -> Self { + WinitSettings { + focused_mode: UpdateMode::continuous_capped(wait), + unfocused_mode: UpdateMode::continuous_capped(wait), + } + } + + /// Default settings for games with a capped frame rate while focused. + /// + /// Uses [`ContinuousCapped`](UpdateMode::ContinuousCapped) while focused and + /// [`reactive_low_power`](UpdateMode::reactive_low_power) otherwise. + /// + /// # Panics + /// + /// Panics if `max_fps` is zero, negative, or not finite. + pub fn game_with_max_fps(max_fps: f64) -> Self { + assert!(max_fps.is_sign_positive(), "max_fps must be greater than zero"); + assert!(max_fps.is_finite(), "max_fps must be finite"); + + WinitSettings { + focused_mode: UpdateMode::continuous_capped(Duration::from_secs_f64(1.0 / max_fps)), + unfocused_mode: UpdateMode::reactive_low_power(Duration::from_secs_f64(1.0 / 60.0)), + } + } + /// Returns the current [`UpdateMode`]. /// /// **Note:** The output depends on whether the window has focus or not. @@ -85,6 +118,14 @@ pub enum UpdateMode { /// The [`App`](bevy_app::App) will update over and over, as fast as it possibly can, until an /// [`AppExit`](bevy_app::AppExit) event appears. Continuous, + /// The [`App`](bevy_app::App) will update continuously at a capped framerate. + ContinuousCapped { + /// The approximate time from the start of one update to the next. + /// + /// This should typically be set to the time per frame for the desired frame rate + /// (for example, `1.0 / 60.0` seconds for 60 FPS). + wait: Duration, + }, /// The [`App`](bevy_app::App) will update in response to the following, until an /// [`AppExit`](bevy_app::AppExit) event appears: /// - `wait` time has elapsed since the previous update @@ -108,6 +149,16 @@ pub enum UpdateMode { } impl UpdateMode { + /// Continuous mode, but capped to the provided wait interval. + /// + /// # Panics + /// + /// Panics if `wait` is zero. + pub fn continuous_capped(wait: Duration) -> Self { + assert_ne!(wait, Duration::ZERO, "wait duration must be non-zero"); + Self::ContinuousCapped { wait } + } + /// Reactive mode, will update the app for any kind of event pub fn reactive(wait: Duration) -> Self { Self::Reactive { diff --git a/examples/README.md b/examples/README.md index 8ec11decef5d1..3d63702458887 100644 --- a/examples/README.md +++ b/examples/README.md @@ -651,6 +651,7 @@ Example | Description --- | --- [Clear Color](../examples/window/clear_color.rs) | Creates a solid color window [Custom Cursor Image](../examples/window/custom_cursor_image.rs) | Demonstrates creating an animated custom cursor from an image +[Frame Limiter](../examples/window/frame_limiter.rs) | Demonstrates capping Bevy's update rate in the winit event loop [Low Power](../examples/window/low_power.rs) | Demonstrates settings to reduce power use for bevy applications [Monitor info](../examples/window/monitor_info.rs) | Displays information about available monitors (displays). [Multiple Windows](../examples/window/multiple_windows.rs) | Demonstrates creating multiple windows, and rendering to them diff --git a/examples/window/frame_limiter.rs b/examples/window/frame_limiter.rs new file mode 100644 index 0000000000000..e5addc6494052 --- /dev/null +++ b/examples/window/frame_limiter.rs @@ -0,0 +1,224 @@ +//! Demonstrates capping Bevy's frame rate in the `winit` event loop. +//! +//! Press space to toggle the limiter, up and down to change the target FPS, and V to toggle +//! VSync. The on-screen text shows the requested limit alongside the measured smoothed FPS. + +use bevy::{ + color::palettes::basic::{LIME, YELLOW}, + diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin}, + prelude::*, + window::{PresentMode, WindowPlugin}, + winit::WinitSettings, +}; + +const DEFAULT_FPS: u16 = 60; +const MIN_FPS: u16 = 30; +const MAX_FPS: u16 = 240; +const FPS_STEP: u16 = 30; + +fn main() { + App::new() + .insert_resource(FrameLimiterSettings::default()) + .insert_resource(WinitSettings::game_with_max_fps(DEFAULT_FPS as f64)) + .add_plugins(( + DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + title: "Frame limiter".into(), + present_mode: PresentMode::AutoNoVsync, + ..default() + }), + ..default() + }), + FrameTimeDiagnosticsPlugin::default(), + )) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + adjust_frame_limiter, + apply_frame_limiter, + rotate_cube, + update_overlay, + ), + ) + .run(); +} + +#[derive(Resource, Debug, Clone)] +struct FrameLimiterSettings { + enabled: bool, + max_fps: u16, + vsync: bool, +} + +impl Default for FrameLimiterSettings { + fn default() -> Self { + Self { + enabled: true, + max_fps: DEFAULT_FPS, + vsync: false, + } + } +} + +impl FrameLimiterSettings { + fn winit_settings(&self) -> WinitSettings { + if self.enabled { + WinitSettings::game_with_max_fps(self.max_fps as f64) + } else { + WinitSettings::game() + } + } + + fn present_mode(&self) -> PresentMode { + if self.vsync { + PresentMode::AutoVsync + } else { + PresentMode::AutoNoVsync + } + } + + fn limiter_label(&self) -> String { + if self.enabled { + format!("capped at {} FPS", self.max_fps) + } else { + "uncapped".to_string() + } + } + + fn vsync_label(&self) -> &'static str { + if self.vsync { + "VSync on" + } else { + "VSync off" + } + } +} + +#[derive(Component)] +struct Rotator; + +#[derive(Component)] +struct OverlayText; + +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + commands.spawn(( + Mesh3d(meshes.add(Cuboid::new(0.7, 0.7, 0.7))), + MeshMaterial3d(materials.add(Color::srgb(0.8, 0.7, 0.6))), + Rotator, + )); + + commands.spawn(( + DirectionalLight::default(), + Transform::from_xyz(1.0, 1.0, 1.0).looking_at(Vec3::ZERO, Vec3::Y), + )); + + commands.spawn(( + Camera3d::default(), + Transform::from_xyz(-2.0, 2.0, 2.0).looking_at(Vec3::ZERO, Vec3::Y), + )); + + commands.spawn(( + Text::default(), + Node { + align_self: AlignSelf::FlexStart, + position_type: PositionType::Absolute, + top: px(12), + left: px(12), + ..default() + }, + OverlayText, + children![ + TextSpan::new("Space: toggle limiter | Up/Down: target FPS | V: toggle vsync\n"), + (TextSpan::default(), TextColor(LIME.into())), + (TextSpan::new("\nSmoothed FPS: "), TextColor(YELLOW.into())), + (TextSpan::new(""), TextColor(YELLOW.into())), + ], + )); +} + +fn adjust_frame_limiter( + input: Res>, + mut settings: ResMut, +) { + let mut changed = false; + + if input.just_pressed(KeyCode::Space) { + settings.enabled = !settings.enabled; + changed = true; + } + + if input.just_pressed(KeyCode::KeyV) { + settings.vsync = !settings.vsync; + changed = true; + } + + if input.just_pressed(KeyCode::ArrowUp) { + settings.max_fps = (settings.max_fps + FPS_STEP).min(MAX_FPS); + settings.enabled = true; + changed = true; + } + + if input.just_pressed(KeyCode::ArrowDown) { + settings.max_fps = settings.max_fps.saturating_sub(FPS_STEP).max(MIN_FPS); + settings.enabled = true; + changed = true; + } + + if changed { + info!( + "Frame limiter updated: {}, {}", + settings.limiter_label(), + settings.vsync_label() + ); + } +} + +fn apply_frame_limiter( + settings: Res, + mut winit_settings: ResMut, + mut window: Single<&mut Window>, +) { + if !settings.is_changed() { + return; + } + + *winit_settings = settings.winit_settings(); + window.present_mode = settings.present_mode(); + window.title = format!( + "Frame limiter | {} | {}", + settings.limiter_label(), + settings.vsync_label() + ); +} + +fn rotate_cube(time: Res