Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 49 additions & 27 deletions node-graph/libraries/canvas-utils/src/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,38 @@ impl Canvas for CanvasHandle {
}

#[cfg(feature = "wgpu")]
pub struct CanvasSurfaceHandle(CanvasHandle, Option<Arc<wgpu::Surface<'static>>>);
struct SurfaceState {
surface: Arc<wgpu::Surface<'static>>,
format: wgpu::TextureFormat,
blitter: wgpu_executor::cached_blitter::CachedBlitter,
}

#[cfg(feature = "wgpu")]
pub struct CanvasSurfaceHandle(CanvasHandle, Option<SurfaceState>);
#[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
.context
.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()
}
Expand All @@ -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],
Expand All @@ -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();
Expand Down
26 changes: 26 additions & 0 deletions node-graph/libraries/wgpu-executor/src/blit.wgsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) tex_coords: vec2<f32>,
}

@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput {
var out: VertexOutput;
out.tex_coords = vec2<f32>(
f32((vi << 1u) & 2u),
f32(vi & 2u),
);
out.position = vec4<f32>(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<f32>;
@group(0) @binding(1)
var src_sampler: sampler;

@fragment
fn fs_main(vs: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(src_texture, src_sampler, vs.tex_coords);
}
198 changes: 198 additions & 0 deletions node-graph/libraries/wgpu-executor/src/cached_blitter.rs
Original file line number Diff line number Diff line change
@@ -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<Option<BlitCache>>,
}

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;
}
}
1 change: 1 addition & 0 deletions node-graph/libraries/wgpu-executor/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod cached_blitter;
mod context;
mod resample;
pub mod shader_runtime;
Expand Down
Loading