Skip to content
Open
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
15 changes: 15 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,9 @@ bevy_ui_render = ["bevy_internal/bevy_ui_render"]
# Windowing layer
bevy_window = ["bevy_internal/bevy_window"]

# Adds frame pacing diagnostics and logs
framepace_debug = ["bevy_render", "bevy_internal/framepace_debug"]

# winit window and input backend
bevy_winit = ["bevy_internal/bevy_winit"]

Expand Down Expand Up @@ -4403,6 +4406,18 @@ description = "Demonstrates creating an animated custom cursor from an image"
category = "Window"
wasm = true

[[example]]
name = "framepace"
path = "examples/window/framepace.rs"
doc-scrape-examples = true
required-features = ["framepace_debug"]

[package.metadata.example.framepace]
name = "Framepace"
description = "Demonstrates frame pacing"
category = "Window"
wasm = false

[[example]]
name = "low_power"
path = "examples/window/low_power.rs"
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_internal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ bevy_animation = ["dep:bevy_animation", "bevy_mesh"]
bevy_mikktspace = ["bevy_mesh?/bevy_mikktspace"]
bevy_window = ["dep:bevy_window", "dep:bevy_a11y", "bevy_image"]
bevy_winit = ["dep:bevy_winit", "bevy_window"]
framepace_debug = ["bevy_render", "bevy_render/framepace_debug"]
bevy_camera = ["dep:bevy_camera", "bevy_mesh", "bevy_window"]
bevy_world_serialization = ["dep:bevy_world_serialization", "bevy_asset"]
bevy_material = ["dep:bevy_material", "bevy_image", "bevy_shader"]
Expand Down
4 changes: 4 additions & 0 deletions crates/bevy_render/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ serialize = ["bevy_mesh/serialize"]

reflect_auto_register = ["bevy_app/reflect_auto_register"]
reflect_functions = ["bevy_app/reflect_functions"]
framepace_debug = []

[dependencies]
# bevy
Expand Down Expand Up @@ -127,6 +128,9 @@ proptest-derive = "0.8"
# noop backend used for tests via test_utils.rs
wgpu = { version = "30", default-features = false, features = ["noop"] }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
spin_sleep = "1.3"

[target.'cfg(all(target_arch = "wasm32", target_feature = "atomics"))'.dependencies]
send_wrapper = { version = "0.6.0" }

Expand Down
38 changes: 38 additions & 0 deletions crates/bevy_render/src/framepace/debug.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! Adds diagnostics for frame pacing.

use bevy_app::prelude::*;
use bevy_diagnostic::{Diagnostic, DiagnosticPath, Diagnostics, RegisterDiagnostic};
use bevy_ecs::prelude::*;

use super::FramePaceStats;

/// Adds [`Diagnostics`] data from [`FramePaceStats`].
pub struct FramepaceDiagnosticsPlugin;

impl Plugin for FramepaceDiagnosticsPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, Self::diagnostic_system)
.register_diagnostic(Diagnostic::new(Self::FRAMEPACE_FRAMETIME).with_suffix("ms"))
.register_diagnostic(Diagnostic::new(Self::FRAMEPACE_OVERSLEEP).with_suffix("µs"));
}
}

impl FramepaceDiagnosticsPlugin {
/// Frame time before the frame limiter sleeps, in milliseconds.
pub const FRAMEPACE_FRAMETIME: DiagnosticPath =
DiagnosticPath::const_new("framepace/frametime");
/// Time the frame limiter slept past its target, in microseconds.
pub const FRAMEPACE_OVERSLEEP: DiagnosticPath =
DiagnosticPath::const_new("framepace/oversleep");

fn diagnostic_system(mut diagnostics: Diagnostics, stats: Res<FramePaceStats>) {
if let Ok(frametime) = stats.frametime.try_lock() {
let frametime_millis = frametime.as_secs_f64() * 1_000.0;
diagnostics.add_measurement(&Self::FRAMEPACE_FRAMETIME, || frametime_millis);
}
if let Ok(oversleep) = stats.oversleep.try_lock() {
let oversleep_micros = oversleep.as_secs_f64() * 1_000_000.0;
diagnostics.add_measurement(&Self::FRAMEPACE_OVERSLEEP, || oversleep_micros);
}
}
}
274 changes: 274 additions & 0 deletions crates/bevy_render/src/framepace/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
//! This is a `bevy` plugin that adds framepacing and framelimiting to improve input latency and
//! power use.
//!
//! # How it works
//!
//! This works by sleeping the app immediately before the event loop starts. In doing so, this
//! minimizes the time from when user input is captured (start of event loop), to when the frame is
//! presented on screen. Graphically, it looks like this:
//!
//! ```none
//! /-- latency --\ /-- latency --\
//! sleep -> input -> render -> sleep -> input -> render
//! \----- event loop -----/ \----- event loop -----/
//! ```
//!
//! One of the interesting benefits of this is that you can keep latency low even if the framerate
//! is limited to a low value. Assuming you are able to reach the target frametime, there should be
//! no difference in motion-to-photon latency when limited to 10fps or 120fps.
//!
//! ```none
//! same same
//! /-- latency --\ /-- latency --\
//! sleep -> input -> render -> sleeeeeeeeeeeeeeeeeeeeeeeep -> input -> render
//! \----- event loop -----/ \---------------- event loop ----------------/
//! 60 fps limited to 10 fps
Comment on lines +21 to +25

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.

