From 7685362b872c04dd49683216f315163ae47362bc Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Sun, 2 Aug 2026 16:38:19 +0200 Subject: [PATCH] egui-wgpu: only copy the part of the frame a backdrop effect reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capture_backdrop` copied the whole target into the backdrop texture for every callback that asked for one, so a panel-sized blur on a large screen paid for the entire frame, once per blur. Ask the callback what it needs instead: `CallbackTrait::backdrop_rect` is given the rect the callback will be drawn into, its own rect already cut down by the clip rect, and returns the region it needs to read. Those are different questions — a blur reads its radius beyond every edge it draws — and keeping them apart is what lets the margin survive clipping, which inflating the callback's own rect could not do: that rect is also the viewport it draws into. `Backdrop::valid_in_pixels` reports what was captured. The texture stays full-size so a pixel is still where the effect expects it; only the region that was asked for holds this frame. The default answer is the rect the callback is drawn into, which is right for an effect that only reads the pixels it covers, so nothing has to change unless it reads further out. With nine blurred panels of 240x150 at radius 24 on a 1600x1200 canvas, this plus scissoring the blur passes in regui takes the frame rate from 146 to 1690 fps on WebGPU and 135 to 616 on WebGL2, measured in Chrome; the marginal cost of one blur drops about twentyfold. egui_kittest's snapshots of the same blurs come out pixel for pixel identical. --- crates/eframe/src/web/web_painter_wgpu.rs | 1 + crates/egui-wgpu/src/renderer.rs | 123 ++++++++++++++++++++-- crates/egui-wgpu/src/winit.rs | 10 +- crates/egui_kittest/src/wgpu.rs | 1 + 4 files changed, 122 insertions(+), 13 deletions(-) diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs index c453a74b8..76d75cc28 100644 --- a/crates/eframe/src/web/web_painter_wgpu.rs +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -431,6 +431,7 @@ impl WebPainter for WebPainterWgpu { &render_state.queue, &mut encoder, clipped_primitives, + &screen_descriptor, cursor, target_texture, backdrop_texture, diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs index 23d7fd641..1d5716cf3 100644 --- a/crates/egui-wgpu/src/renderer.rs +++ b/crates/egui-wgpu/src/renderer.rs @@ -137,6 +137,27 @@ pub trait CallbackTrait: Send + Sync { false } + /// Which part of the frame do you need to read, in points? + /// + /// `drawn` is the rect you will be drawn into: your own rect, already cut down by the + /// clip rect, since nothing outside that reaches the screen. Return the region you need + /// to _read_, which is a different question — a blur reads its radius beyond every edge + /// it draws, and has to say so here or it will find stale pixels along that edge. The + /// answer is clamped to the screen, and is what [`Backdrop::valid_in_pixels`] reports. + /// + /// The default of `drawn` suits an effect that only reads the pixels it covers, such as + /// a tint or a colour twist. Ask for what you need and no more: the copy costs in + /// proportion to the area, which is the whole point of asking. Sampling outside what you + /// asked for is not unsafe, it just shows you a stale frame. + /// + /// Returning a region rather than a margin is what lets a one-sided effect, such as a + /// directional smear, pay for one side instead of four. + /// + /// Only called when [`Self::needs_backdrop`] returns `true`. + fn backdrop_rect(&self, drawn: epaint::Rect) -> epaint::Rect { + drawn + } + /// Called between render passes, once the backdrop has been captured. /// /// You have the [`wgpu::CommandEncoder`], so this is where to run your own render @@ -198,6 +219,27 @@ impl Default for RenderCursor { } } +/// The whole pixels a rect in points covers, clamped to a target `size_in_pixels` across. +/// +/// Returns `[x, y, width, height]`, ready for a texture copy. +fn region_in_pixels( + rect: epaint::Rect, + pixels_per_point: f32, + size_in_pixels: [u32; 2], +) -> [u32; 4] { + let [width, height] = size_in_pixels.map(|size| size as f32); + let min_x = (rect.min.x * pixels_per_point).floor().clamp(0.0, width); + let min_y = (rect.min.y * pixels_per_point).floor().clamp(0.0, height); + let max_x = (rect.max.x * pixels_per_point).ceil().clamp(min_x, width); + let max_y = (rect.max.y * pixels_per_point).ceil().clamp(min_y, height); + [ + min_x as u32, + min_y as u32, + (max_x - min_x) as u32, + (max_y - min_y) as u32, + ] +} + /// Whether [`Renderer::render_from`] finished, or stopped to let you capture a backdrop. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RenderProgress { @@ -217,11 +259,24 @@ pub enum RenderProgress { #[derive(Clone, Copy)] pub struct Backdrop<'a> { /// The captured image, ready to sample. + /// + /// Only [`Self::valid_in_pixels`] holds this frame's image; the rest of the texture is + /// whatever was left there earlier. It is a full-size texture rather than a cut-out so + /// that a pixel is where you expect it: sample it at the position on screen, whichever + /// part was copied. pub view: &'a wgpu::TextureView, /// The size of [`Self::view`], in physical pixels. pub size_in_pixels: [u32; 2], + /// The part of [`Self::view`] that was captured this frame, in physical pixels, as + /// `[x, y, width, height]`. + /// + /// This is what [`CallbackTrait::backdrop_rect`] asked for, clamped to the screen. + /// Copying the whole frame for a panel-sized effect is most of the cost of a backdrop, + /// so only what the effect said it would read is copied. + pub valid_in_pixels: [u32; 4], + /// The format of [`Self::view`]. pub format: wgpu::TextureFormat, } @@ -239,7 +294,11 @@ impl BackdropTexture { /// Allocate a backdrop texture of the given size and format. /// /// The format must match the texture egui is being rendered into. - pub fn new(device: &wgpu::Device, size_in_pixels: [u32; 2], format: wgpu::TextureFormat) -> Self { + pub fn new( + device: &wgpu::Device, + size_in_pixels: [u32; 2], + format: wgpu::TextureFormat, + ) -> Self { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("egui_backdrop"), size: wgpu::Extent3d { @@ -259,7 +318,12 @@ impl BackdropTexture { } /// Reallocate if the size or format no longer matches. - pub fn update(&mut self, device: &wgpu::Device, size_in_pixels: [u32; 2], format: wgpu::TextureFormat) { + pub fn update( + &mut self, + device: &wgpu::Device, + size_in_pixels: [u32; 2], + format: wgpu::TextureFormat, + ) { let size = self.texture.size(); if size.width != size_in_pixels[0].max(1) || size.height != size_in_pixels[1].max(1) @@ -642,7 +706,13 @@ impl Renderer { backdrops_supported: false, ..RenderCursor::default() }; - self.render_from(render_pass, paint_jobs, screen_descriptor, &mut cursor, None); + self.render_from( + render_pass, + paint_jobs, + screen_descriptor, + &mut cursor, + None, + ); } /// Draw paint jobs, stopping when one of them needs a backdrop. @@ -861,12 +931,17 @@ impl Renderer { /// /// `backdrop` must already be the same size and format as `target`; call /// [`BackdropTexture::update`] before the loop starts. + /// + /// Only the part of `target` the callback said it would read is copied, which for a + /// panel-sized effect on a large screen is a small fraction of the frame. See + /// [`CallbackTrait::backdrop_rect`]. pub fn capture_backdrop<'a>( &self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, paint_jobs: &[epaint::ClippedPrimitive], + screen_descriptor: &ScreenDescriptor, cursor: RenderCursor, target: &wgpu::Texture, backdrop: &'a BackdropTexture, @@ -874,8 +949,8 @@ impl Renderer { profiling::function_scope!(); let Some(epaint::ClippedPrimitive { + clip_rect, primitive: Primitive::Callback(callback), - .. }) = paint_jobs.get(cursor.job) else { debug_assert!( @@ -900,15 +975,49 @@ impl Renderer { "The backdrop texture must be the same format as the texture egui is drawn into; call `BackdropTexture::update`" ); + // Nothing outside the clip rect reaches the screen, so that is all the callback can + // be drawn into. What it needs to read is a different question, so ask it. + let drawn = callback.rect.intersect(*clip_rect); + let wanted = cbfn.0.backdrop_rect(drawn); + let valid_in_pixels = + region_in_pixels(wanted, screen_descriptor.pixels_per_point, size_in_pixels); + let [x, y, width, height] = valid_in_pixels; + if width == 0 || height == 0 { + // The callback is off-screen or empty, so there is nothing to copy. Hand it a + // backdrop with an empty region rather than nothing at all, so it can still + // decide for itself whether to draw. + let backdrop = Backdrop { + view: &backdrop.view, + size_in_pixels, + valid_in_pixels, + format, + }; + cbfn.0 + .process_backdrop(device, queue, encoder, &self.callback_resources, &backdrop); + return Some(backdrop); + } + + let origin = wgpu::Origin3d { x, y, z: 0 }; encoder.copy_texture_to_texture( - target.as_image_copy(), - backdrop.texture.as_image_copy(), - size, + wgpu::TexelCopyTextureInfo { + origin, + ..target.as_image_copy() + }, + wgpu::TexelCopyTextureInfo { + origin, + ..backdrop.texture.as_image_copy() + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, ); let backdrop = Backdrop { view: &backdrop.view, size_in_pixels, + valid_in_pixels, format, }; cbfn.0 diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index f13fb7e4b..81f9e7049 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -3,11 +3,11 @@ #![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps #![expect(unsafe_code)] -use crate::{RenderState, SurfaceConfig, SurfaceErrorAction, WgpuConfiguration, renderer}; use crate::{ BackdropTexture, RenderCursor, RenderProgress, Renderer, RendererOptions, capture::{CaptureReceiver, CaptureSender, CaptureState, capture_channel}, }; +use crate::{RenderState, SurfaceConfig, SurfaceErrorAction, WgpuConfiguration, renderer}; use egui::{Context, Event, UserData, ViewportId, ViewportIdMap, ViewportIdSet}; use std::{num::NonZeroU32, sync::Arc}; @@ -674,11 +674,8 @@ impl Painter { match &mut self.backdrop_texture { Some(backdrop) => backdrop.update(&render_state.device, size, format), None => { - self.backdrop_texture = Some(BackdropTexture::new( - &render_state.device, - size, - format, - )); + self.backdrop_texture = + Some(BackdropTexture::new(&render_state.device, size, format)); } } } @@ -785,6 +782,7 @@ impl Painter { &render_state.queue, &mut encoder, clipped_primitives, + &screen_descriptor, cursor, target_texture, backdrop_texture, diff --git a/crates/egui_kittest/src/wgpu.rs b/crates/egui_kittest/src/wgpu.rs index e1837169c..c2f55221a 100644 --- a/crates/egui_kittest/src/wgpu.rs +++ b/crates/egui_kittest/src/wgpu.rs @@ -261,6 +261,7 @@ impl crate::TestRenderer for WgpuTestRenderer { &self.render_state.queue, &mut encoder, &tessellated, + &screen, cursor, &texture, backdrop_texture,