.
diff --git a/sparse_strips/vello_hybrid/Cargo.toml b/sparse_strips/vello_hybrid/Cargo.toml index 916fa82..91b18a0 100644 --- a/sparse_strips/vello_hybrid/Cargo.toml +++ b/sparse_strips/vello_hybrid/Cargo.toml
@@ -34,6 +34,7 @@ "WebGlUniformLocation", "WebGlBuffer", "WebGlShader", + "WebGlSync", "WebGlTexture", "WebGlFramebuffer", "WebGlRenderbuffer",
diff --git a/sparse_strips/vello_hybrid/src/lib.rs b/sparse_strips/vello_hybrid/src/lib.rs index 08244a7..b91b680 100644 --- a/sparse_strips/vello_hybrid/src/lib.rs +++ b/sparse_strips/vello_hybrid/src/lib.rs
@@ -64,6 +64,11 @@ pub use render::{Probe, ProbeResult}; #[cfg(all(target_arch = "wasm32", feature = "webgl"))] pub use render::{WebGlAtlasWriter, WebGlRenderer, WebGlTextureWithDimensions}; +#[cfg(all(target_arch = "wasm32", feature = "webgl", feature = "probe"))] +pub use render::{ + WebGlPendingProbe, WebGlProbePoll, WebGlProbeReadbackError, WebGlProbeStartError, + WebGlProbeTryFinishError, +}; pub use resources::Resources; pub use scene::{RenderSettings, Scene, SceneConstraints}; #[cfg(feature = "text")]
diff --git a/sparse_strips/vello_hybrid/src/render/mod.rs b/sparse_strips/vello_hybrid/src/render/mod.rs index 0a776a1..db890de 100644 --- a/sparse_strips/vello_hybrid/src/render/mod.rs +++ b/sparse_strips/vello_hybrid/src/render/mod.rs
@@ -22,5 +22,10 @@ pub use vello_common::probe::{Probe, ProbeResult}; #[cfg(all(target_arch = "wasm32", feature = "webgl"))] pub use webgl::{WebGlAtlasWriter, WebGlRenderer, WebGlTextureWithDimensions}; +#[cfg(all(target_arch = "wasm32", feature = "webgl", feature = "probe"))] +pub use webgl::{ + WebGlPendingProbe, WebGlProbePoll, WebGlProbeReadbackError, WebGlProbeStartError, + WebGlProbeTryFinishError, +}; #[cfg(feature = "wgpu")] pub use wgpu::{AtlasWriter, RenderTargetConfig, Renderer};
diff --git a/sparse_strips/vello_hybrid/src/render/webgl.rs b/sparse_strips/vello_hybrid/src/render/webgl.rs index 7cbba00..32e64ad 100644 --- a/sparse_strips/vello_hybrid/src/render/webgl.rs +++ b/sparse_strips/vello_hybrid/src/render/webgl.rs
@@ -67,6 +67,8 @@ tile::Tile, }; use vello_sparse_shaders::{clear_slots, filters, render_strips}; +#[cfg(feature = "probe")] +use web_sys::WebGlSync; use web_sys::wasm_bindgen::{JsCast, JsValue}; use web_sys::{ HtmlCanvasElement, WebGl2RenderingContext, WebGlBuffer, WebGlFramebuffer, WebGlProgram, @@ -119,6 +121,195 @@ dummy_image_cache: Option<ImageCache>, } +#[cfg(feature = "probe")] +/// A WebGL probe whose pixel readback has been queued but not completed. +#[derive(Debug)] +pub struct WebGlPendingProbe { + gl: WebGl2RenderingContext, + sync: Option<WebGlSync>, + pixel_pack_buffer: Option<WebGlBuffer>, + width: u16, + height: u16, + elapsed_ms: f64, + finished: bool, +} + +/// Error returned when starting the WebGL probe fails before async readback is queued. +#[cfg(feature = "probe")] +#[derive(Debug, Clone)] +pub enum WebGlProbeStartError { + /// Rendering the probe scene failed. + RenderError { + /// The error returned while rendering the probe. + error: RenderError, + /// Time spent in [`WebGlRenderer::probe`] before returning this error. + elapsed_ms: f64, + }, +} + +/// Result of polling a pending WebGL probe. +#[cfg(feature = "probe")] +#[derive(Debug)] +pub enum WebGlProbePoll { + /// The GPU fence has not completed yet. + Pending, + /// The readback completed and the probe result is available. + Complete(Probe<RenderError>), + /// The GPU fence reported `WAIT_FAILED`. + Failed(WebGlProbeReadbackError), + /// The pending probe was already completed by a previous poll or finish call. + AlreadyFinished, +} + +/// Error returned when a non-blocking `try_finish` cannot produce a probe result. +#[cfg(feature = "probe")] +#[derive(Debug)] +pub enum WebGlProbeTryFinishError { + /// The GPU fence has not completed yet. + Pending(WebGlPendingProbe), + /// The GPU fence reported `WAIT_FAILED`. + Failed(WebGlProbeReadbackError), + /// The pending probe was already completed by a previous poll or finish call. + AlreadyFinished, +} + +/// Error returned when async WebGL probe readback fails. +#[cfg(feature = "probe")] +#[derive(Debug, Clone)] +pub struct WebGlProbeReadbackError { + /// The `clientWaitSync` status value. + pub status: u32, + /// Time spent in [`WebGlRenderer::probe`] before it returned the pending probe. + pub elapsed_ms: f64, +} + +#[cfg(feature = "probe")] +impl WebGlPendingProbe { + /// Return time spent in [`WebGlRenderer::probe`] before it returned this pending probe. + pub fn elapsed_ms(&self) -> f64 { + self.elapsed_ms + } + + /// Try to finish the probe without blocking. + /// + /// Returns `Err(WebGlProbeTryFinishError::Pending(self))` when the GPU fence has not completed + /// yet. + pub fn try_finish(mut self) -> Result<Probe<RenderError>, WebGlProbeTryFinishError> { + match self.poll() { + WebGlProbePoll::Pending => Err(WebGlProbeTryFinishError::Pending(self)), + WebGlProbePoll::Complete(result) => Ok(result), + WebGlProbePoll::Failed(error) => Err(WebGlProbeTryFinishError::Failed(error)), + WebGlProbePoll::AlreadyFinished => Err(WebGlProbeTryFinishError::AlreadyFinished), + } + } + + /// Poll the pending probe without blocking. + pub fn poll(&mut self) -> WebGlProbePoll { + if self.finished { + return WebGlProbePoll::AlreadyFinished; + } + + let status = self.gl.client_wait_sync_with_u32( + self.sync.as_ref().expect("probe sync must exist"), + 0, + 0, + ); + + if status == WebGl2RenderingContext::TIMEOUT_EXPIRED { + return WebGlProbePoll::Pending; + } + + if status == WebGl2RenderingContext::WAIT_FAILED { + return WebGlProbePoll::Failed(self.finish_wait_failed(status)); + } + + if status == WebGl2RenderingContext::ALREADY_SIGNALED + || status == WebGl2RenderingContext::CONDITION_SATISFIED + { + WebGlProbePoll::Complete(self.finish_after_sync()) + } else { + WebGlProbePoll::Failed(self.finish_wait_failed(status)) + } + } + + /// Block until the GPU readback fence completes, then return the final probe result. + pub fn finish_blocking(mut self) -> Result<Probe<RenderError>, WebGlProbeReadbackError> { + if self.finished { + return Err(self.finish_wait_failed(WebGl2RenderingContext::WAIT_FAILED)); + } + + let status = self.gl.client_wait_sync_with_f64( + self.sync.as_ref().expect("probe sync must exist"), + 0, + WebGl2RenderingContext::TIMEOUT_IGNORED, + ); + + if status == WebGl2RenderingContext::WAIT_FAILED { + return Err(self.finish_wait_failed(status)); + } + + if status == WebGl2RenderingContext::ALREADY_SIGNALED + || status == WebGl2RenderingContext::CONDITION_SATISFIED + { + Ok(self.finish_after_sync()) + } else { + Err(self.finish_wait_failed(status)) + } + } + + fn finish_after_sync(&mut self) -> Probe<RenderError> { + let mut pixmap = Pixmap::new(self.width, self.height); + + self.gl.bind_buffer( + WebGl2RenderingContext::PIXEL_PACK_BUFFER, + self.pixel_pack_buffer.as_ref(), + ); + self.gl.get_buffer_sub_data_with_i32_and_u8_array( + WebGl2RenderingContext::PIXEL_PACK_BUFFER, + 0, + pixmap.data_as_u8_slice_mut(), + ); + self.gl + .bind_buffer(WebGl2RenderingContext::PIXEL_PACK_BUFFER, None); + pixmap.recompute_may_have_transparency(); + if let Some(sync) = self.sync.take() { + self.gl.delete_sync(Some(&sync)); + } + if let Some(buffer) = self.pixel_pack_buffer.take() { + self.gl.delete_buffer(Some(&buffer)); + } + + self.finished = true; + Probe::from_actual(pixmap) + } + + fn finish_wait_failed(&mut self, status: u32) -> WebGlProbeReadbackError { + if let Some(sync) = self.sync.take() { + self.gl.delete_sync(Some(&sync)); + } + if let Some(buffer) = self.pixel_pack_buffer.take() { + self.gl.delete_buffer(Some(&buffer)); + } + self.finished = true; + WebGlProbeReadbackError { + status, + elapsed_ms: self.elapsed_ms, + } + } +} + +#[cfg(feature = "probe")] +impl Drop for WebGlPendingProbe { + fn drop(&mut self) { + if let Some(sync) = self.sync.take() { + self.gl.delete_sync(Some(&sync)); + } + if let Some(buffer) = self.pixel_pack_buffer.take() { + self.gl.delete_buffer(Some(&buffer)); + } + } +} + impl WebGlRenderer { /// Creates a new WebGL2 renderer pub fn new(canvas: &HtmlCanvasElement) -> Self { @@ -382,26 +573,42 @@ /// device actually results in visible and correct output. How this achieved is by drawing a selection /// of small elements into a small canvas, and comparing the final output against a reference image. /// - /// If certain pixels in the final output deviate too much from the expected image, an error result - /// will be returned, containing the expected image and the actual image. If probe rendering itself - /// fails, [`Probe::RenderError`] will be returned with the corresponding [`RenderError`]. Otherwise, - /// [`Probe::Success`] will be returned, indicating that the device seems to be compatible with - /// Vello Hybrid. + /// If starting the probe succeeds, this queues an async pixel readback and returns a + /// [`WebGlPendingProbe`]. Poll or finish that pending probe to compare the final pixels against + /// the reference image. If probe rendering itself fails, [`WebGlProbeStartError::RenderError`] + /// will be returned with the corresponding [`RenderError`]. /// - /// **Important:** Note that this method can be expensive to call, as it performs a small rendering - /// operation and also does readback of the pixels to the CPU. Expect it to take anywhere from 10ms - /// to 100ms+. This should be taken into consideration when deciding when and how to call this method, - /// to prevent noticeable stalls on the main thread. + /// The returned pending probe reports the time spent before this method returned. The later + /// readback completion is intentionally not part of that elapsed time. #[cfg(feature = "probe")] - pub fn probe(&mut self) -> Probe<RenderError> { + pub fn probe(&mut self) -> Result<WebGlPendingProbe, WebGlProbeStartError> { + let probe_start = probe_time_now_ms(); match self.probe_inner() { - Ok(actual) => Probe::from_actual(actual), - Err(error) => Probe::RenderError(error), + Ok(mut pending) => { + pending.elapsed_ms = probe_time_now_ms() - probe_start; + Ok(pending) + } + Err(error) => Err(WebGlProbeStartError::RenderError { + error, + elapsed_ms: probe_time_now_ms() - probe_start, + }), } } #[cfg(feature = "probe")] - fn probe_inner(&mut self) -> Result<Pixmap, RenderError> { + fn probe_inner(&mut self) -> Result<WebGlPendingProbe, RenderError> { + let state_guard = WebGlStateGuard::with_config( + &self.gl, + WebGlStateConfig { + framebuffer: true, + read_framebuffer: true, + texture_2d_array: true, + pixel_pack_buffer: true, + viewport: true, + ..Default::default() + }, + ); + let (width, height) = vello_common::probe::canvas_size(); let render_size = RenderSize { width: u32::from(width), @@ -443,8 +650,6 @@ ); let probe_image = Arc::new(vello_common::probe::probe_image_pixmap()); - // Note: No need to destroy the image explicitly in the end, because we discard the image - // cache anyway. let probe_image_id = self.upload_image_with(&mut probe_image_cache, &probe_image, IMAGE_PADDING); let mut scene = Scene::new(width, height); @@ -477,18 +682,24 @@ self.gl .delete_texture(Some(&probe_atlas_texture_array.texture)); - let pixels = match render_result { - Ok(()) => read_framebuffer_rgba8(&self.gl, &probe_framebuffer, width, height), - Err(error) => { - self.gl.delete_framebuffer(Some(&probe_framebuffer)); - self.gl.delete_texture(Some(&probe_texture)); - return Err(error); - } - }; + if let Err(error) = render_result { + self.gl.delete_framebuffer(Some(&probe_framebuffer)); + self.gl.delete_texture(Some(&probe_texture)); + drop(state_guard); + return Err(error); + } - self.gl.delete_framebuffer(Some(&probe_framebuffer)); - self.gl.delete_texture(Some(&probe_texture)); - Ok(pixels) + let pending = start_async_probe_readback( + &self.gl, + &probe_framebuffer, + probe_texture, + width, + height, + 0.0, + ); + + drop(state_guard); + Ok(pending) } /// Shared render pipeline: prepares GPU resources, runs the scheduler, and @@ -1623,19 +1834,20 @@ /// RAII guard for WebGL state management. /// Automatically saves state on creation and restores it on drop. /// Only saves/restores the state specified in the configuration. -struct WebGlStateGuard<'a> { - gl: &'a WebGl2RenderingContext, +struct WebGlStateGuard { + gl: WebGl2RenderingContext, config: WebGlStateConfig, original_framebuffer: Option<WebGlFramebuffer>, original_read_framebuffer: Option<WebGlFramebuffer>, original_texture_2d_array: Option<WebGlTexture>, + original_pixel_pack_buffer: Option<WebGlBuffer>, scissor_enabled: bool, viewport: [i32; 4], } -impl<'a> WebGlStateGuard<'a> { +impl WebGlStateGuard { /// Create a new state guard with custom configuration. - fn with_config(gl: &'a WebGl2RenderingContext, config: WebGlStateConfig) -> Self { + fn with_config(gl: &WebGl2RenderingContext, config: WebGlStateConfig) -> Self { // Save current framebuffer binding if requested let original_framebuffer = if config.framebuffer { gl.get_parameter(WebGl2RenderingContext::FRAMEBUFFER_BINDING) @@ -1663,6 +1875,15 @@ None }; + // Save current pixel pack buffer binding if requested + let original_pixel_pack_buffer = if config.pixel_pack_buffer { + gl.get_parameter(WebGl2RenderingContext::PIXEL_PACK_BUFFER_BINDING) + .ok() + .and_then(|v| v.dyn_into::<WebGlBuffer>().ok()) + } else { + None + }; + // Save current scissor test state if requested let scissor_enabled = if config.scissor { gl.get_parameter(WebGl2RenderingContext::SCISSOR_TEST) @@ -1692,18 +1913,19 @@ }; Self { - gl, + gl: gl.clone(), config, original_framebuffer, original_read_framebuffer, original_texture_2d_array, + original_pixel_pack_buffer, scissor_enabled, viewport, } } /// Create a state guard for clearing an atlas region operations. - fn for_clear_atlas_region(gl: &'a WebGl2RenderingContext) -> Self { + fn for_clear_atlas_region(gl: &WebGl2RenderingContext) -> Self { Self::with_config( gl, WebGlStateConfig { @@ -1716,19 +1938,20 @@ } /// Create a state guard for texture copying operations. - fn for_texture_copy(gl: &'a WebGl2RenderingContext) -> Self { + fn for_texture_copy(gl: &WebGl2RenderingContext) -> Self { Self::with_config( gl, WebGlStateConfig { read_framebuffer: true, texture_2d_array: true, + pixel_pack_buffer: true, ..Default::default() }, ) } } -impl Drop for WebGlStateGuard<'_> { +impl Drop for WebGlStateGuard { /// Restore WebGL state when the guard goes out of scope. /// Only restores state that was configured to be saved. fn drop(&mut self) { @@ -1774,6 +1997,14 @@ self.original_texture_2d_array.as_ref(), ); } + + // Restore original pixel pack buffer binding if it was saved + if self.config.pixel_pack_buffer { + self.gl.bind_buffer( + WebGl2RenderingContext::PIXEL_PACK_BUFFER, + self.original_pixel_pack_buffer.as_ref(), + ); + } } } /// Configuration for which WebGL state to save/restore. @@ -1785,6 +2016,8 @@ read_framebuffer: bool, /// Save/restore 2D array texture binding (`TEXTURE_BINDING_2D_ARRAY`) texture_2d_array: bool, + /// Save/restore pixel pack buffer binding (`PIXEL_PACK_BUFFER_BINDING`) + pixel_pack_buffer: bool, /// Save/restore scissor test state scissor: bool, /// Save/restore viewport @@ -1792,34 +2025,59 @@ } #[cfg(feature = "probe")] -fn read_framebuffer_rgba8( +fn probe_time_now_ms() -> f64 { + js_sys::Date::now() +} + +#[cfg(feature = "probe")] +fn start_async_probe_readback( gl: &WebGl2RenderingContext, framebuffer: &WebGlFramebuffer, + texture: WebGlTexture, width: u16, height: u16, -) -> Pixmap { - let _state_guard = WebGlStateGuard::with_config( - gl, - WebGlStateConfig { - framebuffer: true, - ..Default::default() - }, - ); + elapsed_ms: f64, +) -> WebGlPendingProbe { + let pixel_pack_buffer = gl.create_buffer().unwrap(); + let byte_len = i32::from(width) * i32::from(height) * 4; + gl.bind_buffer( + WebGl2RenderingContext::PIXEL_PACK_BUFFER, + Some(&pixel_pack_buffer), + ); + gl.buffer_data_with_i32( + WebGl2RenderingContext::PIXEL_PACK_BUFFER, + byte_len, + WebGl2RenderingContext::STREAM_READ, + ); gl.bind_framebuffer(WebGl2RenderingContext::FRAMEBUFFER, Some(framebuffer)); - let mut pixmap = Pixmap::new(width, height); - gl.read_pixels_with_opt_u8_array( + gl.read_pixels_with_i32( 0, 0, i32::from(width), i32::from(height), WebGl2RenderingContext::RGBA, WebGl2RenderingContext::UNSIGNED_BYTE, - Some(pixmap.data_as_u8_slice_mut()), + 0, ) .unwrap(); - pixmap.recompute_may_have_transparency(); - pixmap + let sync = gl + .fence_sync(WebGl2RenderingContext::SYNC_GPU_COMMANDS_COMPLETE, 0) + .unwrap(); + gl.flush(); + gl.bind_buffer(WebGl2RenderingContext::PIXEL_PACK_BUFFER, None); + gl.delete_framebuffer(Some(framebuffer)); + gl.delete_texture(Some(&texture)); + + WebGlPendingProbe { + gl: gl.clone(), + sync: Some(sync), + pixel_pack_buffer: Some(pixel_pack_buffer), + width, + height, + elapsed_ms, + finished: false, + } } /// Create a WebGL shader program from vertex and fragment sources.
diff --git a/sparse_strips/vello_sparse_tests/tests/wasm_binary_invariants.rs b/sparse_strips/vello_sparse_tests/tests/wasm_binary_invariants.rs index acb3e7b..570f04b7 100644 --- a/sparse_strips/vello_sparse_tests/tests/wasm_binary_invariants.rs +++ b/sparse_strips/vello_sparse_tests/tests/wasm_binary_invariants.rs
@@ -43,7 +43,7 @@ #[cfg(feature = "webgl")] #[wasm_bindgen_test] fn webgl_probe_succeeds() { - use vello_hybrid::Probe; + use vello_hybrid::{WebGlProbePoll, WebGlProbeStartError}; use wasm_bindgen::JsCast; use web_sys::HtmlCanvasElement; @@ -58,9 +58,24 @@ let mut renderer = vello_hybrid::WebGlRenderer::new(&canvas); match renderer.probe() { - Probe::Success => {} - Probe::Error(_) => panic!("WebGlRenderer::probe() unexpectedly failed"), - Probe::RenderError(error) => { + Ok(mut pending) => { + match pending.poll() { + WebGlProbePoll::Pending => {} + WebGlProbePoll::Complete(result) => { + assert!(result.is_success()); + return; + } + WebGlProbePoll::Failed(error) => { + panic!("WebGlRenderer::probe() readback failed: {error:?}"); + } + WebGlProbePoll::AlreadyFinished => { + panic!("WebGlRenderer::probe() finished before polling"); + } + } + + assert!(pending.finish_blocking().unwrap().is_success()); + } + Err(WebGlProbeStartError::RenderError { error, .. }) => { panic!("WebGlRenderer::probe() failed to render: {error:?}"); } }