diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-26 20:44:07 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-26 20:44:07 +0200 |
| commit | 6b1f38a9a31a0eedaa7abea70f232960469351d5 (patch) | |
| tree | 4050d69056c1d9f14075b784535e2b33719f477b /rust | |
| parent | 56ca09692a308b22455420d11376ff8d41244c47 (diff) | |
| parent | 0b7597b31fa956c7f3d4838923b545ba5e341422 (diff) | |
| download | opencut-6b1f38a9a31a0eedaa7abea70f232960469351d5.tar.gz | |
refactor: split animation helpers by domain
Diffstat (limited to 'rust')
| -rw-r--r-- | rust/crates/compositor/src/compositor.rs | 1 | ||||
| -rw-r--r-- | rust/crates/gpu/src/context.rs | 149 | ||||
| -rw-r--r-- | rust/wasm/Cargo.toml | 4 | ||||
| -rw-r--r-- | rust/wasm/src/compositor.rs | 116 | ||||
| -rw-r--r-- | rust/wasm/src/perf.rs | 51 | ||||
| -rw-r--r-- | rust/wasm/src/wasm.rs | 4 |
6 files changed, 224 insertions, 101 deletions
diff --git a/rust/crates/compositor/src/compositor.rs b/rust/crates/compositor/src/compositor.rs index 84bcceae..99f0e7e9 100644 --- a/rust/crates/compositor/src/compositor.rs +++ b/rust/crates/compositor/src/compositor.rs @@ -348,7 +348,6 @@ impl Compositor { ) -> Result<(), CompositorError> { let frame = options.frame; self.texture_pool.recycle_frame(); - context.configure_surface(options.surface, frame.width, frame.height)?; let surface_texture = context.acquire_surface_texture(options.surface)?; let surface_view = surface_texture .texture diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs index e38eff8c..da835280 100644 --- a/rust/crates/gpu/src/context.rs +++ b/rust/crates/gpu/src/context.rs @@ -1,6 +1,9 @@ use wgpu::util::DeviceExt; #[cfg(all(feature = "wasm", target_arch = "wasm32"))] +use std::cell::RefCell; + +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] use wasm_bindgen::JsCast; use crate::{FULLSCREEN_SHADER_SOURCE, GpuError}; @@ -17,6 +20,12 @@ impl wgpu::rwh::HasDisplayHandle for WebDisplay { } } +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +struct CachedCanvasSurface { + surface: wgpu::Surface<'static>, + size: (u32, u32), +} + const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl"); const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [ @@ -44,6 +53,8 @@ pub struct GpuContext { /// fallback path. Used by render_texture_via_gl_canvas to output frames on WebGL. #[cfg(all(feature = "wasm", target_arch = "wasm32"))] gl_canvas: Option<web_sys::HtmlCanvasElement>, + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + gl_surface: RefCell<Option<CachedCanvasSurface>>, } impl GpuContext { @@ -170,6 +181,8 @@ impl GpuContext { supports_external_texture_copies, #[cfg(all(feature = "wasm", target_arch = "wasm32"))] gl_canvas, + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + gl_surface: RefCell::new(None), }) } @@ -184,17 +197,15 @@ impl GpuContext { ), GpuError, > { - // Temporary fix: force the wasm renderer onto the WebGL backend even when - // WebGPU is available while a WebGPU bug is being investigated. - // let instance = wgpu::util::new_instance_with_webgpu_detection( - // wgpu::InstanceDescriptor::new_without_display_handle(), - // ) - // .await; + let instance = wgpu::util::new_instance_with_webgpu_detection( + wgpu::InstanceDescriptor::new_without_display_handle(), + ) + .await; - // match Self::try_request_device(&instance, None).await { - // Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)), - // Err(_) => {} - // } + match Self::try_request_device(&instance, None).await { + Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)), + Err(_) => {} + } let (gl_instance, adapter, device, queue, canvas) = Self::try_gl_fallback().await?; Ok((gl_instance, adapter, device, queue, Some(canvas))) } @@ -353,6 +364,14 @@ impl GpuContext { self.supports_external_texture_copies } + /// The HTML canvas that owns the backing WebGL context, if running on the + /// WebGL fallback. Callers on that path can mount this canvas directly + /// instead of copying pixels out of it every frame. + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + pub fn gl_canvas(&self) -> Option<&web_sys::HtmlCanvasElement> { + self.gl_canvas.as_ref() + } + pub fn render_texture_to_surface( &self, texture: &wgpu::Texture, @@ -361,6 +380,14 @@ impl GpuContext { height: u32, ) -> Result<(), GpuError> { self.configure_surface(surface, width, height)?; + self.present_texture_to_surface(texture, surface) + } + + pub fn present_texture_to_surface( + &self, + texture: &wgpu::Texture, + surface: &wgpu::Surface<'_>, + ) -> Result<(), GpuError> { let surface_texture = self.acquire_surface_texture(surface)?; let target_view = surface_texture .texture @@ -382,16 +409,38 @@ impl GpuContext { width: u32, height: u32, ) -> Result<(), GpuError> { - let Some(config) = surface.get_default_config(&self.adapter, width, height) else { - return Err(GpuError::UnsupportedSurfaceFormat); - }; - if config.format != self.texture_format { - return Err(GpuError::UnsupportedSurfaceFormat); - } + let config = self.build_surface_configuration(surface, width, height)?; surface.configure(&self.device, &config); Ok(()) } + fn build_surface_configuration( + &self, + surface: &wgpu::Surface<'_>, + width: u32, + height: u32, + ) -> Result<wgpu::SurfaceConfiguration, GpuError> { + let caps = surface.get_capabilities(&self.adapter); + if !caps.formats.contains(&self.texture_format) { + return Err(GpuError::UnsupportedSurfaceFormat); + } + + Ok(wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: self.texture_format, + width, + height, + present_mode: wgpu::PresentMode::Fifo, + alpha_mode: caps + .alpha_modes + .first() + .copied() + .unwrap_or(wgpu::CompositeAlphaMode::Auto), + view_formats: vec![], + desired_maximum_frame_latency: 2, + }) + } + pub fn acquire_surface_texture( &self, surface: &wgpu::Surface<'_>, @@ -571,7 +620,7 @@ impl GpuContext { } #[cfg(all(feature = "wasm", target_arch = "wasm32"))] - fn render_texture_to_gl_canvas_surface( + pub fn render_texture_to_gl_canvas_surface( &self, texture: &wgpu::Texture, width: u32, @@ -585,57 +634,29 @@ impl GpuContext { gl_canvas.set_width(width); gl_canvas.set_height(height); - let surface = self - .instance - .create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?; - - let caps = surface.get_capabilities(&self.adapter); - let surface_format = if caps.formats.contains(&self.texture_format) { - self.texture_format - } else if !caps.formats.is_empty() { - caps.formats[0] - } else { - return Err(GpuError::UnsupportedSurfaceFormat); + let mut cached_surface = self.gl_surface.borrow_mut(); + let cached_surface = match cached_surface.as_mut() { + Some(cached_surface) => cached_surface, + None => { + let surface = self + .instance + .create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?; + cached_surface.replace(CachedCanvasSurface { + surface, + size: (0, 0), + }); + cached_surface + .as_mut() + .expect("gl_surface cache should exist after initialization") + } }; - if surface_format != self.texture_format { - return Err(GpuError::UnsupportedSurfaceFormat); + if cached_surface.size != (width, height) { + self.configure_surface(&cached_surface.surface, width, height)?; + cached_surface.size = (width, height); } - let config = wgpu::SurfaceConfiguration { - usage: wgpu::TextureUsages::RENDER_ATTACHMENT, - format: surface_format, - width, - height, - present_mode: wgpu::PresentMode::Fifo, - alpha_mode: caps - .alpha_modes - .first() - .copied() - .unwrap_or(wgpu::CompositeAlphaMode::Auto), - view_formats: vec![], - desired_maximum_frame_latency: 2, - }; - surface.configure(&self.device, &config); - - let surface_texture = self.acquire_surface_texture(&surface)?; - let surface_view = surface_texture - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); - - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("gpu-gl-canvas-blit-encoder"), - }); - self.encode_texture_blit_to_view( - &mut encoder, - texture, - &surface_view, - "gpu-gl-canvas-blit", - ); - self.queue.submit([encoder.finish()]); - surface_texture.present(); + self.present_texture_to_surface(texture, &cached_surface.surface)?; Ok(gl_canvas) } diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml index f12a8cbc..0f2bfc56 100644 --- a/rust/wasm/Cargo.toml +++ b/rust/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencut-wasm" -version = "0.2.9" +version = "0.2.10" edition = "2024" description = "Shared video editor logic compiled to WebAssembly" repository = "https://github.com/opencut/opencut" @@ -24,7 +24,7 @@ serde-wasm-bindgen = "0.6.5" time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] } wasm-bindgen = "0.2.116" wasm-bindgen-futures = "0.4.66" -web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] } +web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window", "Performance"] } [features] default = ["wasm"] diff --git a/rust/wasm/src/compositor.rs b/rust/wasm/src/compositor.rs index 126c7468..7652b3f3 100644 --- a/rust/wasm/src/compositor.rs +++ b/rust/wasm/src/compositor.rs @@ -11,10 +11,13 @@ use crate::gpu::{ import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property, with_gpu_runtime, }; +use crate::perf; struct CompositorRuntime { canvas: web_sys::HtmlCanvasElement, compositor: Compositor, + surface: wgpu::Surface<'static>, + surface_size: (u32, u32), } thread_local! { @@ -24,20 +27,42 @@ thread_local! { #[wasm_bindgen(js_name = initCompositor)] pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> { with_gpu_runtime(|gpu_runtime| { - let document = web_sys::window() - .and_then(|window| window.document()) - .ok_or_else(|| JsValue::from_str("Document is not available"))?; - let canvas: web_sys::HtmlCanvasElement = document - .create_element("canvas")? - .dyn_into() - .map_err(|_| JsValue::from_str("Failed to create compositor canvas"))?; + // On WebGL, wgpu is bound to a specific canvas; reuse it so the UI + // can mount the output directly instead of copying pixels through + // an intermediate 2D canvas every frame. On WebGPU, surface rendering + // works against any canvas so we create a fresh one. + let canvas = if let Some(gl_canvas) = gpu_runtime.context.gl_canvas() { + gl_canvas.clone() + } else { + let document = web_sys::window() + .and_then(|window| window.document()) + .ok_or_else(|| JsValue::from_str("Document is not available"))?; + document + .create_element("canvas")? + .dyn_into::<web_sys::HtmlCanvasElement>() + .map_err(|_| JsValue::from_str("Failed to create compositor canvas"))? + }; canvas.set_width(width); canvas.set_height(height); let compositor = Compositor::new(&gpu_runtime.context); + let surface = gpu_runtime + .context + .instance() + .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + gpu_runtime + .context + .configure_surface(&surface, width, height) + .map_err(|error| JsValue::from_str(&error.to_string()))?; COMPOSITOR_RUNTIME.with(|runtime| { - runtime.replace(Some(CompositorRuntime { canvas, compositor })); + runtime.replace(Some(CompositorRuntime { + canvas, + compositor, + surface, + surface_size: (width, height), + })); }); Ok(()) @@ -46,16 +71,25 @@ pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> { #[wasm_bindgen(js_name = resizeCompositor)] pub fn resize_compositor(width: u32, height: u32) -> Result<(), JsValue> { - COMPOSITOR_RUNTIME.with(|runtime| { - let mut borrow = runtime.borrow_mut(); - let Some(runtime) = borrow.as_mut() else { - return Err(JsValue::from_str( - "Compositor is not initialized. Call initCompositor() first.", - )); - }; - runtime.canvas.set_width(width); - runtime.canvas.set_height(height); - Ok(()) + with_gpu_runtime(|gpu_runtime| { + COMPOSITOR_RUNTIME.with(|runtime| { + let mut borrow = runtime.borrow_mut(); + let Some(runtime) = borrow.as_mut() else { + return Err(JsValue::from_str( + "Compositor is not initialized. Call initCompositor() first.", + )); + }; + runtime.canvas.set_width(width); + runtime.canvas.set_height(height); + if runtime.surface_size != (width, height) { + gpu_runtime + .context + .configure_surface(&runtime.surface, width, height) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + runtime.surface_size = (width, height); + } + Ok(()) + }) }) } @@ -119,8 +153,12 @@ pub fn release_texture(id: String) -> Result<(), JsValue> { #[wasm_bindgen(js_name = renderFrame)] pub fn render_frame(options: JsValue) -> Result<(), JsValue> { + perf::reset(); + + let t_deserialize = perf::now_ms(); let frame: FrameDescriptor = serde_wasm_bindgen::from_value(options) .map_err(|error| JsValue::from_str(&format!("Invalid frame descriptor: {error}")))?; + perf::record("wasm.deserialize", perf::now_ms() - t_deserialize); with_gpu_runtime(|gpu_runtime| { COMPOSITOR_RUNTIME.with(|runtime| { @@ -131,40 +169,50 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> { )); }; - if gpu_runtime.context.supports_surface_rendering() { - let surface = gpu_runtime + if runtime.surface_size != (frame.width, frame.height) { + runtime.canvas.set_width(frame.width); + runtime.canvas.set_height(frame.height); + let t_surface = perf::now_ms(); + gpu_runtime .context - .instance() - .create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone())) + .configure_surface(&runtime.surface, frame.width, frame.height) .map_err(|error| JsValue::from_str(&error.to_string()))?; + perf::record("wasm.surfaceConfigure", perf::now_ms() - t_surface); + runtime.surface_size = (frame.width, frame.height); + } - runtime + if gpu_runtime.context.supports_surface_rendering() { + let t_render = perf::now_ms(); + let result = runtime .compositor .render_frame( &gpu_runtime.context, RenderFrameOptions { frame: &frame, - surface: &surface, + surface: &runtime.surface, }, ) - .map_err(|error| JsValue::from_str(&error.to_string())) + .map_err(|error| JsValue::from_str(&error.to_string())); + perf::record("wasm.renderFrameToSurface", perf::now_ms() - t_render); + result } else { - // WebGL cannot surface-render to an arbitrary canvas element. - // Composite to a texture, then blit to the canvas via the 2D context. + // WebGL still needs a separate composition pass, but the output + // surface is now persistent just like the WebGPU path. + let t_composite = perf::now_ms(); let texture = runtime .compositor .render_frame_to_texture(&gpu_runtime.context, &frame) .map_err(|error| JsValue::from_str(&error.to_string()))?; + perf::record("wasm.compositeToTexture", perf::now_ms() - t_composite); + let t_present = perf::now_ms(); gpu_runtime .context - .render_texture_via_gl_canvas( - &texture, - &runtime.canvas, - frame.width, - frame.height, - ) - .map_err(|error| JsValue::from_str(&error.to_string())) + .present_texture_to_surface(&texture, &runtime.surface) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + perf::record("wasm.presentToSurface", perf::now_ms() - t_present); + + Ok(()) } }) }) diff --git a/rust/wasm/src/perf.rs b/rust/wasm/src/perf.rs new file mode 100644 index 00000000..0c346a4d --- /dev/null +++ b/rust/wasm/src/perf.rs @@ -0,0 +1,51 @@ +#![cfg(target_arch = "wasm32")] + +//! Per-frame profile buffer for the render pipeline. +//! +//! Sub-span timings are recorded into a thread-local during `renderFrame` +//! and drained by JS via `getLastFrameProfile()`. + +use std::cell::RefCell; + +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::{JsValue, prelude::wasm_bindgen}; + +thread_local! { + static LAST_FRAME_PROFILE: RefCell<Vec<(&'static str, f64)>> = const { RefCell::new(Vec::new()) }; +} + +pub(crate) fn now_ms() -> f64 { + web_sys::window() + .and_then(|window| window.performance()) + .map(|performance| performance.now()) + .unwrap_or(0.0) +} + +pub(crate) fn reset() { + LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().clear()); +} + +pub(crate) fn record(name: &'static str, duration_ms: f64) { + LAST_FRAME_PROFILE.with(|cell| cell.borrow_mut().push((name, duration_ms))); +} + +#[wasm_bindgen(js_name = getLastFrameProfile)] +pub fn get_last_frame_profile() -> Array { + LAST_FRAME_PROFILE.with(|cell| { + let entries = cell.borrow(); + let array = Array::new_with_length(entries.len() as u32); + for (index, (name, duration_ms)) in entries.iter().enumerate() { + let entry = Object::new(); + Reflect::set(&entry, &JsValue::from_str("name"), &JsValue::from_str(name)) + .expect("set name"); + Reflect::set( + &entry, + &JsValue::from_str("durationMs"), + &JsValue::from_f64(*duration_ms), + ) + .expect("set durationMs"); + array.set(index as u32, entry.into()); + } + array + }) +} diff --git a/rust/wasm/src/wasm.rs b/rust/wasm/src/wasm.rs index 1a33092a..0687ff4b 100644 --- a/rust/wasm/src/wasm.rs +++ b/rust/wasm/src/wasm.rs @@ -6,6 +6,8 @@ mod effects; mod gpu; #[cfg(target_arch = "wasm32")] mod masks; +#[cfg(target_arch = "wasm32")] +mod perf; #[cfg(target_arch = "wasm32")] pub use compositor::*; @@ -15,4 +17,6 @@ pub use effects::*; pub use gpu::*; #[cfg(target_arch = "wasm32")] pub use masks::*; +#[cfg(target_arch = "wasm32")] +pub use perf::*; pub use time::*; |
