Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 45 additions & 3 deletions crates/bevy_winit/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Instant>,
}

Expand Down Expand Up @@ -487,7 +488,10 @@ impl ApplicationHandler<WinitUserEvent> 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())
{
Expand Down Expand Up @@ -704,6 +708,23 @@ 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 {
Expand Down Expand Up @@ -749,6 +770,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,
Expand Down Expand Up @@ -957,7 +979,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::*;

Expand Down Expand Up @@ -1066,4 +1089,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),
}));
}
}
54 changes: 54 additions & 0 deletions crates/bevy_winit/src/winit_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,42 @@ 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.
Expand Down Expand Up @@ -85,6 +121,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
Expand All @@ -108,6 +152,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 {
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 framerate 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
Expand Down
188 changes: 188 additions & 0 deletions examples/window/frame_limiter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! Demonstrates capping Bevy's frame rate in the `winit` event loop.
//!
//! Press space to toggle the limiter and up and down to change the target FPS.
//! 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))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really don't like defaulting to 60 fps locked. Winit exposes the monitor refresh rate which should always be used when it's available.

Copy link
Copy Markdown
Member

@aevyrie aevyrie Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, winit only provides monitor Hz to the nearest integer. If you don't round your fps up, you'll end up accumulating frames and latency, which defeats the point of pacing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.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,
}

impl Default for FrameLimiterSettings {
fn default() -> Self {
Self {
enabled: true,
max_fps: DEFAULT_FPS,
}
}
}

impl FrameLimiterSettings {
fn winit_settings(&self) -> WinitSettings {
if self.enabled {
WinitSettings::game_with_max_fps(self.max_fps as f64)
} else {
WinitSettings::game()
}
}

fn limiter_label(&self) -> String {
if self.enabled {
format!("capped at {} FPS", self.max_fps)
} else {
"uncapped".to_string()
}
}
}

#[derive(Component)]
struct Rotator;

#[derive(Component)]
struct OverlayText;

fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
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\n"),
(TextSpan::default(), TextColor(LIME.into())),
(TextSpan::new("\nSmoothed FPS: "), TextColor(YELLOW.into())),
(TextSpan::new(""), TextColor(YELLOW.into())),
],
));
}

fn adjust_frame_limiter(
input: Res<ButtonInput<KeyCode>>,
mut settings: ResMut<FrameLimiterSettings>,
) {
let mut changed = false;

if input.just_pressed(KeyCode::Space) {
settings.enabled = !settings.enabled;
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());
}
}

fn apply_frame_limiter(
settings: Res<FrameLimiterSettings>,
mut winit_settings: ResMut<WinitSettings>,
mut window: Single<&mut Window>,
) {
if !settings.is_changed() {
return;
}

*winit_settings = settings.winit_settings();
window.title = format!("Frame limiter | {}", settings.limiter_label());
}

fn rotate_cube(time: Res<Time>, mut cube_transform: Query<&mut Transform, With<Rotator>>) {
for mut transform in &mut cube_transform {
transform.rotate_x(time.delta_secs());
transform.rotate_local_y(time.delta_secs());
}
}

fn update_overlay(
settings: Res<FrameLimiterSettings>,
diagnostics: Res<DiagnosticsStore>,
text: Single<Entity, With<OverlayText>>,
mut writer: TextUiWriter,
) {
*writer.text(*text, 1) = format!("Mode: {}", settings.limiter_label(),);

let fps = diagnostics
.get(&FrameTimeDiagnosticsPlugin::FPS)
.and_then(bevy::diagnostic::Diagnostic::smoothed)
.map(|value| format!("{value:.1}"))
.unwrap_or_else(|| "--".to_string());
*writer.text(*text, 3) = fps;
}