Skip to content

Commit 7701283

Browse files
committed
Cache blitter bind group to prevent GPU resource leak during viewport panning
1 parent 2d7c52c commit 7701283

3 files changed

Lines changed: 231 additions & 14 deletions

File tree

desktop/src/render/state.rs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -237,19 +237,12 @@ impl RenderState {
237237
};
238238
let size = glam::UVec2::new(viewport_texture.width(), viewport_texture.height());
239239

240-
// Track whether the overlay texture is newly created or resized, since only then does the bind group need updating
241-
let old_texture_size = self.overlays_texture.as_ref().map(|t| t.size());
242-
243240
let result = futures::executor::block_on(self.executor.render_vello_scene_to_target_texture(&scene, size, &Default::default(), None, &mut self.overlays_texture));
244241
if let Err(e) = result {
245242
tracing::error!("Error rendering overlays: {:?}", e);
246243
return;
247244
}
248-
249-
let new_texture_size = self.overlays_texture.as_ref().map(|t| t.size());
250-
if old_texture_size != new_texture_size {
251-
self.update_bindgroup();
252-
}
245+
self.update_bindgroup();
253246
}
254247

255248
pub(crate) fn render(&mut self, window: &Window) -> Result<(), RenderError> {

editor/src/node_graph_executor/runtime.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -384,10 +384,9 @@ impl NodeRuntime {
384384
image_texture.texture.size(),
385385
);
386386
} else {
387-
// Different format (e.g., Firefox's Bgra8Unorm on Mac) - use blitter for conversion
388-
let source_view = image_texture.texture.create_view(&vello::wgpu::TextureViewDescriptor::default());
387+
// Different format (e.g., Firefox's Bgra8Unorm on Mac) - use cached blitter for conversion
389388
let target_view = surface_texture.texture.create_view(&vello::wgpu::TextureViewDescriptor::default());
390-
surface.surface.blitter.copy(&executor.context.device, &mut encoder, &source_view, &target_view);
389+
surface.surface.blitter.copy(&executor.context.device, &mut encoder, image_texture.texture.as_ref(), &target_view);
391390
}
392391

393392
executor.context.queue.submit([encoder.finish()]);

node-graph/libraries/wgpu-executor/src/lib.rs

Lines changed: 228 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ use glam::UVec2;
1313
use graphene_application_io::{ApplicationIo, EditorApi, SurfaceHandle, SurfaceId};
1414
use std::sync::Arc;
1515
use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};
16-
use wgpu::util::TextureBlitter;
1716
use wgpu::{Origin3d, TextureAspect};
1817

1918
pub use context::Context as WgpuContext;
@@ -48,10 +47,236 @@ pub type WgpuWindow = Arc<SurfaceHandle<WindowHandle>>;
4847
pub struct Surface {
4948
pub inner: wgpu::Surface<'static>,
5049
pub target_texture: Mutex<Option<TargetTexture>>,
51-
pub blitter: TextureBlitter,
50+
pub blitter: CachedBlitter,
5251
pub format: wgpu::TextureFormat,
5352
}
5453

