diff --git a/README.md b/README.md index bd6c628..0142189 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Add the following to your `Cargo.toml`: ```toml [dependencies] -grafo = "0.10" +grafo = "0.18" winit = "0.30" # For window creation and event handling image = "0.25" # For image decoding (textures) env_logger = "0.11" # For logging @@ -57,9 +57,15 @@ renderer ) .unwrap(); -// Render one frame (typical winit loop would call this on RedrawRequested) -renderer.render().unwrap(); -renderer.clear_draw_queue(); +// Render one frame (a typical winit loop would call this on RedrawRequested). +match renderer.prepare() { + grafo::PreparationOutcome::Ready => { + renderer.commit(None).unwrap(); + renderer.clear_draw_queue(); + } + // Keep the draw queue for a redraw requested by resize or unocclusion. + grafo::PreparationOutcome::Suspended => {} +} ``` ### Multiple independent windows diff --git a/examples/backdrop_blur.rs b/examples/backdrop_blur.rs index e7ba0a9..b4d356e 100644 --- a/examples/backdrop_blur.rs +++ b/examples/backdrop_blur.rs @@ -14,7 +14,7 @@ /// *group* effect. use futures::executor::block_on; use grafo::{BackdropEffectConfig, BorderRadii, Shape}; -use grafo::{Color, ShapeDrawCommandOptions, Stroke}; +use grafo::{Color, PreparationOutcome, ShapeDrawCommandOptions, Stroke}; use std::sync::Arc; use winit::application::ApplicationHandler; use winit::event::WindowEvent; @@ -145,6 +145,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let (pw, ph) = renderer.size(); @@ -287,20 +292,21 @@ impl<'a> ApplicationHandler for App<'a> { .expect("Failed to set backdrop effect"); // ── Render ─────────────────────────────────────────────── - match renderer.render() { + let PreparationOutcome::Ready = renderer.prepare() else { + renderer.clear_draw_queue(); + return; + }; + let commit_result = renderer.commit(None); + match commit_result { Ok(_) => { renderer.clear_draw_queue(); } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) - } + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => renderer.resize(renderer.size()), - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Ask for another redraw instead of dropping the frame for - // good — winit does not request one when the window becomes visible. + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { renderer.clear_draw_queue(); - window.request_redraw(); } Err(e) => eprintln!("{e:?}"), } diff --git a/examples/basic.rs b/examples/basic.rs index 3582602..9cfa895 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,6 +1,6 @@ use futures::executor::block_on; use grafo::Shape; -use grafo::{Color, ShapeDrawCommandOptions, Stroke}; +use grafo::{Color, PreparationOutcome, ShapeDrawCommandOptions, Stroke}; use std::sync::Arc; use winit::application::ApplicationHandler; use winit::event::WindowEvent; @@ -75,6 +75,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { // Define a simple rectangle shape let rect = Shape::rect( @@ -103,20 +108,20 @@ impl<'a> ApplicationHandler for App<'a> { ) .unwrap(); - match renderer.render() { + let PreparationOutcome::Ready = renderer.prepare() else { + renderer.clear_draw_queue(); + return; + }; + let commit_result = renderer.commit(None); + match commit_result { Ok(_) => { renderer.clear_draw_queue(); } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) - } - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or - // fully covered). Ask for another redraw instead of dropping the - // frame for good — winit does not request one when the window - // becomes visible again. + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => renderer.resize(renderer.size()), + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { renderer.clear_draw_queue(); - window.request_redraw(); } Err(e) => eprintln!("{e:?}"), } diff --git a/examples/bench_render_loop.rs b/examples/bench_render_loop.rs index d6a5d5f..c8cfc55 100644 --- a/examples/bench_render_loop.rs +++ b/examples/bench_render_loop.rs @@ -1,11 +1,11 @@ -/// Renderer performance benchmark — two scenarios, both using the real `render()` path +/// Renderer performance benchmark — two scenarios, both using the real `prepare()`/`commit()` path /// (present to screen, vsync OFF). /// /// **Benchmark 1 — Static scene:** -/// Build the scene once, then render() repeatedly. Measures pure GPU + present cost. +/// Build the scene once, then prepare and commit repeatedly. Measures pure GPU + present cost. /// /// **Benchmark 2 — Dynamic scene (re-add every frame):** -/// Each frame: clear_draw_queue() → rebuild all cached shapes → render(). +/// Each frame: clear_draw_queue() → rebuild all cached shapes → prepare and commit. /// Simulates a real UI where the render queue is reconstructed each frame. /// /// Build and run with: @@ -13,7 +13,7 @@ /// cargo run --example bench_render_loop --features render_metrics --release /// ``` use futures::executor::block_on; -use grafo::{Color, Shape, ShapeDrawCommandOptions, Stroke, TransformInstance}; +use grafo::{Color, PreparationOutcome, Shape, ShapeDrawCommandOptions, Stroke, TransformInstance}; use std::sync::Arc; use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; @@ -479,20 +479,30 @@ impl<'a> ApplicationHandler for BenchApp<'a> { ) { match event { WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let window = self.window.clone().unwrap(); match self.phase { Phase::WarmupStatic => { let renderer = self.renderer.as_mut().unwrap(); - match renderer.render() { + let PreparationOutcome::Ready = renderer.prepare() else { + return; + }; + let commit_result = renderer.commit(None); + match commit_result { Ok(_) => {} - Err(wgpu::SurfaceError::Timeout) => { - // Window not visible — skip without counting the frame. - window.request_redraw(); + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); return; } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { let size = renderer.size(); renderer.resize(size); window.request_redraw(); @@ -515,14 +525,19 @@ impl<'a> ApplicationHandler for BenchApp<'a> { { let renderer = self.renderer.as_mut().unwrap(); let frame_start = Instant::now(); - match renderer.render() { + let PreparationOutcome::Ready = renderer.prepare() else { + return; + }; + let commit_result = renderer.commit(None); + match commit_result { Ok(_) => {} - Err(wgpu::SurfaceError::Timeout) => { - // Window not visible — skip without measuring the frame. - window.request_redraw(); + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); return; } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { let size = renderer.size(); renderer.resize(size); window.request_redraw(); @@ -556,15 +571,20 @@ impl<'a> ApplicationHandler for BenchApp<'a> { Phase::WarmupDynamic => { let renderer = self.renderer.as_mut().unwrap(); build_scene(renderer); - match renderer.render() { + let PreparationOutcome::Ready = renderer.prepare() else { + renderer.clear_draw_queue(); + return; + }; + let commit_result = renderer.commit(None); + match commit_result { Ok(_) => {} - Err(wgpu::SurfaceError::Timeout) => { - // Window not visible — skip without counting the frame. + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { renderer.clear_draw_queue(); - window.request_redraw(); return; } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { renderer.clear_draw_queue(); let size = renderer.size(); renderer.resize(size); @@ -593,15 +613,20 @@ impl<'a> ApplicationHandler for BenchApp<'a> { let rebuild_duration = rebuild_start.elapsed(); let frame_start = Instant::now(); - match renderer.render() { + let PreparationOutcome::Ready = renderer.prepare() else { + renderer.clear_draw_queue(); + return; + }; + let commit_result = renderer.commit(None); + match commit_result { Ok(_) => {} - Err(wgpu::SurfaceError::Timeout) => { - // Window not visible — skip without measuring the frame. + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { renderer.clear_draw_queue(); - window.request_redraw(); return; } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { renderer.clear_draw_queue(); let size = renderer.size(); renderer.resize(size); diff --git a/examples/box_shadow.rs b/examples/box_shadow.rs index 699469e..bf12745 100644 --- a/examples/box_shadow.rs +++ b/examples/box_shadow.rs @@ -221,6 +221,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let (pw, ph) = renderer.size(); let pw = pw as f32; @@ -301,22 +306,22 @@ impl<'a> ApplicationHandler for App<'a> { ); // ── Render ─────────────────────────────────────────────── - match renderer.render() { - Ok(_) => { - renderer.clear_draw_queue(); - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) - } + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => { + renderer.clear_draw_queue(); + } + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => renderer.resize(renderer.size()), - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Ask for another redraw instead of dropping the frame for - // good — winit does not request one when the window becomes visible. - renderer.clear_draw_queue(); - window.request_redraw(); + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(e) => eprintln!("{e:?}"), } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } } _ => {} diff --git a/examples/gaussian_blur.rs b/examples/gaussian_blur.rs index 3922ae5..3071517 100644 --- a/examples/gaussian_blur.rs +++ b/examples/gaussian_blur.rs @@ -145,6 +145,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let (pw, ph) = renderer.size(); @@ -269,22 +274,22 @@ impl<'a> ApplicationHandler for App<'a> { .unwrap(); // ── Render ─────────────────────────────────────────────── - match renderer.render() { - Ok(_) => { - renderer.clear_draw_queue(); - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) - } + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => { + renderer.clear_draw_queue(); + } + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => renderer.resize(renderer.size()), - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Ask for another redraw instead of dropping the frame for - // good — winit does not request one when the window becomes visible. - renderer.clear_draw_queue(); - window.request_redraw(); + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(e) => eprintln!("{e:?}"), } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } } _ => {} diff --git a/examples/group_opacity.rs b/examples/group_opacity.rs index cd672cc..3f7cc6c 100644 --- a/examples/group_opacity.rs +++ b/examples/group_opacity.rs @@ -14,25 +14,18 @@ use futures::executor::block_on; use grafo::Shape; use grafo::{Color, ShapeDrawCommandOptions, Stroke}; use std::sync::Arc; -use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::event::WindowEvent; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::window::{Window, WindowId}; const OPACITY_EFFECT: u64 = 1; -/// How long to wait before retrying a frame that was skipped because the surface -/// reported it was not visible (`Occluded`/`Timeout`). -const OCCLUDED_RETRY_DELAY: Duration = Duration::from_millis(50); - #[derive(Default)] struct App<'a> { window: Option>, renderer: Option>, effect_loaded: bool, - /// Pending retry of a frame skipped because the window was not visible. - redraw_retry_at: Option, } impl<'a> ApplicationHandler for App<'a> { @@ -103,6 +96,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { // ── Background (no effect) ─────────────────────────────── let bg = Shape::rect( @@ -199,49 +197,27 @@ impl<'a> ApplicationHandler for App<'a> { .expect("Failed to set effect"); // ── Render ─────────────────────────────────────────────── - match renderer.render() { - Ok(_) => { - self.redraw_retry_at = None; - renderer.clear_draw_queue(); - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => { + renderer.clear_draw_queue(); + } + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => renderer.resize(renderer.size()), + + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(e) => eprintln!("{e:?}"), } - - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Retry shortly instead of busy-looping redraws — winit does - // not request one when the window becomes visible again. `WaitUntil` - // wakes the event loop without spinning while the window stays hidden. - renderer.clear_draw_queue(); - let retry_at = Instant::now() + OCCLUDED_RETRY_DELAY; - self.redraw_retry_at = Some(retry_at); - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } } _ => {} } } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(retry_at) = self.redraw_retry_at else { - // Clear a stale WaitUntil deadline left behind when a successful - // render cancelled the pending retry before it fired. - event_loop.set_control_flow(ControlFlow::Wait); - return; - }; - if Instant::now() >= retry_at { - self.redraw_retry_at = None; - if let Some(window) = &self.window { - window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::Wait); - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - } } pub fn main() { diff --git a/examples/msaa.rs b/examples/msaa.rs index c530b7a..3805acd 100644 --- a/examples/msaa.rs +++ b/examples/msaa.rs @@ -6,23 +6,16 @@ use futures::executor::block_on; use grafo::{BorderRadii, Shape}; use grafo::{Color, ShapeDrawCommandOptions, Stroke}; use std::sync::Arc; -use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::event::{ElementState, KeyEvent, WindowEvent}; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; -/// How long to wait before retrying a frame that was skipped because the surface -/// reported it was not visible (`Occluded`/`Timeout`). -const OCCLUDED_RETRY_DELAY: Duration = Duration::from_millis(50); - struct App<'a> { window: Option>, renderer: Option>, msaa_enabled: bool, - /// Pending retry of a frame skipped because the window was not visible. - redraw_retry_at: Option, } impl<'a> Default for App<'a> { @@ -31,7 +24,6 @@ impl<'a> Default for App<'a> { window: None, renderer: None, msaa_enabled: true, - redraw_retry_at: None, } } } @@ -107,6 +99,11 @@ impl<'a> ApplicationHandler for App<'a> { ); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let background = Shape::rect( [(0.0, 0.0), (800.0, 600.0)], @@ -182,49 +179,27 @@ impl<'a> ApplicationHandler for App<'a> { ) .unwrap(); - match renderer.render() { - Ok(_) => { - self.redraw_retry_at = None; - renderer.clear_draw_queue(); - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => renderer.clear_draw_queue(), + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { + renderer.clear_draw_queue(); + renderer.resize(renderer.size()); + } + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(error) => eprintln!("{error:?}"), } - - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Retry shortly instead of busy-looping redraws — winit does - // not request one when the window becomes visible again. `WaitUntil` - // wakes the event loop without spinning while the window stays hidden. - renderer.clear_draw_queue(); - let retry_at = Instant::now() + OCCLUDED_RETRY_DELAY; - self.redraw_retry_at = Some(retry_at); - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } } _ => {} } } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(retry_at) = self.redraw_retry_at else { - // Clear a stale WaitUntil deadline left behind when a successful - // render cancelled the pending retry before it fired. - event_loop.set_control_flow(ControlFlow::Wait); - return; - }; - if Instant::now() >= retry_at { - self.redraw_retry_at = None; - if let Some(window) = &self.window { - window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::Wait); - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - } } pub fn main() { diff --git a/examples/multi_texture.rs b/examples/multi_texture.rs index c27beea..a94d667 100644 --- a/examples/multi_texture.rs +++ b/examples/multi_texture.rs @@ -3,22 +3,36 @@ use grafo::{Color, Renderer, Shape, ShapeDrawCommandOptions, Stroke}; use std::sync::Arc; -use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::window::Window; -/// How long to wait before retrying a frame that was skipped because the surface -/// reported it was not visible (`Occluded`/`Timeout`). -const OCCLUDED_RETRY_DELAY: Duration = Duration::from_millis(50); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PersistentSceneCommitErrorAction { + ReconfigureSurface, + PreserveSceneUntilRedraw, + Report, +} + +fn persistent_scene_commit_error_action( + error: &grafo::RenderError, +) -> PersistentSceneCommitErrorAction { + match error { + grafo::RenderError::Surface(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + PersistentSceneCommitErrorAction::ReconfigureSurface + } + grafo::RenderError::Surface(wgpu::SurfaceError::Timeout) => { + PersistentSceneCommitErrorAction::PreserveSceneUntilRedraw + } + _ => PersistentSceneCommitErrorAction::Report, + } +} struct App { window: Option>, renderer: Option>, bg_tex_id: u64, fg_tex_id: u64, - /// Pending retry of a frame skipped because the window was not visible. - redraw_retry_at: Option, } impl Default for App { @@ -28,7 +42,6 @@ impl Default for App { renderer: None, bg_tex_id: 100, fg_tex_id: 101, - redraw_retry_at: None, } } } @@ -98,7 +111,7 @@ impl ApplicationHandler for App { .add_shape( Shape::rect( [(100.0, 100.0), (500.0, 400.0)], - Stroke::new(1.0, Color::BLACK), + Stroke::new(1.0_f32, Color::BLACK), ), None, None, @@ -130,49 +143,31 @@ impl ApplicationHandler for App { renderer.resize((physical_size.width, physical_size.height)); window.request_redraw(); } - WindowEvent::RedrawRequested => { + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } + WindowEvent::RedrawRequested + if renderer.prepare() == grafo::PreparationOutcome::Ready => + { // The draw queue is populated once in `resumed` and persists across frames. - match renderer.render() { - Ok(_) => { - self.redraw_retry_at = None; - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - let size = renderer.size(); - renderer.resize(size); - } - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Retry shortly instead of busy-looping redraws — winit does - // not request one when the window becomes visible again. `WaitUntil` - // wakes the event loop without spinning while the window stays hidden. - let retry_at = Instant::now() + OCCLUDED_RETRY_DELAY; - self.redraw_retry_at = Some(retry_at); - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - Err(e) => eprintln!("{e:?}"), + match renderer.commit(None) { + Ok(_) => {} + Err(error) => match persistent_scene_commit_error_action(&error) { + PersistentSceneCommitErrorAction::ReconfigureSurface => { + let size = renderer.size(); + renderer.resize(size); + } + PersistentSceneCommitErrorAction::PreserveSceneUntilRedraw => {} + PersistentSceneCommitErrorAction::Report => eprintln!("{error:?}"), + }, } } + WindowEvent::RedrawRequested => {} _ => {} } } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(retry_at) = self.redraw_retry_at else { - // Clear a stale WaitUntil deadline left behind when a successful - // render cancelled the pending retry before it fired. - event_loop.set_control_flow(ControlFlow::Wait); - return; - }; - if Instant::now() >= retry_at { - self.redraw_retry_at = None; - if let Some(window) = &self.window { - window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::Wait); - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - } } fn main() { @@ -180,3 +175,18 @@ fn main() { let mut app = App::default(); event_loop.run_app(&mut app).unwrap(); } + +#[cfg(test)] +mod tests { + use super::{persistent_scene_commit_error_action, PersistentSceneCommitErrorAction}; + + #[test] + fn timeout_preserves_the_persistent_scene_until_an_event_requests_redraw() { + let error = grafo::RenderError::Surface(wgpu::SurfaceError::Timeout); + + assert_eq!( + persistent_scene_commit_error_action(&error), + PersistentSceneCommitErrorAction::PreserveSceneUntilRedraw + ); + } +} diff --git a/examples/shadow_and_blur.rs b/examples/shadow_and_blur.rs index 84ebc96..31b7af7 100644 --- a/examples/shadow_and_blur.rs +++ b/examples/shadow_and_blur.rs @@ -15,19 +15,14 @@ use futures::executor::block_on; use grafo::{BackdropEffectConfig, BorderRadii, Shape}; use grafo::{Color, ShapeDrawCommandOptions, Stroke}; use std::sync::Arc; -use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::event::WindowEvent; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::window::{Window, WindowId}; const BOX_SHADOW_EFFECT: u64 = 1; const BLUR_EFFECT: u64 = 2; -/// How long to wait before retrying a frame that was skipped because the surface -/// reported it was not visible (`Occluded`/`Timeout`). -const OCCLUDED_RETRY_DELAY: Duration = Duration::from_millis(50); - // ── Box shadow params & shader ─────────────────────────────────────────────── #[repr(C)] @@ -165,8 +160,6 @@ fn effect_main(@location(0) uv: vec2) -> @location(0) vec4 { struct App<'a> { window: Option>, renderer: Option>, - /// Pending retry of a frame skipped because the window was not visible. - redraw_retry_at: Option, } impl<'a> ApplicationHandler for App<'a> { @@ -226,6 +219,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let (pw, ph) = renderer.size(); let pw = pw as f32; @@ -368,49 +366,30 @@ impl<'a> ApplicationHandler for App<'a> { .expect("Failed to set backdrop blur effect"); // ── Render ─────────────────────────────────────────────── - match renderer.render() { - Ok(_) => { - self.redraw_retry_at = None; - renderer.clear_draw_queue(); - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => { + renderer.clear_draw_queue(); + } + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { + renderer.clear_draw_queue(); + renderer.resize(renderer.size()); + } + + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(e) => eprintln!("{e:?}"), } - - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Retry shortly instead of busy-looping redraws — winit does - // not request one when the window becomes visible again. `WaitUntil` - // wakes the event loop without spinning while the window stays hidden. - renderer.clear_draw_queue(); - let retry_at = Instant::now() + OCCLUDED_RETRY_DELAY; - self.redraw_retry_at = Some(retry_at); - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } } _ => {} } } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(retry_at) = self.redraw_retry_at else { - // Clear a stale WaitUntil deadline left behind when a successful - // render cancelled the pending retry before it fired. - event_loop.set_control_flow(ControlFlow::Wait); - return; - }; - if Instant::now() >= retry_at { - self.redraw_retry_at = None; - if let Some(window) = &self.window { - window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::Wait); - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - } } pub fn main() { diff --git a/examples/shape_texturing.rs b/examples/shape_texturing.rs index 2532d7f..2f503bc 100644 --- a/examples/shape_texturing.rs +++ b/examples/shape_texturing.rs @@ -3,23 +3,17 @@ use grafo::{BorderRadii, Shape}; use grafo::{Color, ShapeDrawCommandOptions, Stroke}; use image::ImageReader; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Instant; use winit::application::ApplicationHandler; use winit::event::WindowEvent; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::window::{Window, WindowId}; -/// How long to wait before retrying a frame that was skipped because the surface -/// reported it was not visible (`Occluded`/`Timeout`). -const OCCLUDED_RETRY_DELAY: Duration = Duration::from_millis(50); - struct App<'a> { window: Option>, renderer: Option>, rust_logo_png_bytes: Vec, rust_logo_png_dimensions: (u32, u32), - /// Pending retry of a frame skipped because the window was not visible. - redraw_retry_at: Option, } impl<'a> Default for App<'a> { @@ -39,7 +33,6 @@ impl<'a> Default for App<'a> { renderer: None, rust_logo_png_bytes, rust_logo_png_dimensions, - redraw_retry_at: None, } } } @@ -91,6 +84,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let window_size = window.inner_size(); @@ -144,26 +142,19 @@ impl<'a> ApplicationHandler for App<'a> { .unwrap(); let timer = Instant::now(); - match renderer.render() { - Ok(_) => { - self.redraw_retry_at = None; - renderer.clear_draw_queue(); - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => renderer.clear_draw_queue(), + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => renderer.resize(renderer.size()), + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(error) => eprintln!("{error:?}"), } - - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Retry shortly instead of busy-looping redraws — winit does - // not request one when the window becomes visible again. `WaitUntil` - // wakes the event loop without spinning while the window stays hidden. - renderer.clear_draw_queue(); - let retry_at = Instant::now() + OCCLUDED_RETRY_DELAY; - self.redraw_retry_at = Some(retry_at); - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } println!("Render time: {:?}", timer.elapsed()); } @@ -173,24 +164,6 @@ impl<'a> ApplicationHandler for App<'a> { _ => {} } } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(retry_at) = self.redraw_retry_at else { - // Clear a stale WaitUntil deadline left behind when a successful - // render cancelled the pending retry before it fired. - event_loop.set_control_flow(ControlFlow::Wait); - return; - }; - if Instant::now() >= retry_at { - self.redraw_retry_at = None; - if let Some(window) = &self.window { - window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::Wait); - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - } } pub fn main() { diff --git a/examples/star_wars_tilt.rs b/examples/star_wars_tilt.rs index e5206a7..29b7340 100644 --- a/examples/star_wars_tilt.rs +++ b/examples/star_wars_tilt.rs @@ -1,6 +1,6 @@ use euclid::{Point2D, UnknownUnit}; use futures::executor::block_on; -use grafo::{Color, Shape, ShapeDrawCommandOptions, Stroke}; +use grafo::{Color, PreparationOutcome, Shape, ShapeDrawCommandOptions, Stroke}; use std::sync::Arc; use winit::application::ApplicationHandler; use winit::event::WindowEvent; @@ -71,6 +71,11 @@ impl<'a> ApplicationHandler for App<'a> { WindowEvent::CloseRequested => { event_loop.exit(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { if let Some(renderer) = &mut self.renderer { renderer.clear_draw_queue(); @@ -228,20 +233,21 @@ impl<'a> ApplicationHandler for App<'a> { ) .unwrap(); - match renderer.render() { + let PreparationOutcome::Ready = renderer.prepare() else { + renderer.clear_draw_queue(); + return; + }; + let commit_result = renderer.commit(None); + match commit_result { Ok(_) => {} - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { let size = renderer.size(); renderer.resize(size); } - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or - // fully covered). Ask for another redraw instead of dropping the - // frame for good — winit does not request one when the window - // becomes visible again. - if let Some(window) = &self.window { - window.request_redraw(); - } + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); } Err(e) => eprintln!("{e:?}"), } diff --git a/examples/transforms.rs b/examples/transforms.rs index b25f0c5..a37be28 100644 --- a/examples/transforms.rs +++ b/examples/transforms.rs @@ -8,7 +8,6 @@ use lyon::geom::point; use lyon::path::FillRule; use lyon::path::Path; use std::sync::Arc; -use std::time::{Duration, Instant}; // Local converter from euclid to grafo's GPU instance layout so we keep euclid out of the main crate. fn transform_instance_from_euclid(m: Transform3D) -> grafo::TransformInstance { @@ -79,7 +78,7 @@ fn world_to_local_2d(tx: &Transform3D, world: (f32, f32)) -> Option<(f32, f } use winit::application::ApplicationHandler; use winit::event::WindowEvent; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; @@ -124,16 +123,10 @@ fn build_perspective_demo_path() -> Path { pb.build() } -/// How long to wait before retrying a frame that was skipped because the surface -/// reported it was not visible (`Occluded`/`Timeout`). -const OCCLUDED_RETRY_DELAY: Duration = Duration::from_millis(50); - #[derive(Default)] struct App<'a> { window: Option>, renderer: Option>, - /// Pending retry of a frame skipped because the window was not visible. - redraw_retry_at: Option, angle: f32, // Last mouse position in physical pixels (window space) last_mouse_pos: Option<(f32, f32)>, @@ -402,6 +395,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { // Background in logical coordinates (renderer normalizes using logical canvas size) let logical_w = window.inner_size().width as f32 / self.scale_factor as f32; @@ -607,50 +605,30 @@ impl<'a> ApplicationHandler for App<'a> { // Advance animation angle self.angle = (self.angle + 0.02) % (std::f32::consts::TAU); - match renderer.render() { - Ok(_) => { - self.redraw_retry_at = None; - renderer.clear_draw_queue(); - window.request_redraw(); - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) - } - - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Retry shortly instead of busy-looping redraws — winit does - // not request one when the window becomes visible again. `WaitUntil` - // wakes the event loop without spinning while the window stays hidden. - renderer.clear_draw_queue(); - let retry_at = Instant::now() + OCCLUDED_RETRY_DELAY; - self.redraw_retry_at = Some(retry_at); - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => { + renderer.clear_draw_queue(); + window.request_redraw(); + } + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { + renderer.clear_draw_queue(); + renderer.resize(renderer.size()); + } + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(error) => eprintln!("{error:?}"), } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } } _ => {} } } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(retry_at) = self.redraw_retry_at else { - // Clear a stale WaitUntil deadline left behind when a successful - // render cancelled the pending retry before it fired. - event_loop.set_control_flow(ControlFlow::Wait); - return; - }; - if Instant::now() >= retry_at { - self.redraw_retry_at = None; - if let Some(window) = &self.window { - window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::Wait); - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - } } pub fn main() { @@ -661,7 +639,6 @@ pub fn main() { let mut app = App { window: None, renderer: None, - redraw_retry_at: None, angle: 0.0, last_mouse_pos: None, orbit_yaw_deg: 0.0, diff --git a/examples/video_playback.rs b/examples/video_playback.rs index b258327..ba4c733 100644 --- a/examples/video_playback.rs +++ b/examples/video_playback.rs @@ -106,12 +106,12 @@ pub fn main() { // None, // ); // - // match renderer.render() { + // match { renderer.prepare(); renderer.commit(None) } { // Ok(_) => { // renderer.clear_draw_queue(); // } - // Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => renderer.resize(renderer.size()), - // Err(wgpu::SurfaceError::Timeout) => { + // Err(grafo::RenderError::Surface(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated)) => renderer.resize(renderer.size()), + // Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { // // The window is not visible yet (still appearing, minimized, or fully // // covered). Ask for another redraw instead of dropping the frame for // // good — winit does not request one when the window becomes visible. diff --git a/examples/visual_test_grid.rs b/examples/visual_test_grid.rs index 08ab17b..2c48177 100644 --- a/examples/visual_test_grid.rs +++ b/examples/visual_test_grid.rs @@ -7,22 +7,15 @@ use futures::executor::block_on; use grafo_test_scenes::{build_main_scene, CANVAS_HEIGHT, CANVAS_WIDTH}; use std::sync::Arc; -use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::event::WindowEvent; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::window::{Window, WindowId}; -/// How long to wait before retrying a frame that was skipped because the surface -/// reported it was not visible (`Occluded`/`Timeout`). -const OCCLUDED_RETRY_DELAY: Duration = Duration::from_millis(50); - #[derive(Default)] struct App<'a> { window: Option>, renderer: Option>, - /// Pending retry of a frame skipped because the window was not visible. - redraw_retry_at: Option, } impl<'a> ApplicationHandler for App<'a> { @@ -74,50 +67,33 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { renderer.clear_draw_queue(); build_main_scene(renderer); - match renderer.render() { - Ok(_) => { - self.redraw_retry_at = None; + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => {} + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => renderer.resize(renderer.size()), + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(e) => eprintln!("{e:?}"), } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) - } - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Retry shortly instead of busy-looping redraws — winit does - // not request one when the window becomes visible again. `WaitUntil` - // wakes the event loop without spinning while the window stays hidden. - let retry_at = Instant::now() + OCCLUDED_RETRY_DELAY; - self.redraw_retry_at = Some(retry_at); - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } } _ => {} } } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(retry_at) = self.redraw_retry_at else { - // Clear a stale WaitUntil deadline left behind when a successful - // render cancelled the pending retry before it fired. - event_loop.set_control_flow(ControlFlow::Wait); - return; - }; - if Instant::now() >= retry_at { - self.redraw_retry_at = None; - if let Some(window) = &self.window { - window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::Wait); - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - } } pub fn main() { diff --git a/examples/winit.rs b/examples/winit.rs index 0bf0454..fd5b3d1 100644 --- a/examples/winit.rs +++ b/examples/winit.rs @@ -3,24 +3,18 @@ use grafo::{BorderRadii, Shape}; use grafo::{Color, ShapeDrawCommandOptions, Stroke}; use image::ImageReader; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Instant; use winit::application::ApplicationHandler; use winit::event::WindowEvent; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::window::{Window, WindowId}; -/// How long to wait before retrying a frame that was skipped because the surface -/// reported it was not visible (`Occluded`/`Timeout`). -const OCCLUDED_RETRY_DELAY: Duration = Duration::from_millis(50); - struct App<'a> { window: Option>, renderer: Option>, rust_logo_png_bytes: Vec, rust_logo_png_dimensions: (u32, u32), rust_logo_png_dimensions_f32: (f32, f32), - /// Pending retry of a frame skipped because the window was not visible. - redraw_retry_at: Option, } impl<'a> Default for App<'a> { @@ -45,7 +39,6 @@ impl<'a> Default for App<'a> { rust_logo_png_bytes, rust_logo_png_dimensions, rust_logo_png_dimensions_f32, - redraw_retry_at: None, } } } @@ -97,6 +90,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let window_size = window.inner_size(); @@ -267,26 +265,25 @@ impl<'a> ApplicationHandler for App<'a> { let _ = (img_rect1_id, img_rect2_id); let timer = Instant::now(); - match renderer.render() { - Ok(_) => { - self.redraw_retry_at = None; - renderer.clear_draw_queue(); - } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { - renderer.resize(renderer.size()) - } + if renderer.prepare() == grafo::PreparationOutcome::Ready { + match renderer.commit(None) { + Ok(_) => { + renderer.clear_draw_queue(); + } + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { + renderer.clear_draw_queue(); + renderer.resize(renderer.size()); + } - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Retry shortly instead of busy-looping redraws — winit does - // not request one when the window becomes visible again. `WaitUntil` - // wakes the event loop without spinning while the window stays hidden. - renderer.clear_draw_queue(); - let retry_at = Instant::now() + OCCLUDED_RETRY_DELAY; - self.redraw_retry_at = Some(retry_at); - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { + renderer.clear_draw_queue(); + } + Err(e) => eprintln!("{e:?}"), } - Err(e) => eprintln!("{e:?}"), + } else { + renderer.clear_draw_queue(); } println!("Render time: {:?}", timer.elapsed()); } @@ -296,24 +293,6 @@ impl<'a> ApplicationHandler for App<'a> { _ => {} } } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - let Some(retry_at) = self.redraw_retry_at else { - // Clear a stale WaitUntil deadline left behind when a successful - // render cancelled the pending retry before it fired. - event_loop.set_control_flow(ControlFlow::Wait); - return; - }; - if Instant::now() >= retry_at { - self.redraw_retry_at = None; - if let Some(window) = &self.window { - window.request_redraw(); - } - event_loop.set_control_flow(ControlFlow::Wait); - } else { - event_loop.set_control_flow(ControlFlow::WaitUntil(retry_at)); - } - } } pub fn main() { diff --git a/examples/winit_transparency.rs b/examples/winit_transparency.rs index e32e170..35a7018 100644 --- a/examples/winit_transparency.rs +++ b/examples/winit_transparency.rs @@ -1,5 +1,5 @@ use futures::executor::block_on; -use grafo::{BorderRadii, Color, Shape, ShapeDrawCommandOptions, Stroke}; +use grafo::{BorderRadii, Color, PreparationOutcome, Shape, ShapeDrawCommandOptions, Stroke}; use std::sync::Arc; use std::time::Instant; use winit::application::ApplicationHandler; @@ -64,6 +64,11 @@ impl<'a> ApplicationHandler for App<'a> { renderer.resize(new_size); window.request_redraw(); } + WindowEvent::Occluded(false) => { + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { let timer = Instant::now(); @@ -97,21 +102,25 @@ impl<'a> ApplicationHandler for App<'a> { .unwrap(); // Render the frame - match renderer.render() { + let PreparationOutcome::Ready = renderer.prepare() else { + renderer.clear_draw_queue(); + return; + }; + let commit_result = renderer.commit(None); + match commit_result { Ok(_) => { renderer.clear_draw_queue(); println!("Render time: {:?}", timer.elapsed()); } - Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + Err(grafo::RenderError::Surface( + wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, + )) => { + renderer.clear_draw_queue(); println!("Surface lost or outdated, resizing..."); renderer.resize(renderer.size()) } - Err(wgpu::SurfaceError::Timeout) => { - // The window is not visible yet (still appearing, minimized, or fully - // covered). Ask for another redraw instead of dropping the frame for - // good — winit does not request one when the window becomes visible. + Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => { renderer.clear_draw_queue(); - window.request_redraw(); } Err(e) => eprintln!("Render error: {e:?}"), } diff --git a/src/effect.rs b/src/effect.rs index 76faf03..0397611 100644 --- a/src/effect.rs +++ b/src/effect.rs @@ -269,9 +269,6 @@ pub(crate) struct OffscreenTexturePool { next_texture_id: u64, } -/// Maximum number of textures to keep in the pool. -const MAX_POOL_SIZE: usize = 8; - impl OffscreenTexturePool { pub fn new() -> Self { Self { @@ -281,23 +278,16 @@ impl OffscreenTexturePool { } /// Return textures for reuse in future frames. - /// Textures that don't match the given active configuration are dropped - /// immediately, and the pool is capped at `MAX_POOL_SIZE`. pub fn recycle(&mut self, textures: &mut Vec) { self.available.append(textures); - self.available.truncate(MAX_POOL_SIZE); } /// Drop all pooled textures whose dimensions, or sample count don't match - /// the current active configuration, and enforce the maximum pool size. + /// the current active configuration. /// Call this when size, format, or MSAA settings change (e.g. on resize). pub fn trim(&mut self, width: u32, height: u32, sample_count: u32) { self.available .retain(|t| t.width == width && t.height == height && t.sample_count == sample_count); - // Enforce max pool size — drop oldest excess textures - if self.available.len() > MAX_POOL_SIZE { - self.available.truncate(MAX_POOL_SIZE); - } } /// Acquire a texture matching the given dimensions and sample count, plus a depth/stencil @@ -337,6 +327,7 @@ impl OffscreenTexturePool { let found = self.available.iter().position(|texture| { texture.width == width && texture.height == height + && texture.color_texture.format() == format && texture.sample_count == sample_count && texture.depth_stencil_view.is_some() == with_depth }); @@ -978,3 +969,51 @@ pub(crate) fn create_params_bind_group( }], }) } + +#[cfg(test)] +mod pool_tests { + use super::OffscreenTexturePool; + use crate::{RendererContext, RendererCreationError}; + use futures::executor::block_on; + + #[test] + fn larger_effect_scenes_retain_all_reusable_textures() { + let context = match block_on(RendererContext::try_new()) { + Ok(context) => context, + Err(RendererCreationError::AdapterNotAvailable(_)) => { + println!("Skipping test: no suitable GPU adapter available."); + return; + } + Err(error) => panic!("context creation failed: {error}"), + }; + let mut pool = OffscreenTexturePool::new(); + let mut textures: Vec<_> = (0..12) + .map(|_| { + pool.acquire_color_only( + &context.inner.device, + 16, + 16, + wgpu::TextureFormat::Rgba8Unorm, + 1, + ) + }) + .collect(); + let original: Vec<_> = textures.iter().map(|texture| texture.texture_id).collect(); + pool.recycle(&mut textures); + for _ in 0..12 { + let texture = pool.acquire_color_only( + &context.inner.device, + 16, + 16, + wgpu::TextureFormat::Rgba8Unorm, + 1, + ); + assert!( + original.contains(&texture.texture_id), + "a reusable texture was dropped and reallocated" + ); + textures.push(texture); + } + pool.recycle(&mut textures); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6c98d9d..9459cac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,24 +116,24 @@ //! window.request_redraw(); //! } //! } +//! WindowEvent::Occluded(false) => { +//! if let Some(window) = &self.window { +//! window.request_redraw(); +//! } +//! } //! WindowEvent::RedrawRequested => { -//! match renderer.render() { -//! Ok(_) => { -//! renderer.clear_draw_queue(); -//! } -//! Err( -//! wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated, -//! ) => renderer.resize(renderer.size()), -//! Err(wgpu::SurfaceError::Timeout) => { -//! // The window is not visible yet (still appearing, minimized, or -//! // fully covered). Ask for another redraw instead of dropping the -//! // frame — winit does not request one when the window becomes -//! // visible again. -//! if let Some(window) = &self.window { -//! window.request_redraw(); -//! } +//! match renderer.prepare() { +//! grafo::PreparationOutcome::Ready => match renderer.commit(None) { +//! Ok(_) => {} +//! Err( +//! grafo::RenderError::Surface(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated), +//! ) => renderer.resize(renderer.size()), +//! Err(grafo::RenderError::Surface(wgpu::SurfaceError::Timeout)) => {} +//! Err(e) => eprintln!("{:?}", e), +//! }, +//! grafo::PreparationOutcome::Suspended => { +//! // Preserve the scene until resize or unocclusion requests a redraw. //! } -//! Err(e) => eprintln!("{:?}", e), //! } //! } //! _ => {} @@ -181,8 +181,8 @@ pub use gradient::types::{ RadialGradientDesc, RadialGradientShape, RadialGradientSize, SpreadMode, }; pub use renderer::{ - types::DrawCommandError, MathRect, Renderer, RendererContext, RendererCreationError, - ShapeOverflow, TextureLayer, + types::DrawCommandError, MathRect, PreparationOutcome, RenderError, Renderer, RendererContext, + RendererCreationError, ShapeOverflow, TextureLayer, }; pub use shape::*; pub use stroke::Stroke; diff --git a/src/renderer.rs b/src/renderer.rs index 3711b75..3628d0b 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -27,6 +27,8 @@ use crate::util::{to_logical, PoolManager}; use crate::vertex::{CustomVertex, InstanceColor, InstanceMetadata, InstanceTransform}; use crate::CachedShapeHandle; pub use construction::RendererCreationError; +use preparation::PreparedBuffers; +pub use preparation::{PreparationOutcome, RenderError}; mod construction; mod draw_queue; @@ -121,6 +123,11 @@ pub struct Renderer<'a> { /// Tree structure holding shapes to be rendered. draw_tree: easy_tree::Tree, + prepared_buffers: Option, + #[cfg(feature = "render_metrics")] + preparation_cpu_time: Duration, + pre_present_callback: Option>, + submitted_work: Option, /// Maps node metadata indices to their clip-parent node ids. metadata_to_clips: HashMap, @@ -137,8 +144,6 @@ pub struct Renderer<'a> { shape_texture_bind_group_layout_foreground: Arc, /// Bind group layout for backdrop textures (group 3, bindings 3 and 4). backdrop_texture_bind_group_layout: Arc, - /// Monotonic counter to invalidate cached shape texture bind groups when the layout changes. - shape_texture_layout_epoch: u64, /// Default transparent texture bind groups for both layers. default_shape_texture_bind_groups: [Arc; 2], // [background, foreground] /// Default transparent bind group for backdrop sampling. @@ -217,6 +222,7 @@ pub struct Renderer<'a> { loaded_effects: HashMap, /// Per-node group effect instances, keyed by node_id. group_effects: HashMap, + effect_instance_pool: Vec, /// Per-node backdrop effect instances, keyed by node_id. /// A backdrop effect processes the pixels already rendered behind a shape. backdrop_effects: HashMap, @@ -270,7 +276,6 @@ pub struct Renderer<'a> { /// Bind group layout for gradient resources plus backdrop sampling. backdrop_gradient_bind_group_layout: wgpu::BindGroupLayout, /// Monotonic counter to invalidate cached gradient bind groups when the layout changes. - gradient_bind_group_layout_epoch: u64, /// Sampler for gradient ramp textures (nearest, clamp-to-edge). gradient_ramp_sampler: wgpu::Sampler, @@ -296,31 +301,46 @@ pub struct Renderer<'a> { /// and `queue.submit`, but excludes presentation, readback mapping, and any /// forced GPU waits after submission. last_render_to_texture_view_cpu_time: Duration, + /// Acquisition, uploads, encoding and submission, including failed attempts. + last_submission_duration: Duration, // ── Reusable scratch state ─────────────────────────────────────────── - scratch: RendererScratch, + scratch: Option, } /// Default AA fringe width in physical pixels. const DEFAULT_FRINGE_WIDTH: f32 = 0.75; +impl Drop for Renderer<'_> { + fn drop(&mut self) { + self.texture_manager + .retire_shape_bind_group_layout(&self.shape_texture_bind_group_layout_background); + self.texture_manager + .retire_shape_bind_group_layout(&self.shape_texture_bind_group_layout_foreground); + } +} + impl<'a> Renderer<'a> { const DEFAULT_FRINGE_WIDTH: f32 = DEFAULT_FRINGE_WIDTH; - pub(super) fn begin_frame_scratch(&mut self) { - self.scratch.begin_frame(); + fn scratch_mut(&mut self) -> &mut RendererScratch { + self.scratch + .as_mut() + .expect("scratch is owned by active rendering") } - pub(super) fn trim_scratch_on_resize_or_policy(&mut self) { - // This is safe to call frequently: `shrink_to` is effectively a no-op - // when capacities are below thresholds, so this acts as amortized - // memory hygiene for long-running sessions. - self.buffers_pool_manager.trim(); - self.scratch.trim_to_policy(); + pub(super) fn begin_frame_scratch(&mut self) { + self.scratch_mut().begin_frame(); } /// Returns the wall-clock CPU time spent in the most recent `render_to_texture_view()` call. pub fn last_render_to_texture_view_cpu_time(&self) -> Duration { self.last_render_to_texture_view_cpu_time } + + /// Time from the latest commit attempt through submission or failure. + /// Includes drawable acquisition and uploads, but excludes presentation and later GPU waits. + pub fn last_submission_duration(&self) -> Duration { + self.last_submission_duration + } } diff --git a/src/renderer/construction.rs b/src/renderer/construction.rs index e9e13e1..dfd3e38 100644 --- a/src/renderer/construction.rs +++ b/src/renderer/construction.rs @@ -79,6 +79,28 @@ pub enum RendererCreationError { } impl RendererContext { + /// Shares the GPU device and queue, with independent mutable assets for a surface. + pub fn isolated_resources(&self) -> Self { + Self { + inner: Arc::new(RendererContextInner { + instance: Arc::clone(&self.inner.instance), + adapter: Arc::clone(&self.inner.adapter), + device: Arc::clone(&self.inner.device), + queue: Arc::clone(&self.inner.queue), + texture_manager: TextureManager::new( + Arc::clone(&self.inner.device), + Arc::clone(&self.inner.queue), + ), + shape_cache: RwLock::new(HashMap::new()), + }), + } + } + + /// Returns the shared texture store without acquiring a surface renderer. + pub fn texture_manager(&self) -> TextureManager { + self.inner.texture_manager.clone() + } + /// Creates GPU resources that can be shared by any number of independent renderers. /// /// The context deliberately has no surface. A renderer created from it validates and @@ -359,7 +381,6 @@ impl<'a> Renderer<'a> { shape_texture_bind_group_layout_layer1, ), backdrop_texture_bind_group_layout: Arc::new(backdrop_texture_bind_group_layout), - shape_texture_layout_epoch: 0, default_shape_texture_bind_groups: [ Arc::new(default_shape_texture_bind_group_layer0), Arc::new(default_shape_texture_bind_group_layer1), @@ -408,6 +429,7 @@ impl<'a> Renderer<'a> { depth_stencil_view: None, loaded_effects: HashMap::new(), group_effects: HashMap::new(), + effect_instance_pool: Vec::new(), backdrop_effects: HashMap::new(), shape_effects: HashMap::new(), shape_effect_cache: FrameCache::new(), @@ -428,7 +450,6 @@ impl<'a> Renderer<'a> { and_gradient_pipeline: Arc::new(and_gradient_pipeline), gradient_bind_group_layout, backdrop_gradient_bind_group_layout, - gradient_bind_group_layout_epoch: 0, gradient_ramp_sampler, #[cfg(feature = "render_metrics")] render_loop_metrics_tracker: RenderLoopMetricsTracker::default(), @@ -439,7 +460,13 @@ impl<'a> Renderer<'a> { #[cfg(feature = "render_metrics")] last_shape_effect_cache_metrics: Default::default(), last_render_to_texture_view_cpu_time: Default::default(), - scratch: RendererScratch::new(), + last_submission_duration: Duration::ZERO, + scratch: Some(RendererScratch::new()), + prepared_buffers: None, + #[cfg(feature = "render_metrics")] + preparation_cpu_time: Duration::ZERO, + pre_present_callback: None, + submitted_work: None, }; renderer.recreate_msaa_texture(); @@ -751,7 +778,7 @@ impl<'a> Renderer<'a> { /// Creates a headless renderer without a window surface. /// /// Use `render_to_buffer()` or `render_to_argb32()` to read back rendered - /// pixels. Calling `render()` on a headless renderer will panic. + /// pixels. Calling [`Self::commit`] returns [`RenderError::Headless`]. /// /// Returns an error if no suitable GPU adapter is available, the device /// cannot be created, or the `scale_factor` is invalid. @@ -802,7 +829,7 @@ impl<'a> Renderer<'a> { /// invalid scale factor, device/queue creation failure). /// /// Use `render_to_buffer()` or `render_to_argb32()` to read back rendered - /// pixels. Calling `render()` on a headless renderer will panic. + /// pixels. Calling [`Self::commit`] returns [`RenderError::Headless`]. /// /// For a non-panicking alternative (e.g. in tests), use /// [`Self::try_new_headless`] instead. @@ -859,18 +886,20 @@ impl<'a> Renderer<'a> { self.decrementing_uniform_buffer = decrementing_uniform_buffer; self.decrementing_bind_group = decrementing_bind_group; + self.texture_manager + .retire_shape_bind_group_layout(&self.shape_texture_bind_group_layout_background); + self.texture_manager + .retire_shape_bind_group_layout(&self.shape_texture_bind_group_layout_foreground); self.shape_texture_bind_group_layout_background = Arc::new(and_texture_bgl_layer0); self.shape_texture_bind_group_layout_foreground = Arc::new(and_texture_bgl_layer1); self.shape_effect_cache.clear(); self.shape_effect_mask_cache.clear(); self.backdrop_texture_bind_group_layout = Arc::new(create_backdrop_texture_bind_group_layout(&self.device)); - self.shape_texture_layout_epoch += 1; self.gradient_bind_group_layout = create_gradient_bind_group_layout(&self.device); self.backdrop_gradient_bind_group_layout = create_backdrop_gradient_bind_group_layout(&self.device); - self.gradient_bind_group_layout_epoch += 1; self.gradient_ramp_sampler = self.device.create_sampler(&wgpu::SamplerDescriptor { label: Some("gradient_ramp_sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, @@ -952,7 +981,6 @@ impl<'a> Renderer<'a> { &self.queue, &self.gradient_bind_group_layout, &self.gradient_ramp_sampler, - self.gradient_bind_group_layout_epoch, ); if let DrawCommand::CachedShape(cached_shape) = draw_command { diff --git a/src/renderer/draw_queue.rs b/src/renderer/draw_queue.rs index e0b8fe3..5d5db24 100644 --- a/src/renderer/draw_queue.rs +++ b/src/renderer/draw_queue.rs @@ -23,6 +23,7 @@ impl<'a> Renderer<'a> { // the geometry is loaded to the GPU. geometry_id: Option, ) { + self.discard_preparation(); let cached_shape = CachedShapeHandle::new( shape.as_ref(), &mut self.tessellator, @@ -39,6 +40,7 @@ impl<'a> Renderer<'a> { /// Removes a loaded shape from the cache. pub fn remove_shape(&mut self, cache_key: u64) { + self.discard_preparation(); self.context .inner .shape_cache @@ -58,6 +60,7 @@ impl<'a> Renderer<'a> { parent_shape_id: Option, options: ShapeDrawCommandOptions, ) -> Result { + self.discard_preparation(); let mut draw_data = if let Some(cached_shape_handle) = self .context .inner @@ -87,6 +90,7 @@ impl<'a> Renderer<'a> { geometry_id: Option, options: ShapeDrawCommandOptions, ) -> Result { + self.discard_preparation(); let cached_shape = CachedShapeHandle::new( shape.as_ref(), &mut self.tessellator, @@ -117,6 +121,7 @@ impl<'a> Renderer<'a> { transform: Option>, clips_children: bool, ) -> Result { + self.discard_preparation(); let transform = transform.map(Into::into); if let Some(transform) = transform { if !clip_rect_supports_transform(transform) { @@ -145,7 +150,6 @@ impl<'a> Renderer<'a> { &self.queue, &self.gradient_bind_group_layout, &self.gradient_ramp_sampler, - self.gradient_bind_group_layout_epoch, ); let index_range = preparation::append_aggregated_geometry_for_shape( cached_shape_data, @@ -223,13 +227,14 @@ impl<'a> Renderer<'a> { } pub fn clear_draw_queue(&mut self) { + self.discard_preparation(); self.draw_tree.clear(); self.metadata_to_clips.clear(); - self.group_effects.clear(); - self.backdrop_effects.clear(); + self.effect_instance_pool + .extend(self.group_effects.drain().map(|(_, instance)| instance)); + self.effect_instance_pool + .extend(self.backdrop_effects.drain().map(|(_, instance)| instance)); self.shape_effects.clear(); - // Keep scratch storage bounded even if queue contents fluctuate frame-to-frame. - self.trim_scratch_on_resize_or_policy(); // Clear memory buffers that are used for GPU upload self.clear_buffers(); } diff --git a/src/renderer/effects.rs b/src/renderer/effects.rs index 7f656dc..4b43757 100644 --- a/src/renderer/effects.rs +++ b/src/renderer/effects.rs @@ -151,7 +151,6 @@ fn build_effect_instance( fn update_effect_instance_params( device: &wgpu::Device, - queue: &wgpu::Queue, loaded_effects: &HashMap, instance: &mut EffectInstance, params: &[u8], @@ -165,7 +164,6 @@ fn update_effect_instance_params( if let Some(existing_buffer) = instance.params_buffer.as_ref() { if params.len() as u64 <= existing_buffer.size() { - queue.write_buffer(existing_buffer, 0, params); return; } } @@ -224,11 +222,60 @@ fn refresh_effect_instance_after_reload( } impl<'a> Renderer<'a> { + fn reusable_effect_instance( + &mut self, + effect_id: u64, + params: &[u8], + backdrop_config: Option, + label: &'static str, + ) -> EffectInstance { + if let Some(index) = self + .effect_instance_pool + .iter() + .position(|instance| instance.effect_id == effect_id) + { + let mut instance = self.effect_instance_pool.swap_remove(index); + update_effect_instance_params( + &self.device, + &self.loaded_effects, + &mut instance, + params, + label, + ); + instance.backdrop_config = backdrop_config; + return instance; + } + build_effect_instance( + &self.device, + &self.loaded_effects, + effect_id, + params, + backdrop_config, + label, + ) + } + + pub(super) fn upload_effect_params(&self) { + for instance in self + .group_effects + .values() + .chain(self.backdrop_effects.values()) + { + if instance.params.is_empty() { + continue; + } + if let Some(buffer) = &instance.params_buffer { + self.queue.write_buffer(buffer, 0, &instance.params); + } + } + } + pub fn load_effect( &mut self, effect_id: u64, pass_sources: &[&str], ) -> Result<(), EffectError> { + self.discard_preparation(); if self.loaded_effects.get(&effect_id).is_some_and(|effect| { effect.pass_sources.len() == pass_sources.len() && effect @@ -255,6 +302,10 @@ impl<'a> Renderer<'a> { instance.effect_id != effect_id || refresh_effect_instance_after_reload(&self.device, loaded_effect, instance) }); + self.effect_instance_pool.retain_mut(|instance| { + instance.effect_id != effect_id + || refresh_effect_instance_after_reload(&self.device, loaded_effect, instance) + }); self.shape_effects.retain(|_, instance| { instance.effect_id != effect_id || validate_params_expectation( @@ -275,6 +326,7 @@ impl<'a> Renderer<'a> { effect_id: u64, params: &[u8], ) -> Result<(), EffectError> { + self.discard_preparation(); if self.draw_tree.get(node_id).is_none() { return Err(EffectError::NodeNotFound(node_id)); } @@ -290,16 +342,12 @@ impl<'a> Renderer<'a> { validate_effect_params(&self.loaded_effects, effect_id, params)?; - let instance = build_effect_instance( - &self.device, - &self.loaded_effects, - effect_id, - params, - None, - "effect_params_buffer", - ); + let instance = + self.reusable_effect_instance(effect_id, params, None, "effect_params_buffer"); - self.group_effects.insert(node_id, instance); + if let Some(previous) = self.group_effects.insert(node_id, instance) { + self.effect_instance_pool.push(previous); + } Ok(()) } @@ -308,6 +356,7 @@ impl<'a> Renderer<'a> { node_id: usize, params: &[u8], ) -> Result<(), EffectError> { + self.discard_preparation(); let instance = self .group_effects .get_mut(&node_id) @@ -317,7 +366,6 @@ impl<'a> Renderer<'a> { update_effect_instance_params( &self.device, - &self.queue, &self.loaded_effects, instance, params, @@ -328,7 +376,10 @@ impl<'a> Renderer<'a> { } pub fn remove_group_effect(&mut self, node_id: usize) { - self.group_effects.remove(&node_id); + self.discard_preparation(); + if let Some(instance) = self.group_effects.remove(&node_id) { + self.effect_instance_pool.push(instance); + } } pub fn set_shape_backdrop_effect( @@ -338,6 +389,7 @@ impl<'a> Renderer<'a> { params: &[u8], backdrop_config: effect::BackdropEffectConfig, ) -> Result<(), EffectError> { + self.discard_preparation(); if self.draw_tree.get(node_id).is_none() { return Err(EffectError::NodeNotFound(node_id)); } @@ -354,16 +406,16 @@ impl<'a> Renderer<'a> { validate_effect_params(&self.loaded_effects, effect_id, params)?; validate_backdrop_config(&backdrop_config)?; - let instance = build_effect_instance( - &self.device, - &self.loaded_effects, + let instance = self.reusable_effect_instance( effect_id, params, Some(backdrop_config), "backdrop_effect_params_buffer", ); - self.backdrop_effects.insert(node_id, instance); + if let Some(previous) = self.backdrop_effects.insert(node_id, instance) { + self.effect_instance_pool.push(previous); + } Ok(()) } @@ -372,6 +424,7 @@ impl<'a> Renderer<'a> { node_id: usize, backdrop_config: effect::BackdropEffectConfig, ) -> Result<(), EffectError> { + self.discard_preparation(); validate_backdrop_config(&backdrop_config)?; let instance = self @@ -389,6 +442,7 @@ impl<'a> Renderer<'a> { node_id: usize, params: &[u8], ) -> Result<(), EffectError> { + self.discard_preparation(); let instance = self .backdrop_effects .get_mut(&node_id) @@ -398,7 +452,6 @@ impl<'a> Renderer<'a> { update_effect_instance_params( &self.device, - &self.queue, &self.loaded_effects, instance, params, @@ -409,7 +462,10 @@ impl<'a> Renderer<'a> { } pub fn remove_backdrop_effect(&mut self, node_id: usize) { - self.backdrop_effects.remove(&node_id); + self.discard_preparation(); + if let Some(instance) = self.backdrop_effects.remove(&node_id) { + self.effect_instance_pool.push(instance); + } } /// Attaches a cached shader effect generated from the node's local coverage mask. @@ -420,6 +476,7 @@ impl<'a> Renderer<'a> { params: &[u8], config: effect::ShapeEffectConfig, ) -> Result<(), EffectError> { + self.discard_preparation(); let draw_command = self .draw_tree .get(node_id) @@ -449,6 +506,7 @@ impl<'a> Renderer<'a> { node_id: usize, params: &[u8], ) -> Result<(), EffectError> { + self.discard_preparation(); let effect_id = self .shape_effects .get(&node_id) @@ -467,6 +525,7 @@ impl<'a> Renderer<'a> { node_id: usize, config: effect::ShapeEffectConfig, ) -> Result<(), EffectError> { + self.discard_preparation(); validate_shape_effect_config(&config)?; let instance = self .shape_effects @@ -477,11 +536,15 @@ impl<'a> Renderer<'a> { } pub fn remove_shape_effect(&mut self, node_id: usize) { + self.discard_preparation(); self.shape_effects.remove(&node_id); } pub fn unload_effect(&mut self, effect_id: u64) { + self.discard_preparation(); self.loaded_effects.remove(&effect_id); + self.effect_instance_pool + .retain(|instance| instance.effect_id != effect_id); self.group_effects .retain(|_, instance| instance.effect_id != effect_id); self.backdrop_effects @@ -602,6 +665,52 @@ mod tests { validate_backdrop_config, validate_params_expectation, validate_shape_effect_config, }; use crate::effect::{BackdropCaptureArea, BackdropEffectConfig, ShapeEffectConfig}; + use crate::{Renderer, RendererCreationError, Shape, ShapeDrawCommandOptions, Stroke}; + use futures::executor::block_on; + + const PARAMETER_EFFECT: &str = r#" +@group(1) @binding(0) var parameters: vec4; +@fragment +fn effect_main(@location(0) uv: vec2) -> @location(0) vec4 { + return textureSample(t_input, s_input, uv) * parameters; +} +"#; + + #[test] + fn replacing_draw_queues_reuses_effect_parameter_storage() { + let mut renderer = match block_on(Renderer::try_new_headless((16, 16), 1.0)) { + Ok(renderer) => renderer, + Err(RendererCreationError::AdapterNotAvailable(_)) => { + println!("Skipping test: no suitable GPU adapter available."); + return; + } + Err(error) => panic!("renderer creation failed: {error}"), + }; + renderer.load_effect(7, &[PARAMETER_EFFECT]).unwrap(); + renderer.load_shape( + Shape::rect([(0.0, 0.0), (16.0, 16.0)], Stroke::default()), + 8, + Some(8), + ); + let node = renderer + .add_cached_shape_to_the_render_queue(8, None, ShapeDrawCommandOptions::new()) + .unwrap(); + renderer.set_group_effect(node, 7, &[0; 16]).unwrap(); + let bytes = renderer.group_effects[&node].params.as_ptr(); + let buffer = renderer.group_effects[&node].params_buffer.clone().unwrap(); + for _ in 0..100 { + renderer.clear_draw_queue(); + let node = renderer + .add_cached_shape_to_the_render_queue(8, None, ShapeDrawCommandOptions::new()) + .unwrap(); + renderer.set_group_effect(node, 7, &[1; 16]).unwrap(); + assert_eq!(renderer.group_effects[&node].params.as_ptr(), bytes); + assert_eq!( + renderer.group_effects[&node].params_buffer.as_ref(), + Some(&buffer) + ); + } + } #[test] fn validate_effect_params_rejects_missing_required_params() { diff --git a/src/renderer/metrics.rs b/src/renderer/metrics.rs index 4b2663d..a11bcda 100644 --- a/src/renderer/metrics.rs +++ b/src/renderer/metrics.rs @@ -58,7 +58,7 @@ impl PipelineSwitchCounts { /// Available when the `render_metrics` feature is enabled. #[derive(Debug, Clone, Copy, Default)] pub struct PhaseTimings { - /// Time spent in `prepare_render()` — CPU-side buffer aggregation and GPU upload. + /// CPU preparation time. The headless path also includes buffer uploads here. pub prepare: Duration, /// Time spent encoding GPU commands and submitting them (`render_to_texture_view` + `queue.submit`). pub encode_and_submit: Duration, diff --git a/src/renderer/passes.rs b/src/renderer/passes.rs index 3bf834c..abea497 100644 --- a/src/renderer/passes.rs +++ b/src/renderer/passes.rs @@ -202,7 +202,6 @@ fn bind_shape_texture_layers( shape_texture_bind_group_layout_background: &wgpu::BindGroupLayout, shape_texture_bind_group_layout_foreground: &wgpu::BindGroupLayout, default_shape_texture_bind_groups: &[Arc; 2], - shape_texture_layout_epoch: u64, bound_texture_state: &mut BoundTextureState, ) { for (layer, texture_binding) in texture_bindings.iter().enumerate() { @@ -225,7 +224,6 @@ fn bind_shape_texture_layers( } else { shape_texture_bind_group_layout_foreground }, - shape_texture_layout_epoch, *texture_id, ) { render_pass.set_bind_group(1 + layer as u32, &*bind_group, &[]); @@ -353,7 +351,6 @@ pub(super) fn handle_increment_pass<'rp>( pipelines.shape_texture_bind_group_layout_background, pipelines.shape_texture_bind_group_layout_foreground, pipelines.default_shape_texture_bind_groups, - pipelines.shape_texture_layout_epoch, bound_texture_state, ); @@ -473,7 +470,6 @@ pub(super) fn handle_leaf_draw_pass<'rp>( pipelines.shape_texture_bind_group_layout_background, pipelines.shape_texture_bind_group_layout_foreground, pipelines.default_shape_texture_bind_groups, - pipelines.shape_texture_layout_epoch, bound_texture_state, ); @@ -558,7 +554,6 @@ pub(super) fn flush_pending_leaf_batch( pipelines.shape_texture_bind_group_layout_background, pipelines.shape_texture_bind_group_layout_foreground, pipelines.default_shape_texture_bind_groups, - pipelines.shape_texture_layout_epoch, bound_texture_state, ); if let Some(instance_transform_buffer) = buffers.aggregated_instance_transform_buffer { @@ -1779,7 +1774,6 @@ pub(super) fn render_segments( pipelines.shape_texture_bind_group_layout_background, pipelines.shape_texture_bind_group_layout_foreground, pipelines.default_shape_texture_bind_groups, - pipelines.shape_texture_layout_epoch, &mut bound_texture_state, ); diff --git a/src/renderer/preparation.rs b/src/renderer/preparation.rs index 04d8421..c4cb385 100644 --- a/src/renderer/preparation.rs +++ b/src/renderer/preparation.rs @@ -3,6 +3,30 @@ use super::*; use crate::pipeline::create_buffer_init; use crate::vertex::CustomVertex; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreparationOutcome { + Ready, + Suspended, +} + +#[derive(Debug, thiserror::Error)] +pub enum RenderError { + #[error("no prepared rendering is available to commit")] + NotPrepared, + #[error("the presentation deadline passed before surface acquisition")] + DeadlineMissed, + #[error("a headless renderer cannot present to a surface")] + Headless, + #[error(transparent)] + Surface(#[from] wgpu::SurfaceError), +} + +pub(super) struct PreparedBuffers { + vertex_count: usize, + index_count: usize, + instance_count: usize, +} + #[derive(Copy, Clone)] pub(crate) struct InstanceTextureData { pub(crate) texture_presence: [bool; 2], @@ -208,19 +232,55 @@ impl<'a> Renderer<'a> { } } - pub(super) fn prepare_render(&mut self) { + /// Prepares the current draw queue without acquiring a drawable or uploading its buffers. + /// Calling this again discards the previous preparation and reuses the same storage. + pub fn prepare(&mut self) -> PreparationOutcome { + #[cfg(feature = "render_metrics")] + let started_at = std::time::Instant::now(); + self.discard_preparation(); + if self.physical_size.0 == 0 || self.physical_size.1 == 0 { + return PreparationOutcome::Suspended; + } self.begin_frame_scratch(); - // Include prepared effect leaves in this upload without making them part - // of the durable user draw queue. - let base_vertex_count = self.temp_vertices.len(); - let base_index_count = self.temp_indices.len(); - let base_instance_count = self.temp_instance_transforms.len(); + self.prepared_buffers = Some(PreparedBuffers { + vertex_count: self.temp_vertices.len(), + index_count: self.temp_indices.len(), + instance_count: self.temp_instance_transforms.len(), + }); self.prepare_shape_effect_leaves(); + #[cfg(feature = "render_metrics")] + { + self.preparation_cpu_time = started_at.elapsed(); + } + PreparationOutcome::Ready + } + + /// Cancels preparation without acquiring, submitting, or presenting a surface image. + pub fn discard_preparation(&mut self) { + if let Some(prepared) = self.prepared_buffers.take() { + self.restore_draw_buffers(prepared); + self.scratch_mut().shape_effect_leaves.clear(); + } + } + + pub(super) fn upload_prepared_buffers(&mut self) -> Result<(), RenderError> { + let prepared = self + .prepared_buffers + .take() + .ok_or(RenderError::NotPrepared)?; + self.upload_effect_params(); self.upload_buffers_for_frame(); - self.temp_vertices.truncate(base_vertex_count); - self.temp_indices.truncate(base_index_count); - self.temp_instance_transforms.truncate(base_instance_count); - self.temp_instance_colors.truncate(base_instance_count); - self.temp_instance_metadata.truncate(base_instance_count); + self.restore_draw_buffers(prepared); + Ok(()) + } + + fn restore_draw_buffers(&mut self, prepared: PreparedBuffers) { + self.temp_vertices.truncate(prepared.vertex_count); + self.temp_indices.truncate(prepared.index_count); + self.temp_instance_transforms + .truncate(prepared.instance_count); + self.temp_instance_colors.truncate(prepared.instance_count); + self.temp_instance_metadata + .truncate(prepared.instance_count); } } diff --git a/src/renderer/readback.rs b/src/renderer/readback.rs index 50c641b..ec98c2a 100644 --- a/src/renderer/readback.rs +++ b/src/renderer/readback.rs @@ -28,7 +28,7 @@ fn copy_padded_readback_rows( } impl<'a> Renderer<'a> { - fn map_readback_buffer_into( + pub(super) fn map_readback_buffer_into( device: &wgpu::Device, buffer: &wgpu::Buffer, mapped_bytes: &mut Vec, @@ -68,7 +68,13 @@ impl<'a> Renderer<'a> { #[cfg(feature = "render_metrics")] let frame_render_loop_started_at = std::time::Instant::now(); - self.prepare_render(); + if self.prepare() == PreparationOutcome::Suspended { + return; + } + if let Err(error) = self.upload_prepared_buffers() { + warn!("Cannot render headless image: {error}"); + return; + } #[cfg(feature = "render_metrics")] let after_prepare = std::time::Instant::now(); @@ -96,7 +102,7 @@ impl<'a> Renderer<'a> { .create_view(&wgpu::TextureViewDescriptor::default()); let output_texture = self.rtb_offscreen_texture.take(); - self.render_to_texture_view(&texture_view, output_texture.as_ref()); + self.render_to_texture_view(&texture_view, output_texture.as_ref(), None); self.rtb_offscreen_texture = output_texture; let (unpadded_bytes_per_row, padded_bytes_per_row) = compute_padded_bytes_per_row(width, 4); @@ -138,11 +144,20 @@ impl<'a> Renderer<'a> { #[cfg(feature = "render_metrics")] let after_submit = std::time::Instant::now(); - let mut readback_bytes = std::mem::take(&mut self.scratch.readback_bytes); + let mut readback_bytes = self + .scratch + .as_mut() + .expect("scratch is not owned by rendering") + .readback_bytes + .take() + .expect("readback buffer is not in use"); Self::map_readback_buffer_into(&self.device, output_buffer, &mut readback_bytes); let required_readback_len = (height as usize).saturating_mul(padded_bytes_per_row as usize); if readback_bytes.is_empty() || readback_bytes.len() < required_readback_len { - self.scratch.readback_bytes = readback_bytes; + self.scratch + .as_mut() + .expect("scratch is not owned by rendering") + .readback_bytes = Some(readback_bytes); return; } copy_padded_readback_rows( @@ -153,7 +168,10 @@ impl<'a> Renderer<'a> { buffer, ); - self.scratch.readback_bytes = readback_bytes; + self.scratch + .as_mut() + .expect("scratch is not owned by rendering") + .readback_bytes = Some(readback_bytes); #[cfg(feature = "render_metrics")] { @@ -179,7 +197,13 @@ impl<'a> Renderer<'a> { #[cfg(feature = "render_metrics")] let frame_render_loop_started_at = std::time::Instant::now(); - self.prepare_render(); + if self.prepare() == PreparationOutcome::Suspended { + return; + } + if let Err(error) = self.upload_prepared_buffers() { + warn!("Cannot render headless image: {error}"); + return; + } #[cfg(feature = "render_metrics")] let after_prepare = std::time::Instant::now(); @@ -216,7 +240,7 @@ impl<'a> Renderer<'a> { .create_view(&wgpu::TextureViewDescriptor::default()); let output_texture = self.argb_offscreen_texture.take(); - self.render_to_texture_view(&texture_view, output_texture.as_ref()); + self.render_to_texture_view(&texture_view, output_texture.as_ref(), None); self.argb_offscreen_texture = output_texture; let (_, padded_bytes_per_row) = compute_padded_bytes_per_row(width, 4); @@ -338,20 +362,32 @@ impl<'a> Renderer<'a> { #[cfg(feature = "render_metrics")] let after_submit = std::time::Instant::now(); - let mut readback_bytes = std::mem::take(&mut self.scratch.readback_bytes); + let mut readback_bytes = self + .scratch + .as_mut() + .expect("scratch is not owned by rendering") + .readback_bytes + .take() + .expect("readback buffer is not in use"); Self::map_readback_buffer_into( &self.device, self.argb_readback_buffer.as_ref().unwrap(), &mut readback_bytes, ); if readback_bytes.is_empty() { - self.scratch.readback_bytes = readback_bytes; + self.scratch + .as_mut() + .expect("scratch is not owned by rendering") + .readback_bytes = Some(readback_bytes); return; } let src_words: &[u32] = bytemuck::cast_slice(&readback_bytes); out_pixels[..needed_len].copy_from_slice(&src_words[..needed_len]); - self.scratch.readback_bytes = readback_bytes; + self.scratch + .as_mut() + .expect("scratch is not owned by rendering") + .readback_bytes = Some(readback_bytes); #[cfg(feature = "render_metrics")] { diff --git a/src/renderer/rendering.rs b/src/renderer/rendering.rs index 9a30278..90bbccb 100644 --- a/src/renderer/rendering.rs +++ b/src/renderer/rendering.rs @@ -7,16 +7,27 @@ use crate::renderer::traversal::{ }; impl<'a> Renderer<'a> { + /// Waits for this renderer's known submission when a caller needs CPU-side completion. + /// Queue-ordered GPU resource reuse does not require this wait. + pub fn wait_for_submitted_work(&mut self) -> Result<(), wgpu::PollError> { + if let Some(submission) = self.submitted_work.take() { + self.device + .poll(wgpu::PollType::WaitForSubmissionIndex(submission))?; + } + Ok(()) + } + pub(super) fn render_to_texture_view( &mut self, texture_view: &wgpu::TextureView, output_texture: Option<&wgpu::Texture>, - ) { + deadline: Option, + ) -> bool { let render_to_texture_view_started_at = std::time::Instant::now(); // Nothing to render when the draw queue is empty. if self.draw_tree.is_empty() { - self.scratch.shape_effect_leaves.clear(); + self.scratch_mut().shape_effect_leaves.clear(); let _collected_shape_effect_results = self.shape_effect_cache.end_frame(); let _collected_shape_effect_masks = self.shape_effect_mask_cache.end_frame(); #[cfg(feature = "render_metrics")] @@ -29,20 +40,27 @@ impl<'a> Renderer<'a> { } self.buffers_pool_manager.tessellation_cache.end_frame(); self.last_render_to_texture_view_cpu_time = render_to_texture_view_started_at.elapsed(); - return; + return true; } - let mut traversal_scratch = std::mem::take(&mut self.scratch.traversal_scratch); - let mut effect_results = std::mem::take(&mut self.scratch.effect_results); - let mut shape_effect_leaves = std::mem::take(&mut self.scratch.shape_effect_leaves); - let mut effect_node_ids = std::mem::take(&mut self.scratch.effect_node_ids); - let mut textures_to_recycle = std::mem::take(&mut self.scratch.textures_to_recycle); - let mut effect_output_textures = std::mem::take(&mut self.scratch.effect_output_textures); - let mut stencil_stack = std::mem::take(&mut self.scratch.stencil_stack); - let skipped_stack = std::mem::take(&mut self.scratch.skipped_stack); - let mut scissor_stack = std::mem::take(&mut self.scratch.scissor_stack); - let mut clip_kind_stack = std::mem::take(&mut self.scratch.clip_kind_stack); - let mut backdrop_work_textures = std::mem::take(&mut self.scratch.backdrop_work_textures); + let RendererScratch { + mut traversal_scratch, + mut effect_results, + mut shape_effect_leaves, + mut effect_node_ids, + mut textures_to_recycle, + mut effect_output_textures, + mut stencil_stack, + skipped_stack, + mut scissor_stack, + mut clip_kind_stack, + mut backdrop_work_textures, + mut encoded_texture_uploads, + readback_bytes, + } = self + .scratch + .take() + .expect("rendering owns the reusable scratch storage"); let has_group_effects = !self.group_effects.is_empty(); let has_backdrop_effects = !self.backdrop_effects.is_empty(); @@ -78,6 +96,9 @@ impl<'a> Renderer<'a> { label: Some("Render Command Encoder"), }); + self.texture_manager + .encode_uploads(&mut encoder, &mut encoded_texture_uploads); + if has_shape_effects { self.resolve_shape_effects( &mut encoder, @@ -101,7 +122,6 @@ impl<'a> Renderer<'a> { shape_texture_bind_group_layout_foreground: &self .shape_texture_bind_group_layout_foreground, default_shape_texture_bind_groups: &self.default_shape_texture_bind_groups, - shape_texture_layout_epoch: self.shape_texture_layout_epoch, texture_manager: &self.texture_manager, }; @@ -473,7 +493,17 @@ impl<'a> Renderer<'a> { ); } - self.queue.submit(std::iter::once(encoder.finish())); + let command_buffer = encoder.finish(); + let submitted = !deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline); + if submitted { + self.submitted_work = Some(self.queue.submit(std::iter::once(command_buffer))); + } else { + self.shape_effect_cache.retain(|_, _| false); + self.shape_effect_mask_cache.retain(|_, _| false); + self.texture_manager + .restore_encoded_uploads(&encoded_texture_uploads); + } + encoded_texture_uploads.clear(); self.last_render_to_texture_view_cpu_time = render_to_texture_view_started_at.elapsed(); @@ -490,17 +520,21 @@ impl<'a> Renderer<'a> { shape_effect_leaves.clear(); - self.scratch.traversal_scratch = traversal_scratch; - self.scratch.effect_results = effect_results; - self.scratch.shape_effect_leaves = shape_effect_leaves; - self.scratch.effect_node_ids = effect_node_ids; - self.scratch.textures_to_recycle = textures_to_recycle; - self.scratch.effect_output_textures = effect_output_textures; - self.scratch.stencil_stack = stencil_stack; - self.scratch.skipped_stack = skipped_stack; - self.scratch.scissor_stack = scissor_stack; - self.scratch.clip_kind_stack = clip_kind_stack; - self.scratch.backdrop_work_textures = backdrop_work_textures; + self.scratch = Some(RendererScratch { + traversal_scratch, + effect_results, + shape_effect_leaves, + effect_node_ids, + textures_to_recycle, + effect_output_textures, + stencil_stack, + skipped_stack, + scissor_stack, + clip_kind_stack, + backdrop_work_textures, + encoded_texture_uploads, + readback_bytes, + }); let _collected_shape_effect_results = self.shape_effect_cache.end_frame(); let _collected_shape_effect_masks = self.shape_effect_mask_cache.end_frame(); self.buffers_pool_manager.tessellation_cache.end_frame(); @@ -514,30 +548,25 @@ impl<'a> Renderer<'a> { self.last_pipeline_switch_counts = frame_pipeline_counts; self.last_shape_effect_cache_metrics = shape_effect_cache_metrics; } + submitted } - pub fn render(&mut self) -> Result<(), wgpu::SurfaceError> { - #[cfg(feature = "render_metrics")] - let frame_render_loop_started_at = std::time::Instant::now(); - self.prepare_render(); - - #[cfg(feature = "render_metrics")] - let after_prepare = std::time::Instant::now(); - - let surface = self - .surface - .as_ref() - .expect("Cannot call render() on a headless renderer; use render_to_buffer()"); - let output = surface.get_current_texture()?; - let output_texture_view = output - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); - - self.render_to_texture_view(&output_texture_view, Some(&output.texture)); + /// Consumes preparation, then acquires, submits, and presents the selected image. + /// An expired deadline cancels only before acquisition. Acquired images must be presented, + /// even if acquisition or encoding overruns, because Vulkan cannot safely discard them. + /// The hook runs immediately before presentation, for platform pre-present notification. + pub fn commit(&mut self, deadline: Option) -> Result<(), RenderError> { + let submission_started_at = std::time::Instant::now(); + let output = self.acquire_and_submit(deadline); + self.last_submission_duration = submission_started_at.elapsed(); + let output = output?; #[cfg(feature = "render_metrics")] let after_submit = std::time::Instant::now(); + if let Some(callback) = &self.pre_present_callback { + callback(); + } output.present(); #[cfg(feature = "render_metrics")] { @@ -546,11 +575,12 @@ impl<'a> Renderer<'a> { let _ = self.device.poll(wgpu::MaintainBase::Wait); let after_gpu_wait = std::time::Instant::now(); - let prepare_dur = after_prepare.saturating_duration_since(frame_render_loop_started_at); - let encode_submit_dur = after_submit.saturating_duration_since(after_prepare); + let prepare_dur = self.preparation_cpu_time; + let encode_submit_dur = self.last_submission_duration; let present_dur = after_present.saturating_duration_since(after_submit); let gpu_wait_dur = after_gpu_wait.saturating_duration_since(after_present); - let total_dur = after_gpu_wait.saturating_duration_since(frame_render_loop_started_at); + let total_dur = + prepare_dur + after_gpu_wait.saturating_duration_since(submission_started_at); self.last_phase_timings = PhaseTimings { prepare: prepare_dur, encode_and_submit: encode_submit_dur, @@ -559,8 +589,255 @@ impl<'a> Renderer<'a> { total: total_dur, }; self.render_loop_metrics_tracker - .record_presented_frame(frame_render_loop_started_at, after_gpu_wait); + .record_presented_frame(submission_started_at, after_gpu_wait); + } + Ok(()) + } + + fn acquire_and_submit( + &mut self, + deadline: Option, + ) -> Result { + if self.prepared_buffers.is_none() { + return Err(RenderError::NotPrepared); + } + if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { + self.discard_preparation(); + return Err(RenderError::DeadlineMissed); + } + + let acquisition_started_at = std::time::Instant::now(); + let output = match self.surface.as_ref() { + None => { + self.discard_preparation(); + return Err(RenderError::Headless); + } + Some(surface) => match surface.get_current_texture() { + Ok(output) => output, + Err(error) => { + self.discard_preparation(); + return Err(error.into()); + } + }, + }; + tracing::debug!( + acquisition_duration = ?acquisition_started_at.elapsed(), + "Surface drawable acquired" + ); + self.submit_acquired_texture(&output.texture, deadline)?; + Ok(output) + } + + fn submit_acquired_texture( + &mut self, + output: &wgpu::Texture, + deadline: Option, + ) -> Result<(), RenderError> { + self.upload_prepared_buffers()?; + let output_texture_view = output.create_view(&wgpu::TextureViewDescriptor::default()); + + // wgpu 25's Vulkan discard does not release an acquired swapchain image. + self.render_to_texture_view(&output_texture_view, Some(output), None); + if let Some(deadline) = deadline { + tracing::debug!( + deadline_overrun = ?std::time::Instant::now().saturating_duration_since(deadline), + "Acquired surface drawable submitted" + ); } + Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::pipeline::{create_readback_buffer, encode_copy_texture_to_buffer}; + use crate::{ + Renderer, RendererCreationError, Shape, ShapeDrawCommandOptions, ShapeTextureOptions, + Stroke, + }; + use futures::executor::block_on; + use std::time::{Duration, Instant}; + + #[test] + fn queued_resource_reuse_preserves_earlier_images_without_cpu_completion_waits() { + let mut first = match block_on(Renderer::try_new_headless((16, 16), 1.0)) { + Ok(renderer) => renderer, + Err(RendererCreationError::AdapterNotAvailable(_)) => { + println!("Skipping test: no suitable GPU adapter available."); + return; + } + Err(error) => panic!("renderer creation failed: {error}"), + }; + let mut second = Renderer::try_new_headless_with_context( + first.context().isolated_resources(), + (16, 16), + 1.0, + ) + .unwrap(); + let output = first.device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 16, + height: 16, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: first.config.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let mut captures = Vec::new(); + for _ in 0..16 { + captures.push(( + submit_textured_capture(&mut first, &output, [255, 0, 0, 255]), + [0, 0, 255, 255], + )); + captures.push(( + submit_textured_capture(&mut second, &output, [0, 255, 0, 255]), + [0, 255, 0, 255], + )); + captures.push(( + submit_textured_capture(&mut first, &output, [0, 0, 255, 255]), + [255, 0, 0, 255], + )); + } + // All writes, draws and captures are submitted before the first CPU wait. + let mut bytes = Vec::new(); + for (buffer, expected) in captures { + Renderer::map_readback_buffer_into(&first.device, &buffer, &mut bytes); + let center = 8 * 256 + 8 * 4; + assert_eq!(&bytes[center..center + 4], &expected); + } + } + + fn submit_textured_capture( + renderer: &mut Renderer<'_>, + output: &wgpu::Texture, + pixels: [u8; 4], + ) -> wgpu::Buffer { + prepare_textured_scene(renderer, pixels); + renderer.upload_prepared_buffers().unwrap(); + let view = output.create_view(&wgpu::TextureViewDescriptor::default()); + assert!(renderer.render_to_texture_view(&view, Some(output), None)); + capture_texture(renderer, output) + } + + fn prepare_textured_scene(renderer: &mut Renderer<'_>, pixels: [u8; 4]) { + renderer.clear_draw_queue(); + renderer + .texture_manager() + .allocate_texture_with_data(7, (1, 1), &pixels); + renderer + .add_shape( + Shape::rect([(0.0, 0.0), (16.0, 16.0)], Stroke::default()), + None, + None, + ShapeDrawCommandOptions::new().background_texture(ShapeTextureOptions::new(7)), + ) + .unwrap(); + renderer.prepare(); + } + + fn capture_texture(renderer: &Renderer<'_>, output: &wgpu::Texture) -> wgpu::Buffer { + let buffer = create_readback_buffer(&renderer.device, None, 256 * 16); + let mut encoder = renderer + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + encode_copy_texture_to_buffer(&mut encoder, output, &buffer, 16, 16, 256); + renderer.queue.submit([encoder.finish()]); + buffer + } + + #[test] + fn acquired_texture_is_submitted_even_after_the_deadline() { + let mut renderer = match block_on(Renderer::try_new_headless((16, 16), 1.0)) { + Ok(renderer) => renderer, + Err(RendererCreationError::AdapterNotAvailable(_)) => { + println!("Skipping test: no suitable GPU adapter available."); + return; + } + Err(error) => panic!("renderer creation failed: {error}"), + }; + let output = renderer.device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 16, + height: 16, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: renderer.config.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + prepare_textured_scene(&mut renderer, [255, 0, 0, 255]); + renderer + .submit_acquired_texture(&output, Some(Instant::now())) + .unwrap(); + assert!(renderer.submitted_work.is_some()); + let buffer = capture_texture(&renderer, &output); + let mut bytes = Vec::new(); + Renderer::map_readback_buffer_into(&renderer.device, &buffer, &mut bytes); + let center = 8 * 256 + 8 * 4; + assert_eq!(&bytes[center..center + 4], &[0, 0, 255, 255]); + } + + #[test] + fn missed_submission_restores_staged_texture_uploads_for_the_next_scene() { + let mut renderer = match block_on(Renderer::try_new_headless((16, 16), 1.0)) { + Ok(renderer) => renderer, + Err(RendererCreationError::AdapterNotAvailable(_)) => { + println!("Skipping test: no suitable GPU adapter available."); + return; + } + Err(error) => panic!("renderer creation failed: {error}"), + }; + renderer + .texture_manager() + .allocate_texture_with_data(7, (1, 1), &[255, 0, 0, 255]); + renderer + .add_shape( + Shape::rect([(0.0, 0.0), (16.0, 16.0)], Stroke::default()), + None, + None, + ShapeDrawCommandOptions::new().background_texture(ShapeTextureOptions::new(7)), + ) + .unwrap(); + renderer.prepare(); + renderer.upload_prepared_buffers().unwrap(); + let output = renderer.device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 16, + height: 16, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: renderer.config.format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = output.create_view(&wgpu::TextureViewDescriptor::default()); + assert!(!renderer.render_to_texture_view(&view, Some(&output), Some(Instant::now()))); + assert!(renderer.submitted_work.is_none()); + renderer.prepare(); + renderer.last_submission_duration = Duration::MAX; + assert!(matches!( + renderer.commit(Some(Instant::now())), + Err(crate::RenderError::DeadlineMissed) + )); + assert_ne!(renderer.last_submission_duration(), Duration::MAX); + assert!(renderer.prepared_buffers.is_none()); + let mut pixels = Vec::new(); + renderer.render_to_buffer(&mut pixels); + let center = (8 * 16 + 8) * 4; + assert_eq!(&pixels[center..center + 4], &[0, 0, 255, 255]); + } +} diff --git a/src/renderer/shape_effects.rs b/src/renderer/shape_effects.rs index cea0760..95d1ad7 100644 --- a/src/renderer/shape_effects.rs +++ b/src/renderer/shape_effects.rs @@ -494,7 +494,11 @@ impl<'a> Renderer<'a> { texture_uv_scales: [[1.0, 1.0]; 2], }, )); - self.scratch.shape_effect_leaves.insert(node_id, leaf); + self.scratch + .as_mut() + .expect("scratch is not owned by rendering") + .shape_effect_leaves + .insert(node_id, leaf); } } diff --git a/src/renderer/surface.rs b/src/renderer/surface.rs index dbd00d6..b01bc47 100644 --- a/src/renderer/surface.rs +++ b/src/renderer/surface.rs @@ -1,6 +1,41 @@ use super::*; +use std::num::NonZeroU32; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SampleCountChange { + Unchanged, + Changed(u32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PresentModeChange { + Unchanged, + Changed(wgpu::PresentMode), +} impl<'a> Renderer<'a> { + /// Sets wgpu's presentation queue-latency hint. Lower values can serialize work on some backends. + pub fn set_maximum_frame_latency(&mut self, latency: NonZeroU32) { + if self.config.desired_maximum_frame_latency == latency.get() { + return; + } + self.discard_preparation(); + self.config.desired_maximum_frame_latency = latency.get(); + if let Some(surface) = &self.surface { + surface.configure(&self.device, &self.config); + } + } + + pub fn maximum_frame_latency(&self) -> u32 { + self.config.desired_maximum_frame_latency + } + + /// Installs the platform notification once when creating or replacing a surface. + pub fn set_pre_present_callback(&mut self, callback: impl Fn() + Send + Sync + 'a) { + self.discard_preparation(); + self.pre_present_callback = Some(Box::new(callback)); + } + /// Returns the shared GPU context used by this renderer. pub fn context(&self) -> &RendererContext { &self.context @@ -11,6 +46,7 @@ impl<'a> Renderer<'a> { } pub fn change_scale_factor(&mut self, new_scale_factor: f64) { + self.discard_preparation(); self.scale_factor = new_scale_factor; self.resize(self.physical_size) } @@ -20,6 +56,7 @@ impl<'a> Renderer<'a> { } pub fn set_fringe_width(&mut self, fringe_width: f32) { + self.discard_preparation(); self.fringe_width = fringe_width; self.resize(self.physical_size); } @@ -29,7 +66,11 @@ impl<'a> Renderer<'a> { } pub fn resize(&mut self, new_physical_size: (u32, u32)) { + self.discard_preparation(); self.physical_size = new_physical_size; + if new_physical_size.0 == 0 || new_physical_size.1 == 0 { + return; + } self.config.width = new_physical_size.0; self.config.height = new_physical_size.1; @@ -64,7 +105,6 @@ impl<'a> Renderer<'a> { new_physical_size.1, self.msaa_sample_count, ); - self.trim_scratch_on_resize_or_policy(); } pub fn msaa_samples(&self) -> u32 { @@ -72,11 +112,13 @@ impl<'a> Renderer<'a> { } pub fn set_msaa_samples(&mut self, samples: u32) { - let validated = Self::validate_sample_count_static(samples); - if validated == self.msaa_sample_count { + let SampleCountChange::Changed(validated) = + Self::sample_count_change(self.msaa_sample_count, samples) + else { return; - } + }; + self.discard_preparation(); self.msaa_sample_count = validated; self.recreate_pipelines(); self.recreate_msaa_texture(); @@ -97,7 +139,19 @@ impl<'a> Renderer<'a> { } } + fn sample_count_change(current: u32, requested: u32) -> SampleCountChange { + let validated = Self::validate_sample_count_static(requested); + if validated == current { + SampleCountChange::Unchanged + } else { + SampleCountChange::Changed(validated) + } + } + pub(super) fn recreate_msaa_texture(&mut self) { + if self.physical_size.0 == 0 || self.physical_size.1 == 0 { + return; + } if self.msaa_sample_count > 1 { let texture = create_msaa_color_texture( &self.device, @@ -123,11 +177,13 @@ impl<'a> Renderer<'a> { self.physical_size.1, self.msaa_sample_count, ); - self.trim_scratch_on_resize_or_policy(); } /// Recreate the cached depth/stencil texture to match current physical size and MSAA settings. pub(super) fn recreate_depth_stencil_texture(&mut self) { + if self.physical_size.0 == 0 || self.physical_size.1 == 0 { + return; + } let texture = create_and_depth_texture(&self.device, self.physical_size, self.msaa_sample_count); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); @@ -136,6 +192,7 @@ impl<'a> Renderer<'a> { } pub fn set_surface(&mut self, window: impl Into>) { + self.discard_preparation(); let surface = self .instance .create_surface(window) @@ -145,13 +202,71 @@ impl<'a> Renderer<'a> { } pub fn set_vsync(&mut self, vsync: bool) { - self.config.present_mode = if vsync { + let PresentModeChange::Changed(present_mode) = + Self::present_mode_change(self.config.present_mode, vsync) + else { + return; + }; + self.discard_preparation(); + self.config.present_mode = present_mode; + if let Some(surface) = &self.surface { + surface.configure(&self.device, &self.config); + } + tracing::debug!( + vsync, + present_mode = ?self.config.present_mode, + maximum_frame_latency = self.config.desired_maximum_frame_latency, + "Surface presentation mode configured" + ); + } + + fn present_mode_change(current: wgpu::PresentMode, vsync: bool) -> PresentModeChange { + let requested = if vsync { wgpu::PresentMode::AutoVsync } else { wgpu::PresentMode::AutoNoVsync }; - if let Some(surface) = &self.surface { - surface.configure(&self.device, &self.config); + if requested == current { + PresentModeChange::Unchanged + } else { + PresentModeChange::Changed(requested) } } } + +#[cfg(test)] +mod tests { + use super::{PresentModeChange, Renderer, SampleCountChange}; + + #[test] + fn equivalent_msaa_requests_do_not_require_renderer_mutation() { + assert_eq!( + Renderer::sample_count_change(1, 0), + SampleCountChange::Unchanged + ); + assert_eq!( + Renderer::sample_count_change(4, 2), + SampleCountChange::Unchanged + ); + assert_eq!( + Renderer::sample_count_change(1, 4), + SampleCountChange::Changed(4) + ); + } + + #[test] + fn equivalent_vsync_requests_do_not_require_renderer_mutation() { + assert_eq!( + Renderer::present_mode_change(wgpu::PresentMode::AutoVsync, true), + PresentModeChange::Unchanged + ); + assert_eq!( + Renderer::present_mode_change(wgpu::PresentMode::AutoNoVsync, false), + PresentModeChange::Unchanged + ); + assert_eq!( + Renderer::present_mode_change(wgpu::PresentMode::AutoVsync, false), + PresentModeChange::Changed(wgpu::PresentMode::AutoNoVsync) + ); + } +} diff --git a/src/renderer/traversal.rs b/src/renderer/traversal.rs index 5264cef..13ece29 100644 --- a/src/renderer/traversal.rs +++ b/src/renderer/traversal.rs @@ -1,9 +1,6 @@ -use super::types::{trim_vector_if_needed, TraversalEvent}; +use super::types::TraversalEvent; use super::*; -const MAX_TRAVERSAL_EVENTS_CAPACITY: usize = 32_768; -const MAX_TRAVERSAL_STACK_CAPACITY: usize = 16_384; - #[derive(Default)] pub(super) struct TraversalScratch { events: Vec, @@ -22,11 +19,6 @@ impl TraversalScratch { self.excluded_depth = 0; } - pub(super) fn trim_to_policy(&mut self) { - trim_vector_if_needed(&mut self.events, MAX_TRAVERSAL_EVENTS_CAPACITY); - trim_vector_if_needed(&mut self.skipped_stack, MAX_TRAVERSAL_STACK_CAPACITY); - } - pub(super) fn events(&self) -> &[TraversalEvent] { &self.events } diff --git a/src/renderer/types.rs b/src/renderer/types.rs index bbc158f..00dbd85 100644 --- a/src/renderer/types.rs +++ b/src/renderer/types.rs @@ -3,7 +3,7 @@ use super::metrics::PipelineSwitchCounts; use super::traversal::TraversalScratch; use crate::effect::{self, LoadedEffect}; use crate::shape::{CachedShapeDrawData, DrawShapeCommand, ShapeTextureBinding}; -use crate::texture_manager::TextureManager; +use crate::texture_manager::{EncodedTextureUpload, TextureManager}; use crate::util::GradientCache; use crate::vertex::InstanceTransform; use ahash::{HashMap, HashMapExt}; @@ -115,7 +115,6 @@ impl DrawCommand { queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout, sampler: &wgpu::Sampler, - layout_epoch: u64, ) { match self { DrawCommand::ClipRect(_) => {} @@ -125,7 +124,6 @@ impl DrawCommand { queue, layout, sampler, - layout_epoch, ), } } @@ -317,7 +315,6 @@ pub(super) struct Pipelines<'a> { pub(super) shape_texture_bind_group_layout_background: &'a wgpu::BindGroupLayout, pub(super) shape_texture_bind_group_layout_foreground: &'a wgpu::BindGroupLayout, pub(super) default_shape_texture_bind_groups: &'a [Arc; 2], - pub(super) shape_texture_layout_epoch: u64, pub(super) texture_manager: &'a TextureManager, } @@ -372,16 +369,6 @@ pub(super) struct BackdropContext<'a> { pub(super) backdrop_gradient_bind_group_layout: &'a wgpu::BindGroupLayout, } -const MAX_EFFECT_RESULTS_CAPACITY: usize = 4_096; -const MAX_SHAPE_EFFECT_LEAVES_CAPACITY: usize = 4_096; -const MAX_EFFECT_NODE_IDS_CAPACITY: usize = 4_096; -const MAX_TEXTURE_RECYCLE_CAPACITY: usize = 1_024; -const MAX_EFFECT_OUTPUT_TEXTURES_CAPACITY: usize = 2_048; -const MAX_STENCIL_STACK_CAPACITY: usize = 16_384; -const MAX_SKIPPED_STACK_CAPACITY: usize = 16_384; -const MAX_SCISSOR_STACK_CAPACITY: usize = 16_384; -const MAX_READBACK_BYTES_CAPACITY: usize = 64 * 1024 * 1024; - pub(super) struct RendererScratch { pub(super) effect_results: HashMap, pub(super) shape_effect_leaves: HashMap, @@ -397,9 +384,10 @@ pub(super) struct RendererScratch { /// non-leaf parent used so the `Post` path avoids re-evaluating eligibility. pub(super) clip_kind_stack: Vec, pub(super) backdrop_work_textures: Vec, + pub(super) encoded_texture_uploads: Vec, /// Reused across readback calls; intentionally not cleared on `begin_frame` /// because readback may run after render submission and reuse prior capacity. - pub(super) readback_bytes: Vec, + pub(super) readback_bytes: Option>, pub(super) traversal_scratch: TraversalScratch, } @@ -416,7 +404,8 @@ impl RendererScratch { scissor_stack: Vec::new(), clip_kind_stack: Vec::new(), backdrop_work_textures: Vec::new(), - readback_bytes: Vec::new(), + encoded_texture_uploads: Vec::new(), + readback_bytes: Some(Vec::new()), traversal_scratch: TraversalScratch::new(), } } @@ -432,52 +421,11 @@ impl RendererScratch { self.scissor_stack.clear(); self.clip_kind_stack.clear(); self.backdrop_work_textures.clear(); + self.encoded_texture_uploads.clear(); self.traversal_scratch.begin(); // Keep readback bytes length/capacity untouched to preserve reuse across // `render_to_buffer`/`render_to_argb32` calls that are not tied to frame start. } - - pub(super) fn trim_to_policy(&mut self) { - trim_hash_map_if_needed(&mut self.effect_results, MAX_EFFECT_RESULTS_CAPACITY); - trim_hash_map_if_needed( - &mut self.shape_effect_leaves, - MAX_SHAPE_EFFECT_LEAVES_CAPACITY, - ); - trim_vector_if_needed(&mut self.effect_node_ids, MAX_EFFECT_NODE_IDS_CAPACITY); - trim_vector_if_needed(&mut self.textures_to_recycle, MAX_TEXTURE_RECYCLE_CAPACITY); - trim_vector_if_needed( - &mut self.effect_output_textures, - MAX_EFFECT_OUTPUT_TEXTURES_CAPACITY, - ); - trim_vector_if_needed(&mut self.stencil_stack, MAX_STENCIL_STACK_CAPACITY); - trim_vector_if_needed(&mut self.skipped_stack, MAX_SKIPPED_STACK_CAPACITY); - trim_vector_if_needed(&mut self.scissor_stack, MAX_SCISSOR_STACK_CAPACITY); - trim_vector_if_needed(&mut self.clip_kind_stack, MAX_SCISSOR_STACK_CAPACITY); - trim_vector_if_needed( - &mut self.backdrop_work_textures, - MAX_EFFECT_OUTPUT_TEXTURES_CAPACITY, - ); - if self.readback_bytes.len() > MAX_READBACK_BYTES_CAPACITY { - self.readback_bytes.truncate(MAX_READBACK_BYTES_CAPACITY); - } - trim_vector_if_needed(&mut self.readback_bytes, MAX_READBACK_BYTES_CAPACITY); - self.traversal_scratch.trim_to_policy(); - } -} - -pub(super) fn trim_vector_if_needed(values: &mut Vec, max_capacity: usize) { - if values.capacity() > max_capacity { - values.shrink_to(max_capacity); - } -} - -pub(super) fn trim_hash_map_if_needed(values: &mut HashMap, max_capacity: usize) -where - K: Eq + std::hash::Hash, -{ - if values.capacity() > max_capacity { - values.shrink_to(max_capacity); - } } #[derive(Debug, Clone, Copy)] @@ -499,10 +447,7 @@ pub(super) fn decide_buffer_sizing( #[cfg(test)] mod tests { - use super::{ - decide_buffer_sizing, RendererScratch, MAX_EFFECT_NODE_IDS_CAPACITY, - MAX_READBACK_BYTES_CAPACITY, - }; + use super::{decide_buffer_sizing, RendererScratch}; #[test] fn decide_buffer_sizing_reallocates_when_missing() { @@ -526,34 +471,41 @@ mod tests { fn renderer_scratch_begin_frame_clears_lengths() { let mut scratch = RendererScratch::new(); scratch.effect_node_ids.extend([(1, 1), (2, 2)]); - scratch.readback_bytes.extend([1, 2, 3, 4]); + scratch + .readback_bytes + .as_mut() + .unwrap() + .extend([1, 2, 3, 4]); scratch.begin_frame(); assert!(scratch.effect_node_ids.is_empty()); - assert_eq!(scratch.readback_bytes.len(), 4); + assert_eq!(scratch.readback_bytes.as_ref().unwrap().len(), 4); } #[test] - fn renderer_scratch_trims_large_capacities() { + fn renderer_scratch_retains_large_capacities() { let mut scratch = RendererScratch::new(); - scratch - .effect_node_ids - .resize(MAX_EFFECT_NODE_IDS_CAPACITY + 2_048, (0, 0)); + scratch.effect_node_ids.resize(6_144, (0, 0)); scratch.effect_node_ids.clear(); - scratch.trim_to_policy(); - assert!(scratch.effect_node_ids.capacity() <= MAX_EFFECT_NODE_IDS_CAPACITY); + let capacity = scratch.effect_node_ids.capacity(); + scratch.begin_frame(); + assert_eq!(scratch.effect_node_ids.capacity(), capacity); } #[test] - fn renderer_scratch_trims_readback_bytes_length_before_shrinking() { + fn renderer_scratch_preserves_readback_storage_on_begin() { let mut scratch = RendererScratch::new(); - scratch - .readback_bytes - .resize(MAX_READBACK_BYTES_CAPACITY + 1_024, 0); - - scratch.trim_to_policy(); + scratch.readback_bytes.as_mut().unwrap().resize(1_024, 0); - assert!(scratch.readback_bytes.len() <= MAX_READBACK_BYTES_CAPACITY); + let storage = scratch.readback_bytes.as_ref().unwrap().as_ptr(); + let capacity = scratch.readback_bytes.as_ref().unwrap().capacity(); + scratch.begin_frame(); + assert_eq!(scratch.readback_bytes.as_ref().unwrap().len(), 1_024); + assert_eq!(scratch.readback_bytes.as_ref().unwrap().as_ptr(), storage); + assert_eq!( + scratch.readback_bytes.as_ref().unwrap().capacity(), + capacity + ); } } diff --git a/src/shape.rs b/src/shape.rs index be32ce5..3f3af8c 100644 --- a/src/shape.rs +++ b/src/shape.rs @@ -618,19 +618,6 @@ impl AaFringeScratch { self.boundary_edges.clear(); self.triangle_stack.clear(); } - - pub(crate) fn trim(&mut self) { - self.edge_use_counts.shrink_to_fit(); - self.edge_owners.shrink_to_fit(); - self.incident_triangles_by_vertex.shrink_to_fit(); - self.triangle_adjacency.shrink_to_fit(); - self.visited_triangles.shrink_to_fit(); - self.triangle_component_map.shrink_to_fit(); - self.boundary_corner_normals.shrink_to_fit(); - self.outer_vertex_indices.shrink_to_fit(); - self.boundary_edges.shrink_to_fit(); - self.triangle_stack.shrink_to_fit(); - } } fn normalized_float_bits(value: f32) -> u32 { @@ -1304,7 +1291,6 @@ impl CachedShapeDrawData { queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout, sampler: &wgpu::Sampler, - layout_epoch: u64, ) { self.gradient_bind_group = match self.fill.as_mut() { Some(Fill::Gradient(gradient)) => Some(gradient_cache.get_or_create_bind_group( @@ -1313,7 +1299,6 @@ impl CachedShapeDrawData { queue, layout, sampler, - layout_epoch, )), _ => None, }; diff --git a/src/texture_manager.rs b/src/texture_manager.rs index 580932a..375f703 100644 --- a/src/texture_manager.rs +++ b/src/texture_manager.rs @@ -5,6 +5,33 @@ use std::sync::{Arc, RwLock}; pub enum TextureManagerError { #[error("Texture {0} not found")] TextureNotFound(u64), + #[error("Texture {0} upload dimensions or byte length are invalid")] + InvalidUpload(u64), +} + +fn validate_upload( + texture_id: u64, + texture_dimensions: (u32, u32), + upload_dimensions: (u32, u32), + byte_length: usize, + reset: bool, +) -> Result<(), TextureManagerError> { + let expected_byte_length = upload_dimensions + .0 + .try_into() + .ok() + .and_then(|width: usize| { + usize::try_from(upload_dimensions.1) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .and_then(|pixels| pixels.checked_mul(4)); + if upload_dimensions != texture_dimensions + || (!reset && expected_byte_length != Some(byte_length)) + { + return Err(TextureManagerError::InvalidUpload(texture_id)); + } + Ok(()) } /// A manager for textures providing granular control over texture handling. @@ -68,11 +95,35 @@ pub struct TextureManager { sampler: Arc, /// Textures is raw image data, without any screen position information texture_storage: Arc>>, - /// Cache for shape texture bind groups keyed by (texture_id, layout_epoch) + /// Each entry retains its layout until that layout is explicitly retired. shape_bind_group_cache: Arc>, + uploads: Arc>>, } -type BindGroupCache = HashMap<(u64, u64), Arc>; +struct TextureUpload { + texture_id: u64, + texture: Option, + bytes: Vec, + bytes_per_row: u32, + buffer: Option, + pending: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct EncodedTextureUpload { + index: usize, + texture_id: u64, +} + +impl EncodedTextureUpload { + fn restore_if_matches(self, index: usize, texture_id: u64, retained: bool, pending: &mut bool) { + if retained && self.index == index && self.texture_id == texture_id { + *pending = true; + } + } +} + +type BindGroupCache = HashMap<(u64, wgpu::BindGroupLayout), Arc>; impl TextureManager { pub(crate) fn new(device: Arc, queue: Arc) -> Self { @@ -83,12 +134,18 @@ impl TextureManager { sampler: Arc::new(sampler), texture_storage: Arc::new(RwLock::new(HashMap::new())), shape_bind_group_cache: Arc::new(RwLock::new(HashMap::new())), + uploads: Arc::new(RwLock::new(Vec::new())), } } pub fn clear(&self) { self.texture_storage.write().unwrap().clear(); self.shape_bind_group_cache.write().unwrap().clear(); + for upload in self.uploads.write().unwrap().iter_mut() { + upload.texture = None; + upload.bytes.clear(); + upload.pending = false; + } } pub fn size(&self) -> (usize, usize) { @@ -120,6 +177,19 @@ impl TextureManager { /// - `texture_id`: Unique identifier for the texture. /// - `texture_dimensions`: A tuple `(width, height)` representing the dimensions of the texture. pub fn allocate_texture(&self, texture_id: u64, texture_dimensions: (u32, u32)) { + let existing = self + .texture_storage + .read() + .unwrap() + .get(&texture_id) + .cloned(); + if let Some(texture) = existing.filter(|texture| { + texture.width() == texture_dimensions.0 && texture.height() == texture_dimensions.1 + }) { + self.stage_upload(texture_id, &texture, texture_dimensions, &[], true) + .expect("valid allocated texture"); + return; + } let mut bind_group_cache = self.shape_bind_group_cache.write().unwrap(); // If the binding cache contains entries for this texture_id, remove them // as the texture is being re-allocated, and the old bind groups are no longer valid. @@ -148,10 +218,12 @@ impl TextureManager { self.texture_storage .write() .unwrap() - .insert(texture_id, texture); + .insert(texture_id, texture.clone()); + self.stage_upload(texture_id, &texture, texture_dimensions, &[], true) + .expect("valid allocated texture"); } - /// Allocates a texture and immediately loads image data into it. + /// Allocates a texture and stages image data for the next committed scene. /// /// This function will first allocate the texture, then attempt to load the provided data. /// @@ -176,7 +248,7 @@ impl TextureManager { .unwrap(); } - /// Loads image data into an already allocated texture. If you are seeing fringes when + /// Stages image data for an already allocated texture. If you are seeing fringes when /// sampling/minifying near transparent edges, ensure that your texture data is in a /// premultiplied alpha format. You can use the `premultiply_rgba8_srgb_inplace` helper /// function provided in this crate to convert your RGBA8 sRGB data to premultiplied alpha. @@ -191,6 +263,8 @@ impl TextureManager { /// # Returns /// - `Ok(())` if the operation succeeds. /// - `Err(TextureManagerError::TextureNotFound(texture_id))` if the texture does not exist. + /// - `Err(TextureManagerError::InvalidUpload(texture_id))` if dimensions or byte length do not + /// exactly match the allocated RGBA8 texture. pub fn load_data_into_texture( &self, texture_id: u64, @@ -202,20 +276,7 @@ impl TextureManager { .get(&texture_id) .ok_or(TextureManagerError::TextureNotFound(texture_id))?; - let texture_extent = wgpu::Extent3d { - width: texture_dimensions.0, - height: texture_dimensions.1, - depth_or_array_layers: 1, - }; - - self.write_image_bytes_to_texture( - texture, - texture_dimensions, - texture_extent, - texture_data, - ); - - Ok(()) + self.stage_upload(texture_id, texture, texture_dimensions, texture_data, false) } /// Removes the texture identified by `texture_id` from the manager. @@ -227,45 +288,158 @@ impl TextureManager { .retain(|(cached_texture_id, _shape_id), _bind_group| *cached_texture_id != texture_id); self.texture_storage.write().unwrap().remove(&texture_id); + if let Some(upload) = self + .uploads + .write() + .unwrap() + .iter_mut() + .find(|upload| upload.texture_id == texture_id) + { + upload.texture = None; + upload.bytes.clear(); + upload.pending = false; + } } - fn write_image_bytes_to_texture( + fn stage_upload( &self, + texture_id: u64, texture: &wgpu::Texture, - texture_dimensions: (u32, u32), - texture_extent: wgpu::Extent3d, - texture_data_bytes: &[u8], + dimensions: (u32, u32), + bytes: &[u8], + reset: bool, + ) -> Result<(), TextureManagerError> { + validate_upload( + texture_id, + (texture.width(), texture.height()), + dimensions, + bytes.len(), + reset, + )?; + let source_row_length = dimensions.0 as usize * 4; + let mut uploads = self.uploads.write().unwrap(); + let index = uploads + .iter() + .position(|upload| upload.texture_id == texture_id) + .or_else(|| uploads.iter().position(|upload| upload.texture.is_none())) + .unwrap_or_else(|| { + uploads.push(TextureUpload { + texture_id, + texture: None, + bytes: Vec::new(), + bytes_per_row: 0, + buffer: None, + pending: false, + }); + uploads.len() - 1 + }); + let upload = &mut uploads[index]; + upload.texture_id = texture_id; + let bytes_per_row = (texture.width() * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + upload + .bytes + .resize(bytes_per_row as usize * texture.height() as usize, 0); + if reset { + upload.bytes.fill(0); + } else { + for row in 0..dimensions.1 as usize { + let destination_start = row * bytes_per_row as usize; + upload.bytes[destination_start..destination_start + source_row_length] + .copy_from_slice( + &bytes[row * source_row_length..(row + 1) * source_row_length], + ); + } + } + upload.texture = Some(texture.clone()); + upload.bytes_per_row = bytes_per_row; + upload.pending = true; + Ok(()) + } + + pub(crate) fn restore_encoded_uploads(&self, encoded_uploads: &[EncodedTextureUpload]) { + let mut uploads = self.uploads.write().unwrap(); + for encoded in encoded_uploads { + let Some(upload) = uploads.get_mut(encoded.index) else { + continue; + }; + encoded.restore_if_matches( + encoded.index, + upload.texture_id, + upload.texture.is_some(), + &mut upload.pending, + ); + } + } + + /// Encodes asset writes only into the selected scene's command buffer. + pub(crate) fn encode_uploads( + &self, + encoder: &mut wgpu::CommandEncoder, + encoded_uploads: &mut Vec, ) { - self.queue.write_texture( - // Tells wgpu where to copy the pixel data - wgpu::TexelCopyTextureInfo { - texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - // The actual pixel data - texture_data_bytes, - // The layout of the texture - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4 * texture_dimensions.0), - rows_per_image: Some(texture_dimensions.1), - }, - texture_extent, - ); + encoded_uploads.clear(); + let mut uploads = self.uploads.write().unwrap(); + for (index, upload) in uploads + .iter_mut() + .enumerate() + .filter(|(_, upload)| upload.pending) + { + let Some(texture) = &upload.texture else { + continue; + }; + let size = upload.bytes.len() as u64; + if upload + .buffer + .as_ref() + .is_none_or(|buffer| buffer.size() < size) + { + upload.buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("texture upload storage"), + size, + usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); + } + let buffer = upload + .buffer + .as_ref() + .expect("texture upload storage allocated"); + self.queue.write_buffer(buffer, 0, &upload.bytes); + encoder.copy_buffer_to_texture( + wgpu::TexelCopyBufferInfo { + buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(upload.bytes_per_row), + rows_per_image: Some(texture.height()), + }, + }, + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + texture.size(), + ); + upload.pending = false; + encoded_uploads.push(EncodedTextureUpload { + index, + texture_id: upload.texture_id, + }); + } } /// Creates a bind group for the provided `layout` using the stored sampler and /// the texture identified by `texture_id`. /// - /// Returns a cached bind group for the given `layout_epoch` and `texture_id`, + /// Returns a cached bind group for this layout and texture, /// creating and caching it if necessary. This avoids per-frame bind group creation /// when binding textures for shapes. pub(crate) fn get_or_create_shape_bind_group( &self, layout: &wgpu::BindGroupLayout, - layout_epoch: u64, texture_id: u64, ) -> Result, TextureManagerError> { // Fast path: check cache @@ -273,7 +447,7 @@ impl TextureManager { .shape_bind_group_cache .read() .unwrap() - .get(&(texture_id, layout_epoch)) + .get(&(texture_id, layout.clone())) .cloned() { return Ok(bg); @@ -304,11 +478,18 @@ impl TextureManager { self.shape_bind_group_cache .write() .unwrap() - .insert((texture_id, layout_epoch), bind_group.clone()); + .insert((texture_id, layout.clone()), bind_group.clone()); Ok(bind_group) } + pub(crate) fn retire_shape_bind_group_layout(&self, layout: &wgpu::BindGroupLayout) { + self.shape_bind_group_cache + .write() + .unwrap() + .retain(|(_, cached_layout), _| cached_layout != layout); + } + pub fn is_texture_loaded(&self, texture_id: u64) -> bool { self.texture_storage .read() @@ -374,3 +555,46 @@ pub fn premultiply_rgba8_srgb_inplace(pixels: &mut [u8]) { // keep alpha as-is } } + +#[cfg(test)] +mod tests { + use super::{validate_upload, EncodedTextureUpload, TextureManagerError}; + + #[test] + fn discarded_submission_restores_only_uploads_encoded_for_that_submission() { + let mut committed_pending = false; + let mut discarded_pending = false; + let discarded = EncodedTextureUpload { + index: 1, + texture_id: 9, + }; + + discarded.restore_if_matches(0, 7, true, &mut committed_pending); + discarded.restore_if_matches(1, 9, true, &mut discarded_pending); + + assert!(!committed_pending); + assert!(discarded_pending); + } + + #[test] + fn texture_upload_requires_exact_dimensions_and_byte_length() { + assert!(validate_upload(7, (4, 3), (4, 3), 48, false).is_ok()); + assert!(matches!( + validate_upload(7, (4, 3), (3, 3), 36, false), + Err(TextureManagerError::InvalidUpload(7)) + )); + assert!(matches!( + validate_upload(7, (4, 3), (4, 3), 47, false), + Err(TextureManagerError::InvalidUpload(7)) + )); + assert!(matches!( + validate_upload(7, (4, 3), (4, 3), 49, false), + Err(TextureManagerError::InvalidUpload(7)) + )); + assert!(validate_upload(7, (4, 3), (4, 3), 0, true).is_ok()); + assert!(matches!( + validate_upload(7, (4, 3), (3, 3), 0, true), + Err(TextureManagerError::InvalidUpload(7)) + )); + } +} diff --git a/src/util.rs b/src/util.rs index 502c073..5db69d4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -44,7 +44,6 @@ pub struct LyonVertexBuffersPool { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct GradientBindGroupCacheKey { - layout_epoch: u64, params: GpuGradientColorParamsKey, ramp_key: GradientRampCacheKey, } @@ -195,11 +194,9 @@ impl GradientCache { queue: &wgpu::Queue, layout: &wgpu::BindGroupLayout, sampler: &wgpu::Sampler, - layout_epoch: u64, ) -> Arc { let material_params = GpuMaterialParams::from_gradient_data(gradient_data); let cache_key = GradientBindGroupCacheKey { - layout_epoch, params: GpuGradientColorParamsKey::from_params(material_params.gradient), ramp_key: gradient_data.ramp_cache_key.clone(), }; @@ -290,8 +287,6 @@ impl GradientCache { }) } - fn trim(&mut self) {} - fn print_sizes(&self) { println!("Gradient ramps: {}", self.ramps.len()); println!("Gradient ramp textures: {}", self.ramp_textures.len()); @@ -346,11 +341,6 @@ impl PoolManager { } } - pub(crate) fn trim(&mut self) { - self.aa_fringe_scratch.trim(); - self.gradient_cache.trim(); - } - pub fn print_sizes(&self) { println!("Pool sizes:"); println!("Vertex buffers: {}", self.lyon_vertex_buffers_pool.len()); diff --git a/tests/visual_regression.rs b/tests/visual_regression.rs index 4f6540f..17cfed4 100644 --- a/tests/visual_regression.rs +++ b/tests/visual_regression.rs @@ -42,6 +42,56 @@ fn assert_pixels_match(pixel_buffer: &[u8], expectations: &[grafo_test_scenes::P } } +#[test] +fn discarded_preparation_preserves_the_scene_and_can_be_rebuilt() { + let Some(mut renderer) = create_headless_renderer() else { + return; + }; + let expectations = build_main_scene(&mut renderer); + let mut original_pixels = Vec::new(); + renderer.render_to_buffer(&mut original_pixels); + assert_pixels_match(&original_pixels, &expectations); + + for _replacement in 0..3 { + assert_eq!(renderer.prepare(), grafo::PreparationOutcome::Ready); + renderer.discard_preparation(); + assert!(matches!( + renderer.commit(None), + Err(grafo::RenderError::NotPrepared) + )); + } + + let mut rebuilt_pixels = Vec::new(); + renderer.render_to_buffer(&mut rebuilt_pixels); + assert_eq!(original_pixels, rebuilt_pixels); + + renderer.clear_draw_queue(); + renderer.resize((32, 32)); + renderer.render_to_buffer(&mut rebuilt_pixels); + renderer.resize((CANVAS_WIDTH, CANVAS_HEIGHT)); + build_main_scene(&mut renderer); + renderer.render_to_buffer(&mut rebuilt_pixels); + assert_pixels_match(&rebuilt_pixels, &expectations); + assert_eq!(original_pixels, rebuilt_pixels); +} + +#[test] +fn surface_commit_failure_consumes_preparation_without_running_the_hook() { + let Some(mut renderer) = create_headless_renderer_with_size_and_scale((8, 8), 1.0) else { + return; + }; + renderer.set_pre_present_callback(|| panic!("failed commits must not notify presentation")); + renderer.prepare(); + assert!(matches!( + renderer.commit(None), + Err(grafo::RenderError::Headless) + )); + assert!(matches!( + renderer.commit(None), + Err(grafo::RenderError::NotPrepared) + )); +} + #[test] fn shape_effect_is_resolved_before_backdrop_capture_with_msaa() { let Some(mut renderer) = create_headless_renderer_with_size_and_scale((64, 64), 1.0) else {