Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 2b8326b131 macOS: VSync off no longer holds a window to the display's refresh rate
With VSync off the Metal layer presents without display sync, but a window's
compositor still takes one drawable a refresh: acquiring the next frame's
drawable waited in nextDrawable whenever the last one was not on screen yet,
so a windowed game ran at the display's rate (144 FPS on a 144 Hz screen,
0.7 ms of CPU a frame, #1333).

- The vendored wgpu-hal counts the drawables presented and not yet shown
  (or dropped), from each drawable's presented handler. With display sync
  off and one still queued, acquiring returns Timeout instead of waiting
  (and stops doing so if a handler is missing for 100 ms).
- On that Timeout with VSync off, the renderer draws the frame into its
  offscreen target and presents nothing: the display shows the newest frame
  each refresh, as vanilla's OpenGL window does when it drops frames. Its
  offscreen target is made when first needed, and again after a resize.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 07:58:54 +02:00
4 changed files with 71 additions and 5 deletions

No files matched your search

+5 -2
View File
@@ -19,8 +19,11 @@ large_enum_variant = "allow"
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
# pool with no pool sizes while it creates the device, which Vulkan allows only
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
# folder once a wgpu release has the fix.
# UHD 620/630) crashes on it with an access violation (#746). Also, on macOS
# with display sync off, acquiring times out instead of waiting while the last
# drawable presented is not on screen yet, so a window with VSync off is not
# held to the display's refresh rate (#1333; gfx.rs draws that frame
# offscreen). Drop this and the folder once wgpu has both.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+23 -2
View File
@@ -288,8 +288,11 @@ pub struct Renderer {
adapter: wgpu::Adapter,
/// The window's swapchain; `None` when rendering offscreen (`--offscreen`).
surface: Option<wgpu::Surface<'static>>,
/// The offscreen colour target, sized like `config`.
/// The offscreen colour target, sized like `config`; with a window, for
/// the frames the display has no room for (Metal with VSync off).
offscreen: Option<wgpu::Texture>,
/// Rendering offscreen only (`--offscreen`): no surface to recreate.
windowless: bool,
/// Offscreen submissions still on the GPU: at most two frames run ahead.
in_flight: std::collections::VecDeque<wgpu::SubmissionIndex>,
/// Which steps of the first frame on this device still go to the log
@@ -2126,6 +2129,7 @@ impl Renderer {
instance,
adapter,
surface,
windowless: offscreen.is_some(),
offscreen,
in_flight: std::collections::VecDeque::new(),
first_frame: if previous.is_none() {
@@ -2301,7 +2305,7 @@ impl Renderer {
}
/// Reattach a window after surface loss or a failed renderer replacement.
pub fn recreate_surface(&mut self) -> Result<()> {
if self.offscreen.is_some() {
if self.windowless {
return Ok(());
}
// Wayland allows only one tearing-control object per window surface.
@@ -2372,6 +2376,8 @@ impl Renderer {
self.config.height = height;
if let Some(surface) = &self.surface {
surface.configure(&self.device, &self.config);
// Made again at the new size when next needed.
self.offscreen = None;
} else {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
@@ -2826,6 +2832,21 @@ impl Renderer {
None => (None, false),
Some(wgpu::CurrentSurfaceTexture::Success(f)) => (Some(f), false),
Some(wgpu::CurrentSurfaceTexture::Suboptimal(f)) => (Some(f), true),
// Metal with VSync off (the vendored wgpu-hal): the display has yet
// to show the last frame presented. A window's compositor takes one
// drawable a refresh, so waiting for one would hold the frame rate
// to the display's, VSync or not (#1333). This frame is drawn
// offscreen instead, as vanilla's OpenGL window drops the frames
// the display has no time for: it shows the newest each refresh.
Some(wgpu::CurrentSurfaceTexture::Timeout)
if cfg!(target_os = "macos")
&& self.config.present_mode != wgpu::PresentMode::Fifo =>
{
if self.offscreen.is_none() {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
(None, false)
}
Some(wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded) => {
return Ok(false);
}
+19 -1
View File
@@ -644,6 +644,10 @@ pub struct Surface {
render_layer: Mutex<Retained<CAMetalLayer>>,
swapchain_format: RwLock<Option<wgt::TextureFormat>>,
extent: RwLock<wgt::Extent3d>,
/// Horizon: drawables presented and not yet on screen (or dropped).
queued: Arc<atomic::AtomicUsize>,
/// Horizon: since when `acquire_texture` has found `queued` above zero.
busy_since: Mutex<Option<std::time::Instant>>,
}
unsafe impl Send for Surface {}
@@ -801,9 +805,23 @@ impl crate::Queue for Queue {
}
unsafe fn present(
&self,
_surface: &Surface,
surface: &Surface,
texture: SurfaceTexture,
) -> Result<(), crate::SurfaceError> {
// Horizon: count the drawable until the display shows it (or drops it).
let queued = surface.queued.clone();
queued.fetch_add(1, atomic::Ordering::AcqRel);
let block = block2::RcBlock::new(move |_drawable| {
// Saturating: the count may have been reset since.
let _ = queued.try_update(atomic::Ordering::AcqRel, atomic::Ordering::Acquire, |n| {
n.checked_sub(1)
});
});
unsafe {
texture
.drawable
.addPresentedHandler(block2::RcBlock::as_ptr(&block))
};
autoreleasepool(|_| {
// We do not bother adjusting `command_buffer_created_not_submitted`
// because we immediately commit this buffer.
+24
View File
@@ -40,6 +40,8 @@ impl super::Surface {
render_layer: Mutex::new(layer),
swapchain_format: RwLock::new(None),
extent: RwLock::new(wgt::Extent3d::default()),
queued: Default::default(),
busy_since: Mutex::new(None),
}
}
@@ -370,6 +372,28 @@ impl crate::Surface for super::Surface {
}
}
// Horizon: with display sync off, don't wait for a drawable. A
// window's compositor takes one a refresh, so `nextDrawable` would
// wait for the display whenever the last one presented is not on
// screen yet, and the frame rate would follow the display. Report a
// timeout instead; the caller draws that frame elsewhere.
// A drawable whose handler never comes (none should) stops it after
// a moment.
#[cfg(target_os = "macos")]
{
let mut busy = self.busy_since.lock();
if !render_layer.displaySyncEnabled()
&& self.queued.load(core::sync::atomic::Ordering::Acquire) > 0
{
let since = *busy.get_or_insert_with(std::time::Instant::now);
if since.elapsed() < core::time::Duration::from_millis(100) {
return Err(crate::SurfaceError::Timeout);
}
self.queued.store(0, core::sync::atomic::Ordering::Release);
}
*busy = None;
}
let (drawable, texture) = match autoreleasepool(|_| {
render_layer
.nextDrawable()