54+
/// A texture blitter that caches its bind group to avoid recreating it every frame.
55+
///
56+
/// The standard wgpu `TextureBlitter` creates a new bind group on every `copy()` call,
57+
/// which causes excessive GPU resource allocation during viewport panning. This blitter
58+
/// maintains a persistent intermediate texture (recreated only on size change) and a cached
59+
/// bind group bound to it. Each frame, the source is copied into the persistent texture
60+
/// via `copy_texture_to_texture` (same format, no bind groups), then the cached bind group
61+
/// is used for the format-converting render pass.
62+
pub struct CachedBlitter {
63+
pipeline: wgpu::RenderPipeline,
64+
bind_group_layout: wgpu::BindGroupLayout,
65+
sampler: wgpu::Sampler,
66+
cache: std::sync::Mutex<Option<BlitCache>>,
67+
}
68+
69+
struct BlitCache {
70+
source_texture: wgpu::Texture,
71+
bind_group: wgpu::BindGroup,
72+
size: wgpu::Extent3d,
73+
}
74+
75+
const BLIT_SHADER: &str = r"
76+
struct VertexOutput {
77+
@builtin(position) position: vec4<f32>,
78+
@location(0) tex_coords: vec2<f32>,
79+
}
80+
81+
@vertex
82+
fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput {
83+
var out: VertexOutput;
84+
out.tex_coords = vec2<f32>(
85+
f32((vi << 1u) & 2u),
86+
f32(vi & 2u),
87+
);
88+
out.position = vec4<f32>(out.tex_coords * 2.0 - 1.0, 0.0, 1.0);
89+
out.tex_coords.y = 1.0 - out.tex_coords.y;
90+
return out;
91+
}
92+
93+
@group(0) @binding(0)
94+
var src_texture: texture_2d<f32>;
95+
@group(0) @binding(1)
96+
var src_sampler: sampler;
97+
98+
@fragment
99+
fn fs_main(vs: VertexOutput) -> @location(0) vec4<f32> {
100+
return textureSample(src_texture, src_sampler, vs.tex_coords);
101+
}
102+
";
103+
104+
impl CachedBlitter {
105+
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
106+
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
107+
label: Some("CachedBlitter::sampler"),
108+
address_mode_u: wgpu::AddressMode::ClampToEdge,
109+
address_mode_v: wgpu::AddressMode::ClampToEdge,
110+
address_mode_w: wgpu::AddressMode::ClampToEdge,
111+
mag_filter: wgpu::FilterMode::Nearest,
112+
..Default::default()
113+
});
114+
115+
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
116+
label: Some("CachedBlitter::bind_group_layout"),
117+
entries: &[
118+
wgpu::BindGroupLayoutEntry {
119+
binding: 0,
120+
visibility: wgpu::ShaderStages::FRAGMENT,
121+
ty: wgpu::BindingType::Texture {
122+
sample_type: wgpu::TextureSampleType::Float { filterable: false },
123+
view_dimension: wgpu::TextureViewDimension::D2,
124+
multisampled: false,
125+
},
126+
count: None,
127+
},
128+
wgpu::BindGroupLayoutEntry {
129+
binding: 1,
130+
visibility: wgpu::ShaderStages::FRAGMENT,
131+
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
132+
count: None,
133+
},
134+
],
135+
});
136+
137+
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
138+
label: Some("CachedBlitter::pipeline_layout"),
139+
bind_group_layouts: &[&bind_group_layout],
140+
push_constant_ranges: &[],
141+
});
142+
143+
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
144+
label: Some("CachedBlitter::shader"),
145+
source: wgpu::ShaderSource::Wgsl(BLIT_SHADER.into()),
146+
});
147+
148+
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
149+
label: Some("CachedBlitter::pipeline"),
150+
layout: Some(&pipeline_layout),
151+
vertex: wgpu::VertexState {
152+
module: &shader,
153+
entry_point: Some("vs_main"),
154+
compilation_options: wgpu::PipelineCompilationOptions::default(),
155+
buffers: &[],
156+
},
157+
primitive: wgpu::PrimitiveState {
158+
topology: wgpu::PrimitiveTopology::TriangleList,
159+
..Default::default()
160+
},
161+
depth_stencil: None,
162+
multisample: wgpu::MultisampleState::default(),
163+
fragment: Some(wgpu::FragmentState {
164+
module: &shader,
165+
entry_point: Some("fs_main"),
166+
compilation_options: wgpu::PipelineCompilationOptions::default(),
167+
targets: &[Some(wgpu::ColorTargetState {
168+
format,
169+
blend: None,
170+
write_mask: wgpu::ColorWrites::ALL,
171+
})],
172+
}),
173+
multiview: None,
174+
cache: None,
175+
});
176+
177+
Self {
178+
pipeline,
179+
bind_group_layout,
180+
sampler,
181+
cache: std::sync::Mutex::new(None),
182+
}
183+
}
184+
185+
/// Copies the source texture to the target with format conversion, using a cached bind group.
186+
///
187+
/// Internally maintains a persistent intermediate texture. Each frame:
188+
/// 1. Copies `source` → intermediate via `copy_texture_to_texture` (same format, no bind groups)
189+
/// 2. Blits intermediate → `target` via a render pass with the cached bind group
190+
///
191+
/// The bind group and intermediate texture are only recreated when the source size changes.
192+
pub fn copy(
193+
&self,
194+
device: &wgpu::Device,
195+
encoder: &mut wgpu::CommandEncoder,
196+
source: &wgpu::Texture,
197+
target: &wgpu::TextureView,
198+
) {
199+
let size = source.size();
200+
201+
// Take cache out of mutex to avoid holding the lock during GPU operations
202+
let mut cache = self.cache.lock().unwrap().take();
203+
204+
// Recreate the persistent texture and bind group if size changed
205+
if !matches!(&cache, Some(c) if c.size == size) {
206+
let texture = device.create_texture(&wgpu::TextureDescriptor {
207+
label: Some("CachedBlitter::intermediate"),
208+
size,
209+
mip_level_count: 1,
210+
sample_count: 1,
211+
dimension: wgpu::TextureDimension::D2,
212+
format: VELLO_SURFACE_FORMAT,
213+
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
214+
view_formats: &[],
215+
});
216+
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
217+
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
218+
label: Some("CachedBlitter::bind_group"),
219+
layout: &self.bind_group_layout,
220+
entries: &[
221+
wgpu::BindGroupEntry {
222+
binding: 0,
223+
resource: wgpu::BindingResource::TextureView(&view),
224+
},
225+
wgpu::BindGroupEntry {
226+
binding: 1,
227+
resource: wgpu::BindingResource::Sampler(&self.sampler),
228+
},
229+
],
230+
});
231+
cache = Some(BlitCache { source_texture: texture, bind_group, size });
232+
}
233+
234+
let c = cache.as_ref().unwrap();
235+
236+
// Copy source → persistent intermediate texture (same format, no bind group creation)
237+
encoder.copy_texture_to_texture(
238+
wgpu::TexelCopyTextureInfoBase {
239+
texture: source,
240+
mip_level: 0,
241+
origin: Default::default(),
242+
aspect: Default::default(),
243+
},
244+
wgpu::TexelCopyTextureInfoBase {
245+
texture: &c.source_texture,
246+
mip_level: 0,
247+
origin: Default::default(),
248+
aspect: Default::default(),
249+
},
250+
size,
251+
);
252+
253+
// Blit intermediate → target with format conversion using the cached bind group
254+
{
255+
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
256+
label: Some("CachedBlitter::pass"),
257+
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
258+
view: target,
259+
depth_slice: None,
260+
resolve_target: None,
261+
ops: wgpu::Operations {
262+
load: wgpu::LoadOp::Load,
263+
store: wgpu::StoreOp::Store,
264+
},
265+
})],
266+
depth_stencil_attachment: None,
267+
timestamp_writes: None,
268+
occlusion_query_set: None,
269+
});
270+
pass.set_pipeline(&self.pipeline);
271+
pass.set_bind_group(0, &c.bind_group, &[]);
272+
pass.draw(0..3, 0..1);
273+
}
274+
275+
// Put cache back for next frame
276+
*self.cache.lock().unwrap() = cache;
277+
}
278+
}
279+
55280
#[derive(Clone, Debug)]
56281
pub struct TargetTexture {
57282
texture: wgpu::Texture,
@@ -182,7 +407,7 @@ impl WgpuExecutor {
182407
// Use the surface's preferred format (Firefox prefers Bgra8Unorm, Chrome prefers Rgba8Unorm)
183408
let surface_caps = surface.get_capabilities(&self.context.adapter);
184409
let surface_format = surface_caps.formats[0];
185-
let blitter = TextureBlitter::new(&self.context.device, surface_format);
410+
let blitter = CachedBlitter::new(&self.context.device, surface_format);
186411
Ok(SurfaceHandle {
187412
window_id,
188413
surface: Surface {

0 commit comments

Comments
 (0)