diff --git a/node-graph/libraries/canvas-utils/src/wasm.rs b/node-graph/libraries/canvas-utils/src/wasm.rs index 9ec9154d8e..a1beeab4cd 100644 --- a/node-graph/libraries/canvas-utils/src/wasm.rs +++ b/node-graph/libraries/canvas-utils/src/wasm.rs @@ -52,13 +52,20 @@ impl Canvas for CanvasHandle { } #[cfg(feature = "wgpu")] -pub struct CanvasSurfaceHandle(CanvasHandle, Option>>); +struct SurfaceState { + surface: Arc>, + format: wgpu::TextureFormat, + blitter: wgpu_executor::cached_blitter::CachedBlitter, +} + +#[cfg(feature = "wgpu")] +pub struct CanvasSurfaceHandle(CanvasHandle, Option); #[cfg(feature = "wgpu")] impl CanvasSurfaceHandle { pub fn new() -> Self { Self(CanvasHandle::new(), None) } - fn surface(&mut self, executor: &WgpuExecutor) -> &wgpu::Surface<'_> { + fn state(&mut self, executor: &WgpuExecutor) -> &SurfaceState { if self.1.is_none() { let canvas = self.0.get().canvas.clone(); let surface = executor @@ -66,7 +73,17 @@ impl CanvasSurfaceHandle { .instance .create_surface(wgpu::SurfaceTarget::Canvas(canvas)) .expect("Failed to create surface from canvas"); - self.1 = Some(Arc::new(surface)); + + // Use the surface's preferred format (Firefox WebGL prefers Bgra8Unorm, Chrome prefers Rgba8Unorm) + let surface_caps = surface.get_capabilities(&executor.context.adapter); + let surface_format = surface_caps.formats.iter().copied().find(|f| f.is_srgb()).unwrap_or(surface_caps.formats[0]); + let blitter = wgpu_executor::cached_blitter::CachedBlitter::new(&executor.context.device, surface_format); + + self.1 = Some(SurfaceState { + surface: Arc::new(surface), + format: surface_format, + blitter, + }); } self.1.as_ref().unwrap() } @@ -87,23 +104,21 @@ impl Canvas for CanvasSurfaceHandle { impl CanvasSurface for CanvasSurfaceHandle { fn present(&mut self, image_texture: &ImageTexture, executor: &WgpuExecutor) { let source_texture: &wgpu::Texture = image_texture.as_ref(); + let state = self.state(executor); - let surface = self.surface(executor); - - // Blit the texture to the surface let mut encoder = executor.context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Texture to Surface Blit"), }); let size = source_texture.size(); - // Configure the surface at physical resolution (for HiDPI displays) - let surface_caps = surface.get_capabilities(&executor.context.adapter); - surface.configure( + // Configure the surface at the detected preferred format + let surface_caps = state.surface.get_capabilities(&executor.context.adapter); + state.surface.configure( &executor.context.device, &wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST, - format: wgpu::TextureFormat::Rgba8Unorm, + format: state.format, width: size.width, height: size.height, present_mode: surface_caps.present_modes[0], @@ -113,23 +128,30 @@ impl CanvasSurface for CanvasSurfaceHandle { }, ); - let surface_texture = surface.get_current_texture().expect("Failed to get surface texture"); - - encoder.copy_texture_to_texture( - wgpu::TexelCopyTextureInfoBase { - texture: source_texture, - mip_level: 0, - origin: Default::default(), - aspect: Default::default(), - }, - wgpu::TexelCopyTextureInfoBase { - texture: &surface_texture.texture, - mip_level: 0, - origin: Default::default(), - aspect: Default::default(), - }, - source_texture.size(), - ); + let surface_texture = state.surface.get_current_texture().expect("Failed to get surface texture"); + + // If the surface format matches the source, use a direct copy; otherwise use the cached blitter + // for format conversion (e.g., Rgba8Unorm source to Bgra8Unorm surface on Firefox) + if state.format == source_texture.format() { + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfoBase { + texture: source_texture, + mip_level: 0, + origin: Default::default(), + aspect: Default::default(), + }, + wgpu::TexelCopyTextureInfoBase { + texture: &surface_texture.texture, + mip_level: 0, + origin: Default::default(), + aspect: Default::default(), + }, + source_texture.size(), + ); + } else { + let target_view = surface_texture.texture.create_view(&wgpu::TextureViewDescriptor::default()); + state.blitter.copy(&executor.context.device, &mut encoder, source_texture, &target_view); + } executor.context.queue.submit([encoder.finish()]); surface_texture.present(); diff --git a/node-graph/libraries/wgpu-executor/src/blit.wgsl b/node-graph/libraries/wgpu-executor/src/blit.wgsl new file mode 100644 index 0000000000..12385107a1 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/blit.wgsl @@ -0,0 +1,26 @@ +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) tex_coords: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput { + var out: VertexOutput; + out.tex_coords = vec2( + f32((vi << 1u) & 2u), + f32(vi & 2u), + ); + out.position = vec4(out.tex_coords * 2.0 - 1.0, 0.0, 1.0); + out.tex_coords.y = 1.0 - out.tex_coords.y; + return out; +} + +@group(0) @binding(0) +var src_texture: texture_2d; +@group(0) @binding(1) +var src_sampler: sampler; + +@fragment +fn fs_main(vs: VertexOutput) -> @location(0) vec4 { + return textureSample(src_texture, src_sampler, vs.tex_coords); +} diff --git a/node-graph/libraries/wgpu-executor/src/cached_blitter.rs b/node-graph/libraries/wgpu-executor/src/cached_blitter.rs new file mode 100644 index 0000000000..7db97a6849 --- /dev/null +++ b/node-graph/libraries/wgpu-executor/src/cached_blitter.rs @@ -0,0 +1,198 @@ +use crate::VELLO_SURFACE_FORMAT; + +/// A texture blitter that caches its bind group to avoid recreating it every frame. +/// +/// The standard wgpu `TextureBlitter` creates a new bind group on every `copy()` call, +/// which causes excessive GPU resource allocation during viewport panning. This blitter +/// maintains a persistent intermediate texture (recreated only on size change) and a cached +/// bind group bound to it. Each frame, the source is copied into the persistent texture +/// via `copy_texture_to_texture` (same format, no bind groups), then the cached bind group +/// is used for the format-converting render pass. +pub struct CachedBlitter { + pipeline: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + cache: std::sync::Mutex>, +} + +struct BlitCache { + source_texture: wgpu::Texture, + bind_group: wgpu::BindGroup, + size: wgpu::Extent3d, +} + +const BLIT_SHADER: &str = include_str!("blit.wgsl"); + +impl CachedBlitter { + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("CachedBlitter::sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("CachedBlitter::bind_group_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("CachedBlitter::pipeline_layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("CachedBlitter::shader"), + source: wgpu::ShaderSource::Wgsl(BLIT_SHADER.into()), + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("CachedBlitter::pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview: None, + cache: None, + }); + + Self { + pipeline, + bind_group_layout, + sampler, + cache: std::sync::Mutex::new(None), + } + } + + /// Copies the source texture to the target with format conversion, using a cached bind group. + /// + /// Internally maintains a persistent intermediate texture. Each frame: + /// 1. Copies `source` → intermediate via `copy_texture_to_texture` (same format, no bind groups) + /// 2. Blits intermediate → `target` via a render pass with the cached bind group + /// + /// The bind group and intermediate texture are only recreated when the source size changes. + pub fn copy(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::Texture, target: &wgpu::TextureView) { + let size = source.size(); + + // Take cache out of mutex to avoid holding the lock during GPU operations + let mut cache = self.cache.lock().unwrap().take(); + + // Recreate the persistent texture and bind group if size changed + if !matches!(&cache, Some(c) if c.size == size) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("CachedBlitter::intermediate"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: VELLO_SURFACE_FORMAT, + usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("CachedBlitter::bind_group"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + cache = Some(BlitCache { + source_texture: texture, + bind_group, + size, + }); + } + + let c = cache.as_ref().unwrap(); + + // Copy source → persistent intermediate texture (same format, no bind group creation) + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfoBase { + texture: source, + mip_level: 0, + origin: Default::default(), + aspect: Default::default(), + }, + wgpu::TexelCopyTextureInfoBase { + texture: &c.source_texture, + mip_level: 0, + origin: Default::default(), + aspect: Default::default(), + }, + size, + ); + + // Blit intermediate → target with format conversion using the cached bind group + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("CachedBlitter::pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &c.bind_group, &[]); + pass.draw(0..3, 0..1); + } + + // Put cache back for next frame + *self.cache.lock().unwrap() = cache; + } +} diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 7cd413c1cb..7b5315cb69 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -1,3 +1,4 @@ +pub mod cached_blitter; mod context; mod resample; pub mod shader_runtime;