This is fun (especially the sleeeep part, love it!) :D , I think a table would be a better way to format this information.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That was copied from the original crate. I'd prefer keeping changes to a minimum in this PR.

//! ```

#![deny(missing_docs)]

use crate::{Render, RenderApp, RenderSystems};
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_platform::{
sync::{Arc, Mutex},
time::Instant,
};
use bevy_reflect::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use bevy_window::{Monitor, OnMonitor, Window};

use core::time::Duration;

#[cfg(feature = "framepace_debug")]
pub mod debug;

/// Adds frame-pacing and frame-limiting functionality to your [`App`].
#[derive(Debug, Clone, Component)]
pub struct FramepacePlugin;
impl Plugin for FramepacePlugin {
fn build(&self, app: &mut App) {
app.register_type::<FramepaceSettings>();

let limit = FrametimeLimit::default();
let settings = FramepaceSettings::default();
let settings_proxy = FramepaceSettingsProxy::default();
let stats = FramePaceStats::default();

app.insert_resource(settings)
.insert_resource(settings_proxy.clone())
.insert_resource(limit.clone())
.insert_resource(stats.clone())
.add_systems(Update, update_proxy_resources);

#[cfg(not(target_arch = "wasm32"))]
app.add_systems(Update, get_display_refresh_rate);

app.sub_app_mut(RenderApp)
.insert_resource(FrameTimer::default())
.insert_resource(settings_proxy)
.insert_resource(limit)
.insert_resource(stats)
.add_systems(
Render,
framerate_limiter
.in_set(RenderSystems::Cleanup)
.after(World::clear_entities),

@Cyannide Cyannide Nov 2, 2026 •

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.

AFAIK nothing runs clear_entities anymore...it's a NOP so could be removed.
Could be in RenderSystems::PostCleanup if it's meant to run last?

);
}
}

/// Framepacing plugin configuration.
#[derive(Debug, Clone, Resource, Reflect)]
#[reflect(Resource)]
pub struct FramepaceSettings {
/// Configures the framerate limiting strategy.
pub limiter: Limiter,
}
impl FramepaceSettings {
/// Builds plugin settings with the specified [`Limiter`] configuration.
pub fn with_limiter(mut self, limiter: Limiter) -> Self {
self.limiter = limiter;
self
}
}
impl Default for FramepaceSettings {
fn default() -> FramepaceSettings {
FramepaceSettings {
limiter: Limiter::Auto,
}
}
}

#[derive(Default, Debug, Clone, Resource)]
struct FramepaceSettingsProxy {
/// Configures the framerate limiting strategy.
limiter: Arc<Mutex<Limiter>>,
}

#[cfg(not(target_arch = "wasm32"))]
impl FramepaceSettingsProxy {
fn is_enabled(&self) -> bool {
self.limiter.try_lock().iter().any(|l| l.is_enabled())
}
}

fn update_proxy_resources(settings: Res<FramepaceSettings>, proxy: Res<FramepaceSettingsProxy>) {
if settings.is_changed()
&& let Ok(mut limiter) = proxy.limiter.try_lock()
{
*limiter = settings.limiter.clone();
}
}

/// Configures the framelimiting technique for the app.
#[derive(Debug, Default, Clone, Reflect)]
pub enum Limiter {
/// Uses the window's refresh rate to set the frametime limit, updating when the window changes
/// monitors.
#[default]
Auto,
/// Set a fixed manual frametime limit. This should be greater than the monitors frametime
/// (`1.0 / monitor frequency`).
Manual(Duration),
/// Disables frame limiting
Off,
}

impl Limiter {
/// Returns `true` if the [`Limiter`] is enabled.
pub fn is_enabled(&self) -> bool {
!matches!(self, Limiter::Off)
}

/// Constructs a new [`Limiter`] from the provided `framerate`.
pub fn from_framerate(framerate: f64) -> Self {
Limiter::Manual(Duration::from_secs_f64(1.0 / framerate))
}
}

impl std::fmt::Display for Limiter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Limiter::Auto => write!(f, "Auto"),
Limiter::Manual(t) => write!(f, "{:.2} fps", 1.0 / t.as_secs_f32()),
Limiter::Off => write!(f, "Off"),
}
}
}

/// Current frametime limit based on settings and monitor refresh rate.
#[derive(Debug, Default, Clone, Resource)]
pub struct FrametimeLimit(pub Arc<Mutex<Duration>>);

