From f342ab884797ee2a58b888bc0a35777a898f4717 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 17 Apr 2026 11:39:47 +0200 Subject: [PATCH] wgpu: Allow configuring VSync and frame latency at runtime (#8114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let apps change present_mode and desired_maximum_frame_latency at runtime instead of only at startup. API changes (egui-wgpu): - New SurfaceConfig { present_mode, desired_maximum_frame_latency }. - WgpuConfiguration now nests these as pub surface: SurfaceConfig (was two top-level fields). - RenderState gains pub surface_config: SurfaceConfig — the currently-requested value. API additions (eframe): - Frame::wgpu_surface_config() / Frame::set_wgpu_surface_config(...) for get/set. - SurfaceConfig re-exported as eframe::SurfaceConfig. How it works: The wgpu painter compares render_state.surface_config to its currently-applied values each paint. If they differ it updates its config and flips needs_reconfigure on every surface, piggybacking on the existing deferred-reconfigure pathway. Demo: The backend panel (egui_demo_app) gets dropdowns for present mode and desired max frame latency, wired through the new Frame accessors. image --- crates/eframe/src/epi.rs | 25 ++++++- crates/eframe/src/lib.rs | 2 +- crates/eframe/src/web/web_painter_wgpu.rs | 2 +- crates/egui-wgpu/src/lib.rs | 81 ++++++++++++++++------- crates/egui-wgpu/src/winit.rs | 55 +++++++++------ crates/egui_demo_app/src/backend_panel.rs | 51 ++++++++++++++ 6 files changed, 170 insertions(+), 46 deletions(-) diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index a9ce726fe..9e1a1bd07 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -469,7 +469,8 @@ impl Default for NativeOptions { centered: false, #[cfg(feature = "wgpu_no_default_features")] - wgpu_options: egui_wgpu::WgpuConfiguration::default(), + wgpu_options: egui_wgpu::WgpuConfiguration::default() + .with_surface_config(egui_wgpu::SurfaceConfig::LOW_LATENCY), persist_window: true, @@ -793,6 +794,28 @@ impl Frame { pub fn wgpu_render_state(&self) -> Option<&egui_wgpu::RenderState> { self.wgpu_render_state.as_ref() } + + /// The currently-applied runtime surface config (present mode, frame latency) + /// used by the `wgpu` renderer, if any. + /// + /// Returns `None` when not using the `wgpu` backend. + #[cfg(feature = "wgpu_no_default_features")] + pub fn wgpu_surface_config(&self) -> Option { + self.wgpu_render_state + .as_ref() + .map(|state| state.surface_config) + } + + /// Set the runtime surface config (present mode, frame latency) for the `wgpu` + /// renderer. The surface is reconfigured on the next paint. + /// + /// No-op when not using the `wgpu` backend. + #[cfg(feature = "wgpu_no_default_features")] + pub fn set_wgpu_surface_config(&mut self, config: egui_wgpu::SurfaceConfig) { + if let Some(state) = &mut self.wgpu_render_state { + state.surface_config = config; + } + } } /// Information about the web environment (if applicable). diff --git a/crates/eframe/src/lib.rs b/crates/eframe/src/lib.rs index 9f7a4f3ef..597bfd98d 100644 --- a/crates/eframe/src/lib.rs +++ b/crates/eframe/src/lib.rs @@ -159,7 +159,7 @@ pub use {egui, egui::emath, egui::epaint}; pub use {egui_glow, glow}; #[cfg(feature = "wgpu_no_default_features")] -pub use {egui_wgpu, egui_wgpu::wgpu}; +pub use {egui_wgpu, egui_wgpu::SurfaceConfig, egui_wgpu::WgpuConfiguration, egui_wgpu::wgpu}; mod epi; diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs index 63702592d..8735530aa 100644 --- a/crates/eframe/src/web/web_painter_wgpu.rs +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -118,7 +118,7 @@ impl WebPainterWgpu { let surface_configuration = wgpu::SurfaceConfiguration { format: render_state.target_format, - present_mode: wgpu_options.present_mode, + present_mode: wgpu_options.surface.present_mode, view_formats: vec![render_state.target_format], ..default_configuration }; diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs index 75155899d..3e95f7885 100644 --- a/crates/egui-wgpu/src/lib.rs +++ b/crates/egui-wgpu/src/lib.rs @@ -64,6 +64,43 @@ pub enum WgpuError { HandleError(#[from] ::winit::raw_window_handle::HandleError), } +/// Runtime-mutable subset of [`WgpuConfiguration`]. +/// +/// Edit any field to have the surface reconfigured on the next paint. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SurfaceConfig { + /// Present mode used for the primary surface. + pub present_mode: wgpu::PresentMode, + + /// Desired maximum number of frames that the presentation engine should queue in advance. + /// + /// Use `1` for low-latency, and `2` for high-throughput. + /// + /// See [`wgpu::SurfaceConfiguration::desired_maximum_frame_latency`] for details. + /// + /// `None` => Let `wgpu` pick a default (currently `2`). + pub desired_maximum_frame_latency: Option, +} + +impl SurfaceConfig { + /// Good default for GUIs with very little (or no) extra GPU work. + pub const LOW_LATENCY: Self = Self { + present_mode: wgpu::PresentMode::AutoVsync, + desired_maximum_frame_latency: if cfg!(target_os = "ios") { + None // The default is good on iOS, while `Some(1)` cuts FPS in half + } else { + Some(1) // Low-latency by default. + }, + }; + + /// Good default for GUIs with a lot of extra GPU work, + /// or that want to prioritize smoothness over latency. + pub const HIGH_THROUGHPUT: Self = Self { + present_mode: wgpu::PresentMode::AutoVsync, + desired_maximum_frame_latency: Some(2), // High-throughput. + }; +} + /// Access to the render state for egui. #[derive(Clone)] pub struct RenderState { @@ -88,6 +125,11 @@ pub struct RenderState { /// Egui renderer responsible for drawing the UI. pub renderer: Arc>, + + /// Runtime-mutable subset of the wgpu configuration. + /// + /// Update this to have the surface reconfigured on the next paint. + pub surface_config: SurfaceConfig, } async fn request_adapter( @@ -243,6 +285,7 @@ impl RenderState { queue, target_format, renderer: Arc::new(RwLock::new(renderer)), + surface_config: config.surface, }) } } @@ -273,17 +316,11 @@ pub enum SurfaceErrorAction { /// Configuration for using wgpu with eframe or the egui-wgpu winit feature. #[derive(Clone)] pub struct WgpuConfiguration { - /// Present mode used for the primary surface. - pub present_mode: wgpu::PresentMode, - - /// Desired maximum number of frames that the presentation engine should queue in advance. + /// Runtime-mutable configuration for the surface (present mode, frame latency). /// - /// Use `1` for low-latency, and `2` for high-throughput. - /// - /// See [`wgpu::SurfaceConfiguration::desired_maximum_frame_latency`] for details. - /// - /// `None` = `wgpu` default. - pub desired_maximum_frame_latency: Option, + /// These are the fields exposed via [`RenderState::surface_config`] for live + /// reconfiguration at runtime. + pub surface: SurfaceConfig, /// How to create the wgpu adapter & device pub wgpu_setup: WgpuSetup, @@ -308,31 +345,29 @@ fn wgpu_config_impl_send_sync() { impl std::fmt::Debug for WgpuConfiguration { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { - present_mode, - desired_maximum_frame_latency, + surface, wgpu_setup, on_surface_status: _, } = self; f.debug_struct("WgpuConfiguration") - .field("present_mode", &present_mode) - .field( - "desired_maximum_frame_latency", - &desired_maximum_frame_latency, - ) + .field("surface", &surface) .field("wgpu_setup", &wgpu_setup) .finish_non_exhaustive() } } +impl WgpuConfiguration { + #[inline] + pub fn with_surface_config(mut self, surface_config: SurfaceConfig) -> Self { + self.surface = surface_config; + self + } +} + impl Default for WgpuConfiguration { fn default() -> Self { Self { - present_mode: wgpu::PresentMode::AutoVsync, - desired_maximum_frame_latency: if cfg!(target_os = "ios") { - None // The default is good on iOS, while `Some(1)` cuts FPS in half - } else { - Some(1) // Low-latency by default. - }, + surface: SurfaceConfig::HIGH_THROUGHPUT, // No display handle available at this point — callers should replace this with // `WgpuSetup::from_display_handle(...)` before creating the instance if one is available. diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index d64644330..8b5a91904 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -3,7 +3,7 @@ #![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps #![expect(unsafe_code)] -use crate::{RenderState, SurfaceErrorAction, WgpuConfiguration, renderer}; +use crate::{RenderState, SurfaceConfig, SurfaceErrorAction, WgpuConfiguration, renderer}; use crate::{ RendererOptions, capture::{CaptureReceiver, CaptureSender, CaptureState, capture_channel}, @@ -27,7 +27,7 @@ struct SurfaceState { /// NOTE: all egui viewports share the same painter. pub struct Painter { context: Context, - configuration: WgpuConfiguration, + config: WgpuConfiguration, options: RendererOptions, support_transparent_backbuffer: bool, screen_capture_state: Option, @@ -58,16 +58,16 @@ impl Painter { /// associated. pub async fn new( context: Context, - configuration: WgpuConfiguration, + config: WgpuConfiguration, support_transparent_backbuffer: bool, options: RendererOptions, ) -> Self { let (capture_tx, capture_rx) = capture_channel(); - let instance = configuration.wgpu_setup.new_instance().await; + let instance = config.wgpu_setup.new_instance().await; Self { context, - configuration, + config, options, support_transparent_backbuffer, screen_capture_state: None, @@ -94,17 +94,22 @@ impl Painter { fn configure_surface( surface_state: &SurfaceState, render_state: &RenderState, - config: &WgpuConfiguration, + config: &SurfaceConfig, ) { profiling::function_scope!(); + let SurfaceConfig { + present_mode, + desired_maximum_frame_latency, + } = *config; + let width = surface_state.width; let height = surface_state.height; let mut surf_config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: render_state.target_format, - present_mode: config.present_mode, + present_mode, alpha_mode: surface_state.alpha_mode, view_formats: vec![render_state.target_format], ..surface_state @@ -113,7 +118,7 @@ impl Painter { .expect("The surface isn't supported by this adapter") }; - if let Some(desired_maximum_frame_latency) = config.desired_maximum_frame_latency { + if let Some(desired_maximum_frame_latency) = desired_maximum_frame_latency { surf_config.desired_maximum_frame_latency = desired_maximum_frame_latency; } @@ -201,13 +206,9 @@ impl Painter { let render_state = if let Some(render_state) = &self.render_state { render_state } else { - let render_state = RenderState::create( - &self.configuration, - &self.instance, - Some(&surface), - self.options, - ) - .await?; + let render_state = + RenderState::create(&self.config, &self.instance, Some(&surface), self.options) + .await?; self.render_state.get_or_insert(render_state) }; let alpha_mode = if self.support_transparent_backbuffer { @@ -278,7 +279,7 @@ impl Painter { surface_state.width = width; surface_state.height = height; - Self::configure_surface(surface_state, render_state, &self.configuration); + Self::configure_surface(surface_state, render_state, &self.config.surface); if let Some(depth_format) = self.options.depth_stencil_format { self.depth_texture_view.insert( @@ -375,7 +376,7 @@ impl Painter { Self::configure_surface( state, self.render_state.as_ref().unwrap(), - &self.configuration, + &self.config.surface, ); } } @@ -449,6 +450,20 @@ impl Painter { let capture = !capture_data.is_empty(); let mut vsync_sec = 0.0; + // Apply any runtime changes requested via `RenderState::surface_config`. + // We diff against the already-applied values in `self.config.surface` + // and, if anything differs, mark every surface as needing reconfiguration so + // the existing `needs_reconfigure` pathway below picks them up. + if let Some(render_state) = self.render_state.as_ref() + && render_state.surface_config != self.config.surface + { + self.config.surface = render_state.surface_config; + #[expect(clippy::iter_over_hash_type)] + for surface in self.surfaces.values_mut() { + surface.needs_reconfigure = true; + } + } + let Some(render_state) = self.render_state.as_mut() else { return vsync_sec; }; @@ -496,7 +511,7 @@ impl Painter { }; if surface_state.needs_reconfigure { - Self::configure_surface(surface_state, render_state, &self.configuration); + Self::configure_surface(surface_state, render_state, &self.config.surface); surface_state.needs_reconfigure = false; } @@ -516,9 +531,9 @@ impl Painter { frame } other => { - match (*self.configuration.on_surface_status)(&other) { + match (*self.config.on_surface_status)(&other) { SurfaceErrorAction::RecreateSurface => { - Self::configure_surface(surface_state, render_state, &self.configuration); + Self::configure_surface(surface_state, render_state, &self.config.surface); } SurfaceErrorAction::SkipFrame => {} } diff --git a/crates/egui_demo_app/src/backend_panel.rs b/crates/egui_demo_app/src/backend_panel.rs index cd17afd4a..acd114ff4 100644 --- a/crates/egui_demo_app/src/backend_panel.rs +++ b/crates/egui_demo_app/src/backend_panel.rs @@ -310,6 +310,11 @@ fn integration_ui(ui: &mut egui::Ui, _frame: &mut eframe::Frame) { ui.end_row(); } }); + + if let Some(mut cfg) = _frame.wgpu_surface_config() { + wgpu_surface_config_ui(ui, &mut cfg); + _frame.set_wgpu_surface_config(cfg); + } } #[cfg(not(target_arch = "wasm32"))] @@ -357,6 +362,52 @@ fn integration_ui(ui: &mut egui::Ui, _frame: &mut eframe::Frame) { } } +#[cfg(feature = "wgpu")] +fn wgpu_surface_config_ui(ui: &mut egui::Ui, cfg: &mut eframe::SurfaceConfig) { + use eframe::wgpu::PresentMode; + + egui::Grid::new("wgpu_surface_config") + .num_columns(2) + .show(ui, |ui| { + ui.label("Present mode:"); + egui::ComboBox::from_id_salt("wgpu_present_mode") + .selected_text(format!("{:?}", cfg.present_mode)) + .show_ui(ui, |ui| { + for mode in [ + PresentMode::AutoVsync, + PresentMode::AutoNoVsync, + PresentMode::Fifo, + PresentMode::FifoRelaxed, + PresentMode::Immediate, + PresentMode::Mailbox, + ] { + ui.selectable_value(&mut cfg.present_mode, mode, format!("{mode:?}")); + } + }); + ui.end_row(); + + ui.label("Desired max frame latency:"); + egui::ComboBox::from_id_salt("wgpu_desired_max_frame_latency") + .selected_text(match cfg.desired_maximum_frame_latency { + None => "Default".to_owned(), + Some(n) => n.to_string(), + }) + .show_ui(ui, |ui| { + ui.weak("Lower value = lower latency"); + ui.selectable_value(&mut cfg.desired_maximum_frame_latency, None, "Default"); + for n in [0_u32, 1, 2, 3] { + ui.selectable_value( + &mut cfg.desired_maximum_frame_latency, + Some(n), + n.to_string(), + ); + } + ui.weak("Higher value = higher throughput/FPS"); + }); + ui.end_row(); + }); +} + // ---------------------------------------------------------------------------- #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]