From 3d9eff8770adab9e730ce17b1b306f86f1f73521 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Mon, 28 Sep 2026 03:08:37 -0400 Subject: [PATCH 1/3] fix(windows): don't present faster than the display Swapchains are created with a frame-latency waitable object, so Present no longer throttles, and nothing waited on the object. Presenting faster than the display queued frames (and the GPU work and memory each holds) without bound: a busy canvas grew by ~100 MB/s. A present now checks the waitable without blocking and is skipped when the display hasn't taken the queued frames. The frame scheduler keeps that context dirty and flushes it again on the next requestAnimationFrame, so no frame rate is capped and nothing blocks the UI thread. After 250 ms of skipped presents, one goes through anyway. --- crates/canvas-2d/src/context/surface_d3d.rs | 3 + crates/canvas-core/src/gpu/dxgi.rs | 30 ++++++ crates/canvas-core/src/gpu/gl/egl.rs | 3 + crates/canvas-napi/src/frame.rs | 100 ++++++++++++++++++-- 4 files changed, 129 insertions(+), 7 deletions(-) diff --git a/crates/canvas-2d/src/context/surface_d3d.rs b/crates/canvas-2d/src/context/surface_d3d.rs index c94fcc0e0..6be9744b8 100644 --- a/crates/canvas-2d/src/context/surface_d3d.rs +++ b/crates/canvas-2d/src/context/surface_d3d.rs @@ -507,6 +507,9 @@ impl Context { return; } let Some(swap_chain) = target.swap_chain.as_ref() else { return }; + if !swap_chain.acquire_frame() { + return; + } let Some(direct_context) = self.direct_context.as_mut() else { return }; let index = swap_chain.current_index() as usize; let Some(back_buffer) = target.back_buffers.get_mut(index) else { return }; diff --git a/crates/canvas-core/src/gpu/dxgi.rs b/crates/canvas-core/src/gpu/dxgi.rs index 6ae3ed556..48e0bb3fe 100644 --- a/crates/canvas-core/src/gpu/dxgi.rs +++ b/crates/canvas-core/src/gpu/dxgi.rs @@ -6,7 +6,9 @@ //! maps it back (1 / composition scale), which is also how the canvas "fit" modes are applied //! without resizing buffers. +use std::cell::Cell; use std::ffi::c_void; +use std::time::{Duration, Instant}; use windows::core::{Interface, Result, HRESULT}; use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0}; @@ -36,6 +38,16 @@ pub unsafe trait ISwapChainPanelNative: windows::core::IUnknown { pub const BUFFER_COUNT: u32 = 2; +const MAX_FRAME_DEFERRAL: Duration = Duration::from_millis(250); + +thread_local! { + static PRESENT_DEFERRED: Cell = const { Cell::new(false) }; +} + +pub fn take_present_deferred() -> bool { + PRESENT_DEFERRED.with(|deferred| deferred.replace(false)) +} + /// WinUI 3's `ISurfaceImageSourceNative` (microsoft.ui.xaml.media.dxinterop.h; not the UWP IID). #[windows::core::interface("e4cecd6c-f14b-4f46-83c3-8bbda27c6504")] pub unsafe trait ISurfaceImageSourceNative: windows::core::IUnknown { @@ -317,6 +329,7 @@ pub struct CompositionSwapChain { width: u32, height: u32, flags: DXGI_SWAP_CHAIN_FLAG, + deferred_since: parking_lot::Mutex>, } impl CompositionSwapChain { @@ -375,6 +388,7 @@ impl CompositionSwapChain { width: width.max(1), height: height.max(1), flags, + deferred_since: parking_lot::Mutex::new(None), }) } @@ -439,6 +453,22 @@ impl CompositionSwapChain { unsafe { WaitForSingleObjectEx(self.waitable, 0, false) == WAIT_OBJECT_0 } } + /// `false`: the display hasn't taken the queued frames yet; skip this present. + pub fn acquire_frame(&self) -> bool { + let mut deferred_since = self.deferred_since.lock(); + if self.frame_ready() { + *deferred_since = None; + return true; + } + let now = Instant::now(); + if now.duration_since(*deferred_since.get_or_insert(now)) >= MAX_FRAME_DEFERRAL { + *deferred_since = None; + return true; + } + PRESENT_DEFERRED.with(|deferred| deferred.set(true)); + false + } + pub fn present(&self, vsync: bool) -> Result<()> { let (interval, flags) = if vsync { (1, DXGI_PRESENT::default()) diff --git a/crates/canvas-core/src/gpu/gl/egl.rs b/crates/canvas-core/src/gpu/gl/egl.rs index 3b84772ad..d685ee3eb 100644 --- a/crates/canvas-core/src/gpu/gl/egl.rs +++ b/crates/canvas-core/src/gpu/gl/egl.rs @@ -816,6 +816,9 @@ impl GLContext { let (Some(presenter), Some(texture)) = (self.0.presenter.as_ref(), self.0.texture.as_ref()) else { return true; }; + if !presenter.swap_chain.acquire_frame() { + return true; + } let Ok(back_buffer) = presenter.swap_chain.buffer::(0) else { return false; }; diff --git a/crates/canvas-napi/src/frame.rs b/crates/canvas-napi/src/frame.rs index 82fcf7048..f1a979eea 100644 --- a/crates/canvas-napi/src/frame.rs +++ b/crates/canvas-napi/src/frame.rs @@ -7,7 +7,7 @@ //! frame. use std::cell::{Cell, RefCell}; -use std::ffi::c_void; +use std::ffi::{c_void, CStr}; use std::rc::{Rc, Weak}; use napi::sys; @@ -18,6 +18,7 @@ use napi::JsValue; pub struct FrameSlot { dirty: Cell, paused: Cell, + retrying: Cell, target: *mut c_void, flush: unsafe fn(*mut c_void), } @@ -28,6 +29,7 @@ impl FrameSlot { Rc::new(FrameSlot { dirty: Cell::new(false), paused: Cell::new(false), + retrying: Cell::new(false), target, flush, }) @@ -46,15 +48,36 @@ impl FrameSlot { } /// Renders now if dirty (e.g. before a readback). - pub fn flush_now(&self) { - if self.dirty.replace(false) { - unsafe { (self.flush)(self.target) }; + pub fn flush_now(self: &Rc) { + if !self.dirty.replace(false) { + return; + } + take_present_deferred(); + unsafe { (self.flush)(self.target) }; + if take_present_deferred() { + self.dirty.set(true); + if !self.retrying.replace(true) { + RETRY.with(|r| r.borrow_mut().push(Rc::downgrade(self))); + } + request_retry(); } } } +fn take_present_deferred() -> bool { + #[cfg(target_os = "windows")] + { + canvas_core::gpu::dxgi::take_present_deferred() + } + #[cfg(not(target_os = "windows"))] + { + false + } +} + thread_local! { static PENDING: RefCell>> = const { RefCell::new(Vec::new()) }; + static RETRY: RefCell>> = const { RefCell::new(Vec::new()) }; static SCHEDULER: RefCell>> = const { RefCell::new(None) }; /// A flush found a context's GPU device lost. static LOST: Cell = const { Cell::new(false) }; @@ -81,14 +104,25 @@ pub fn mark_dirty(slot: &Rc) { /// Flushes every queued, still-alive, dirty and unpaused context. Called by the host at the end /// of a frame (and by `CanvasModule.__flushAll()`). pub fn flush_all() { - let pending = PENDING.with(|p| std::mem::take(&mut *p.borrow_mut())); - for slot in pending.iter().filter_map(Weak::upgrade) { + let mut pending = PENDING.with(|p| std::mem::take(&mut *p.borrow_mut())); + pending.extend(RETRY.with(|r| std::mem::take(&mut *r.borrow_mut()))); + let pending: Vec<_> = pending.iter().filter_map(Weak::upgrade).collect(); + for slot in &pending { + slot.retrying.set(false); + } + for slot in &pending { if !slot.paused.get() { slot.flush_now(); } } } +fn request_retry() { + if let Some(scheduler) = MICROTASK.with(|m| m.borrow().clone()) { + scheduler.request_retry(); + } +} + /// A context found its GPU device lost while flushing. The listener /// (`__setContextLostListener`) hears about it once the flush is done. pub fn report_lost() { @@ -159,6 +193,8 @@ struct MicrotaskScheduler { queue_microtask: sys::napi_ref, flush: sys::napi_ref, queued: Cell, + retry: sys::napi_ref, + retry_queued: Cell, } thread_local! { @@ -176,7 +212,45 @@ unsafe extern "C" fn microtask_flush(env: sys::napi_env, _: sys::napi_callback_i undefined } +unsafe extern "C" fn retry_flush(env: sys::napi_env, _: sys::napi_callback_info) -> sys::napi_value { + if let Some(scheduler) = MICROTASK.with(|m| m.borrow().clone()) { + scheduler.retry_queued.set(false); + if RETRY.with(|r| !r.borrow().is_empty()) { + scheduler.request(); + } + } + let mut undefined = std::ptr::null_mut(); + unsafe { sys::napi_get_undefined(env, &mut undefined) }; + undefined +} + +unsafe fn call_global(env: sys::napi_env, global: sys::napi_value, name: &CStr, arg: sys::napi_value) -> bool { + let (mut function, mut kind, mut result) = (std::ptr::null_mut(), 0, std::ptr::null_mut()); + unsafe { + sys::napi_get_named_property(env, global, name.as_ptr(), &mut function) == sys::Status::napi_ok + && sys::napi_typeof(env, function, &mut kind) == sys::Status::napi_ok + && kind == sys::ValueType::napi_function + && sys::napi_call_function(env, global, function, 1, &arg, &mut result) == sys::Status::napi_ok + } +} + impl MicrotaskScheduler { + fn request_retry(&self) { + if self.retry_queued.replace(true) { + return; + } + unsafe { + let (mut retry, mut global) = (std::ptr::null_mut(), std::ptr::null_mut()); + let ok = sys::napi_get_reference_value(self.env, self.retry, &mut retry) == sys::Status::napi_ok + && sys::napi_get_global(self.env, &mut global) == sys::Status::napi_ok + && (call_global(self.env, global, c"requestAnimationFrame", retry) + || call_global(self.env, global, c"setTimeout", retry)); + if !ok { + self.retry_queued.set(false); + } + } + } + fn request(&self) { if self.queued.replace(true) { return; @@ -222,9 +296,19 @@ pub fn install_microtask_scheduler(env: sys::napi_env) -> napi::Result<()> { std::ptr::null_mut(), &mut flush, ))?; - let (mut queue_ref, mut flush_ref) = (std::ptr::null_mut(), std::ptr::null_mut()); + let mut retry = std::ptr::null_mut(); + napi::check_status!(sys::napi_create_function( + env, + c"__canvasRetry".as_ptr(), + -1, + Some(retry_flush), + std::ptr::null_mut(), + &mut retry, + ))?; + let (mut queue_ref, mut flush_ref, mut retry_ref) = (std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()); napi::check_status!(sys::napi_create_reference(env, queue_microtask, 1, &mut queue_ref))?; napi::check_status!(sys::napi_create_reference(env, flush, 1, &mut flush_ref))?; + napi::check_status!(sys::napi_create_reference(env, retry, 1, &mut retry_ref))?; napi::check_status!(sys::napi_add_env_cleanup_hook(env, Some(microtask_teardown), std::ptr::null_mut()))?; let scheduler = Rc::new(MicrotaskScheduler { @@ -232,6 +316,8 @@ pub fn install_microtask_scheduler(env: sys::napi_env) -> napi::Result<()> { queue_microtask: queue_ref, flush: flush_ref, queued: Cell::new(false), + retry: retry_ref, + retry_queued: Cell::new(false), }); MICROTASK.with(|m| *m.borrow_mut() = Some(scheduler.clone())); set_scheduler(Some(Box::new(move || scheduler.request()))); From fee12fc69374106c4401a3934c8d6bd81efee28b Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Mon, 28 Sep 2026 03:08:37 -0400 Subject: [PATCH 2/3] fix(windows): cap and purge the shared Skia resource cache Every D3D canvas on a thread shares one DirectContext, which kept Skia's default 256 MB resource cache and never purged it. Its buffers live in D3D12 upload heaps, which grew to 480 MB in the demo app. The budget is now 64 MB, and resources unused for 5 s are freed (checked at most once a second while presenting): the demo app's home screen went from ~770 MB to ~385 MB. --- crates/canvas-2d/src/context/surface_d3d.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/canvas-2d/src/context/surface_d3d.rs b/crates/canvas-2d/src/context/surface_d3d.rs index 6be9744b8..f7a167d17 100644 --- a/crates/canvas-2d/src/context/surface_d3d.rs +++ b/crates/canvas-2d/src/context/surface_d3d.rs @@ -6,9 +6,10 @@ //! thread share one D3D12 device and one Skia `DirectContext`, so drawing one canvas into another //! stays on the GPU. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::ffi::c_void; use std::rc::Rc; +use std::time::{Duration, Instant}; use canvas_core::context_attributes::ColorSpace; use canvas_core::gpu::d3d::{D3D12Context, PowerPreference}; @@ -30,6 +31,18 @@ thread_local! { static SKIA_D3D: RefCell, DirectContext)>> = const { RefCell::new(None) }; /// The thread's D3D canvases (`Context::register_d3d`), for `release_lost_canvases`. static CANVASES: RefCell> = const { RefCell::new(Vec::new()) }; + static LAST_PURGE: Cell> = const { Cell::new(None) }; +} + +const RESOURCE_CACHE_LIMIT: usize = 64 << 20; + +fn purge_idle_resources(context: &mut DirectContext) { + let now = Instant::now(); + if LAST_PURGE.with(|last| last.get().is_some_and(|at| now - at < Duration::from_secs(1))) { + return; + } + LAST_PURGE.with(|last| last.set(Some(now))); + context.perform_deferred_cleanup(Duration::from_secs(5), None); } /// The thread's device, a new one after a device loss. The adapter makes no new device while the @@ -84,7 +97,8 @@ fn shared_direct_context(device: &Rc) -> Option { if let Some((_, mut stale)) = shared.take() { stale.abandon(); } - let context = device.make_direct_context()?; + let mut context = device.make_direct_context()?; + context.set_resource_cache_limit(RESOURCE_CACHE_LIMIT); *shared = Some((device.clone(), context.clone())); Some(context) }) @@ -483,6 +497,9 @@ impl Context { return; } self.flush_surface(); + if let Some(direct_context) = self.direct_context.as_mut() { + purge_idle_resources(direct_context); + } let Some(target) = self.d3d.as_mut() else { return }; if let Some(xaml) = target.xaml.as_mut() { let Some(direct_context) = self.direct_context.as_mut() else { return }; From 6cc9e19e05491b27032ceac6e4baf56ca00fb76a Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Mon, 28 Sep 2026 03:08:38 -0400 Subject: [PATCH 3/3] fix(webgl): deleteShader takes a WebGLShader The binding declared a WebGLRenderbuffer, so every deleteShader(shader) threw 'Value is not an instance of class WebGLRenderbuffer' (three.js and PixiJS both call it after linking). --- crates/canvas-napi/src/gl/base.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/canvas-napi/src/gl/base.rs b/crates/canvas-napi/src/gl/base.rs index e1243a009..9d64b9342 100644 --- a/crates/canvas-napi/src/gl/base.rs +++ b/crates/canvas-napi/src/gl/base.rs @@ -339,7 +339,7 @@ macro_rules! impl_webgl_context { } #[napi] - pub fn delete_shader(&self, shader: &WebGLRenderbuffer) { + pub fn delete_shader(&self, shader: &WebGLShader) { canvas_c::canvas_native_webgl_delete_shader(shader.0, self.state) }