/// Tracks the instant of the end of the previous frame.
#[derive(Debug, Clone, Resource, Reflect)]
pub struct FrameTimer {
sleep_end: Instant,
}
impl Default for FrameTimer {
fn default() -> Self {
FrameTimer {
sleep_end: Instant::now(),
}
}
}

#[cfg(not(target_arch = "wasm32"))]
fn get_display_refresh_rate(
settings: Res<FramepaceSettings>,
windows: Query<&OnMonitor, With<Window>>,
monitors: Query<&Monitor>,
frame_limit: Res<FrametimeLimit>,
) {
let new_frametime = match settings.limiter {
Limiter::Auto => match detect_frametime(&windows, &monitors) {
Some(frametime) => frametime,
None => return,
},
Limiter::Manual(frametime) => frametime,
Limiter::Off => {
#[cfg(feature = "framepace_debug")]
if settings.is_changed() {
bevy_log::info!("Frame limiter disabled");
}
return;
}
};

if let Ok(mut limit) = frame_limit.0.try_lock()
&& new_frametime != *limit
{
#[cfg(feature = "framepace_debug")]
bevy_log::info!("Frametime limit changed to: {:?}", new_frametime);
*limit = new_frametime;
}
}

/// Autodetect frame time from the refresh rate of the [`Monitor`] that each [`Window`] is on.
#[cfg(not(target_arch = "wasm32"))]
fn detect_frametime(
windows: &Query<&OnMonitor, With<Window>>,
monitors: &Query<&Monitor>,
) -> Option<Duration> {
let best_framerate = {
windows
.iter()
.filter_map(|on_monitor| monitors.get(on_monitor.0).ok()?.refresh_rate_millihertz)
.min()? as f64
/ 1000.0
- 0.5 // Winit only provides integer refresh rate values. We need to round down to handle the worst case scenario of a rounded refresh rate.
};

let best_frametime = Duration::from_secs_f64(1.0 / best_framerate);
Some(best_frametime)
}

/// Holds frame time measurements for framepacing diagnostics
#[derive(Clone, Debug, Default, Resource)]
pub struct FramePaceStats {
frametime: Arc<Mutex<Duration>>,
oversleep: Arc<Mutex<Duration>>,
}

/// Accurately sleeps until it's time to start the next frame.
///
/// The `spin_sleep` dependency makes it possible to get extremely accurate sleep times across
/// platforms. Using `std::thread::sleep()` will not be precise enough, especially windows. Using a
/// spin lock, even with `std::hint::spin_loop()`, will result in significant power usage.
///
/// `spin_sleep` sleeps as long as possible given the platform's sleep accuracy, and spins for the
/// remainder. The dependency is however not WASM compatible, which is fine, because frame limiting
/// should not be used in a browser; this would compete with the browser's frame limiter.
fn framerate_limiter(
mut timer: ResMut<FrameTimer>,
target_frametime: Res<FrametimeLimit>,
stats: Res<FramePaceStats>,
#[cfg(not(target_arch = "wasm32"))] settings: Res<FramepaceSettingsProxy>,
) {
if let Ok(limit) = target_frametime.0.try_lock() {
let frame_time = timer.sleep_end.elapsed();
#[cfg(not(target_arch = "wasm32"))]
{
let oversleep = stats
.oversleep
.try_lock()
.as_deref()
.cloned()
.unwrap_or_default();
let sleep_time = limit.saturating_sub(frame_time + oversleep);
if settings.is_enabled() {
spin_sleep::sleep(sleep_time);
}
}

let frame_time_total = timer.sleep_end.elapsed();
timer.sleep_end = Instant::now();
if let Ok(mut frametime) = stats.frametime.try_lock() {
*frametime = frame_time;
}
if let Ok(mut oversleep) = stats.oversleep.try_lock() {
*oversleep = frame_time_total.saturating_sub(*limit);
}
};
}
1 change: 1 addition & 0 deletions crates/bevy_render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub mod extract_resource {

pub use bevy_extract::extract_resource::{extract_resource, ExtractResource};
}
pub mod framepace;
pub mod globals;
pub mod gpu_component_array_buffer;
pub mod gpu_readback;
Expand Down
1 change: 1 addition & 0 deletions docs/cargo_features.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ This is the complete `bevy` cargo feature list, without "profiles" or "collectio
|file_watcher|Enables watching the filesystem for Bevy Asset hot-reloading|
|flac|FLAC audio format support (through `claxon`)|
|force_disable_dlss|Forcibly disable DLSS so that `cargo build --all-features` works without the DLSS SDK being installed. Not meant for users.|
|framepace_debug|Adds frame pacing diagnostics and logs|
|free_camera|Enables the free cam from bevy_camera_controller|
|gamepad|Gamepad support. Automatically enabled by `bevy_gilrs`.|
|gestures|Gestures support. Automatically enabled by `bevy_window`.|
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,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
[Framepace](../examples/window/framepace.rs) | Demonstrates frame pacing
[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
Loading
Loading