Skip to main content

agg_gui_wgpu/
screenshot_scaled.rs

1//! Scaled / region screenshot readback for [`WgpuGfxCtx`].
2//!
3//! Sibling of `screenshot_capture.rs`, which owns the full-surface capture
4//! texture and the two full-surface readback flavours (blocking for native
5//! Save/Copy, non-blocking `begin_capture_readback` / `poll_capture_readback`
6//! for the web screen-share sender).  Both of those pull the ENTIRE surface
7//! back to system memory and swizzle it on the calling thread — measured at
8//! ~5.6 ms for a 1280x720 window, scaling with window area.
9//!
10//! This module adds the small-output path: a GPU render-pass blit from the
11//! capture texture (optionally from a sub-rect of it) into a
12//! `dst_w` x `dst_h` `Rgba8Unorm` texture, and a non-blocking readback of
13//! only THAT texture.  A 256x192 thumbnail is ~200 KB regardless of window
14//! size, so the per-capture CPU cost stops tracking resolution.
15//!
16//! Everything here is additive: [`WgpuGfxCtx::begin_capture_readback`] and
17//! friends are untouched and keep their own in-flight slot, so a consumer can
18//! run the `--screenshot` flow and periodic thumbnails independently.
19//!
20//! # Output format
21//!
22//! The scaled path delivers exactly what [`WgpuGfxCtx::poll_capture_readback`]
23//! delivers today: tight, top-row-first, 4-bytes-per-pixel RGBA8 whose bytes
24//! are sRGB-encoded when the surface format is sRGB.  Two deliberate choices
25//! get us there:
26//!
27//! * The blit target is `Rgba8Unorm`, **not** `Rgba8UnormSrgb`, and the
28//!   fragment shader re-encodes the (sampler-decoded) linear value back to
29//!   sRGB when the source format is sRGB.  So the stored bytes are the raw
30//!   surface bytes, not their linearization — an sRGB target would store the
31//!   linear value and darken every thumbnail.
32//! * Channel order needs no work: `textureSample` yields RGBA regardless of
33//!   the source's BGRA memory layout, and the target is RGBA8, so the R↔B
34//!   swap `poll_capture_readback` does per pixel on the CPU happens for free.
35//!
36//! Net effect: a PNG encoder fed by this path needs no changes relative to the
37//! full-surface path — `screenshot_scaled_tests` pins that byte-for-byte.
38
39use bytemuck::{Pod, Zeroable};
40
41use crate::shaders::SCALED_BLIT_WGSL;
42use crate::WgpuGfxCtx;
43
44/// Format of the small blit target, and therefore of the bytes handed back by
45/// [`WgpuGfxCtx::poll_capture_readback_scaled`].  See the module docs for why
46/// this is the non-sRGB variant.
47const DST_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
48
49/// A rectangle in **framebuffer pixels**: origin at the TOP-left of the
50/// surface, +Y downward — the same convention the readback buffers use
51/// ("first row is the top row"), and deliberately NOT agg-gui's Y-up
52/// drawing space.  Callers holding a Y-up widget rect must flip it:
53/// `y = surface_height - (rect.top)`.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub struct RectInPixels {
56    pub x: u32,
57    pub y: u32,
58    pub width: u32,
59    pub height: u32,
60}
61
62impl RectInPixels {
63    pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
64        Self {
65            x,
66            y,
67            width,
68            height,
69        }
70    }
71}
72
73/// Intersect `region` with the `src_w` x `src_h` surface.
74///
75/// `None` in means "the whole surface".  `None` out means "nothing to
76/// capture" — an empty request, or one that starts past the surface edge;
77/// callers report that the same way the existing capture API reports
78/// "nothing captured" (a `false` return, no readback queued).
79pub(crate) fn clamp_region(
80    region: Option<RectInPixels>,
81    src_w: u32,
82    src_h: u32,
83) -> Option<RectInPixels> {
84    if src_w == 0 || src_h == 0 {
85        return None;
86    }
87    let Some(r) = region else {
88        return Some(RectInPixels::new(0, 0, src_w, src_h));
89    };
90    if r.width == 0 || r.height == 0 || r.x >= src_w || r.y >= src_h {
91        return None;
92    }
93    let width = r.width.min(src_w - r.x);
94    let height = r.height.min(src_h - r.y);
95    Some(RectInPixels::new(r.x, r.y, width, height))
96}
97
98/// `bytes_per_row` for a `w`-pixel-wide RGBA8 texture-to-buffer copy, rounded
99/// up to `COPY_BYTES_PER_ROW_ALIGNMENT` as wgpu requires.  The alignment
100/// applies to the small blit target exactly as it does to a full-surface
101/// copy, so the map still hands back padded rows we strip on the CPU.
102pub(crate) fn padded_bytes_per_row(w: u32) -> u32 {
103    const ALIGN: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
104    (w * 4).div_ceil(ALIGN) * ALIGN
105}
106
107/// 32-byte uniform block for `SCALED_BLIT_WGSL` (layout must match the WGSL
108/// struct).  `flags.x` = re-encode linear→sRGB; `flags.yzw` are padding.
109#[repr(C)]
110#[derive(Clone, Copy, Debug, Pod, Zeroable)]
111struct ScaledBlitUniforms {
112    uv_offset: [f32; 2],
113    uv_scale: [f32; 2],
114    flags: [f32; 4],
115}
116const _: () = assert!(std::mem::size_of::<ScaledBlitUniforms>() == 32);
117
118/// Lazily-created GPU resources for the scaled blit: one pipeline, its two
119/// bind-group layouts, a linear sampler (the filtering IS the downscale), and
120/// a size-cached destination texture.  Built on first use rather than in
121/// `WgpuGfxCtx::new` so contexts that never take a scaled screenshot pay
122/// nothing.
123pub(crate) struct ScaledBlit {
124    pipeline: wgpu::RenderPipeline,
125    bgl0: wgpu::BindGroupLayout,
126    bgl1: wgpu::BindGroupLayout,
127    sampler: wgpu::Sampler,
128    dst: Option<(wgpu::Texture, wgpu::TextureView, u32, u32)>,
129}
130
131impl ScaledBlit {
132    fn new(device: &wgpu::Device) -> Self {
133        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
134            label: Some("scaled_blit"),
135            source: wgpu::ShaderSource::Wgsl(SCALED_BLIT_WGSL.into()),
136        });
137        let bgl0 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
138            label: Some("scaled_blit_bgl0"),
139            entries: &[wgpu::BindGroupLayoutEntry {
140                binding: 0,
141                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
142                ty: wgpu::BindingType::Buffer {
143                    ty: wgpu::BufferBindingType::Uniform,
144                    has_dynamic_offset: false,
145                    min_binding_size: wgpu::BufferSize::new(
146                        std::mem::size_of::<ScaledBlitUniforms>() as u64,
147                    ),
148                },
149                count: None,
150            }],
151        });
152        let bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
153            label: Some("scaled_blit_bgl1"),
154            entries: &[
155                wgpu::BindGroupLayoutEntry {
156                    binding: 0,
157                    visibility: wgpu::ShaderStages::FRAGMENT,
158                    ty: wgpu::BindingType::Texture {
159                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
160                        view_dimension: wgpu::TextureViewDimension::D2,
161                        multisampled: false,
162                    },
163                    count: None,
164                },
165                wgpu::BindGroupLayoutEntry {
166                    binding: 1,
167                    visibility: wgpu::ShaderStages::FRAGMENT,
168                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
169                    count: None,
170                },
171            ],
172        });
173        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
174            label: Some("scaled_blit_layout"),
175            bind_group_layouts: &[Some(&bgl0), Some(&bgl1)],
176            immediate_size: 0,
177        });
178        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
179            label: Some("scaled_blit"),
180            layout: Some(&layout),
181            vertex: wgpu::VertexState {
182                module: &shader,
183                entry_point: Some("vs_main"),
184                buffers: &[],
185                compilation_options: Default::default(),
186            },
187            fragment: Some(wgpu::FragmentState {
188                module: &shader,
189                entry_point: Some("fs_main"),
190                targets: &[Some(wgpu::ColorTargetState {
191                    format: DST_FORMAT,
192                    // Straight overwrite: the pass clears nothing meaningful
193                    // and every dst pixel is covered by the triangle.
194                    blend: None,
195                    write_mask: wgpu::ColorWrites::ALL,
196                })],
197                compilation_options: Default::default(),
198            }),
199            primitive: wgpu::PrimitiveState::default(),
200            depth_stencil: None,
201            multisample: wgpu::MultisampleState::default(),
202            multiview_mask: None,
203            cache: None,
204        });
205        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
206            label: Some("scaled_blit_sampler"),
207            address_mode_u: wgpu::AddressMode::ClampToEdge,
208            address_mode_v: wgpu::AddressMode::ClampToEdge,
209            address_mode_w: wgpu::AddressMode::ClampToEdge,
210            mag_filter: wgpu::FilterMode::Linear,
211            min_filter: wgpu::FilterMode::Linear,
212            mipmap_filter: wgpu::MipmapFilterMode::Linear,
213            ..Default::default()
214        });
215        Self {
216            pipeline,
217            bgl0,
218            bgl1,
219            sampler,
220            dst: None,
221        }
222    }
223
224    /// (Re-)allocate the destination texture when the requested output size
225    /// changes, then hand back its view.  Thumbnail sizes are stable in
226    /// practice, so this allocates once.
227    fn ensure_dst(&mut self, device: &wgpu::Device, w: u32, h: u32) -> &wgpu::TextureView {
228        let need_alloc = match &self.dst {
229            Some((_, _, dw, dh)) => *dw != w || *dh != h,
230            None => true,
231        };
232        if need_alloc {
233            let texture = device.create_texture(&wgpu::TextureDescriptor {
234                label: Some("scaled_capture_dst"),
235                size: wgpu::Extent3d {
236                    width: w,
237                    height: h,
238                    depth_or_array_layers: 1,
239                },
240                mip_level_count: 1,
241                sample_count: 1,
242                dimension: wgpu::TextureDimension::D2,
243                format: DST_FORMAT,
244                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
245                view_formats: &[],
246            });
247            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
248            self.dst = Some((texture, view, w, h));
249        }
250        // `dst` was just populated when it was missing.
251        &self.dst.as_ref().expect("dst texture just allocated").1
252    }
253}
254
255impl WgpuGfxCtx {
256    /// Whether a *scaled* capture readback is already in flight.  Independent
257    /// of [`Self::has_pending_readback`], which tracks the full-surface one.
258    pub fn has_pending_scaled_readback(&self) -> bool {
259        self.pending_scaled_readback.is_some()
260    }
261
262    /// Kick off an async readback of a scaled (and optionally cropped) copy of
263    /// the current capture texture.
264    ///
265    /// `src_region` is in framebuffer pixels (top-down, see [`RectInPixels`])
266    /// and is clamped to the surface; `None` means the whole surface.  The
267    /// result is resampled to `dst_w` x `dst_h` with linear filtering, so the
268    /// caller controls the readback cost directly: only
269    /// `dst_w * dst_h * 4` bytes ever cross back to the CPU.
270    ///
271    /// Returns `false` — queueing nothing — when there is nothing to capture:
272    /// no capture texture (call `DrawCtx::capture_screenshot` first), a
273    /// zero-sized output, an empty or entirely off-surface region, or a scaled
274    /// readback already in flight.  Same "nothing captured" signal as
275    /// [`Self::begin_capture_readback`].
276    ///
277    /// Pure GPU submit + `map_async`; never blocks.  Harvest with
278    /// [`Self::poll_capture_readback_scaled`].
279    pub fn begin_capture_readback_scaled(
280        &mut self,
281        src_region: Option<RectInPixels>,
282        dst_w: u32,
283        dst_h: u32,
284    ) -> bool {
285        if self.pending_scaled_readback.is_some() || dst_w == 0 || dst_h == 0 {
286            return false;
287        }
288        let Some((_, src_view, src_w, src_h)) = self.capture_texture.as_ref() else {
289            return false;
290        };
291        let (src_view, src_w, src_h) = (src_view.clone(), *src_w, *src_h);
292        let Some(region) = clamp_region(src_region, src_w, src_h) else {
293            return false;
294        };
295
296        if self.scaled_blit.is_none() {
297            self.scaled_blit = Some(ScaledBlit::new(&self.device));
298        }
299        let device = std::sync::Arc::clone(&self.device);
300        let srgb_src = matches!(
301            self.surface_format,
302            wgpu::TextureFormat::Bgra8UnormSrgb | wgpu::TextureFormat::Rgba8UnormSrgb
303        );
304        let uniforms = ScaledBlitUniforms {
305            uv_offset: [
306                region.x as f32 / src_w as f32,
307                region.y as f32 / src_h as f32,
308            ],
309            uv_scale: [
310                region.width as f32 / src_w as f32,
311                region.height as f32 / src_h as f32,
312            ],
313            flags: [if srgb_src { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0],
314        };
315
316        let blit = self
317            .scaled_blit
318            .as_mut()
319            .expect("scaled blit resources just created");
320        let dst_view = blit.ensure_dst(&device, dst_w, dst_h).clone();
321        let ub = device.create_buffer(&wgpu::BufferDescriptor {
322            label: Some("scaled_blit_uniforms"),
323            size: std::mem::size_of::<ScaledBlitUniforms>() as u64,
324            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
325            mapped_at_creation: false,
326        });
327        self.queue
328            .write_buffer(&ub, 0, bytemuck::bytes_of(&uniforms));
329        let bg0 = device.create_bind_group(&wgpu::BindGroupDescriptor {
330            label: Some("scaled_blit_bg0"),
331            layout: &blit.bgl0,
332            entries: &[wgpu::BindGroupEntry {
333                binding: 0,
334                resource: ub.as_entire_binding(),
335            }],
336        });
337        let bg1 = device.create_bind_group(&wgpu::BindGroupDescriptor {
338            label: Some("scaled_blit_bg1"),
339            layout: &blit.bgl1,
340            entries: &[
341                wgpu::BindGroupEntry {
342                    binding: 0,
343                    resource: wgpu::BindingResource::TextureView(&src_view),
344                },
345                wgpu::BindGroupEntry {
346                    binding: 1,
347                    resource: wgpu::BindingResource::Sampler(&blit.sampler),
348                },
349            ],
350        });
351
352        let padded_bpr = padded_bytes_per_row(dst_w);
353        let staging = device.create_buffer(&wgpu::BufferDescriptor {
354            label: Some("scaled_capture_readback"),
355            size: (padded_bpr as u64) * (dst_h as u64),
356            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
357            mapped_at_creation: false,
358        });
359
360        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
361            label: Some("scaled_capture_blit"),
362        });
363        {
364            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
365                label: Some("scaled_capture_pass"),
366                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
367                    view: &dst_view,
368                    depth_slice: None,
369                    resolve_target: None,
370                    ops: wgpu::Operations {
371                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
372                        store: wgpu::StoreOp::Store,
373                    },
374                })],
375                depth_stencil_attachment: None,
376                timestamp_writes: None,
377                occlusion_query_set: None,
378                multiview_mask: None,
379            });
380            pass.set_pipeline(&blit.pipeline);
381            pass.set_bind_group(0, &bg0, &[]);
382            pass.set_bind_group(1, &bg1, &[]);
383            // Fullscreen triangle — positions come from `vertex_index`.
384            pass.draw(0..3, 0..1);
385        }
386        let dst_texture = &blit.dst.as_ref().expect("dst texture ensured above").0;
387        encoder.copy_texture_to_buffer(
388            wgpu::TexelCopyTextureInfo {
389                texture: dst_texture,
390                mip_level: 0,
391                origin: wgpu::Origin3d::ZERO,
392                aspect: wgpu::TextureAspect::All,
393            },
394            wgpu::TexelCopyBufferInfo {
395                buffer: &staging,
396                layout: wgpu::TexelCopyBufferLayout {
397                    offset: 0,
398                    bytes_per_row: Some(padded_bpr),
399                    rows_per_image: Some(dst_h),
400                },
401            },
402            wgpu::Extent3d {
403                width: dst_w,
404                height: dst_h,
405                depth_or_array_layers: 1,
406            },
407        );
408        self.queue.submit(std::iter::once(encoder.finish()));
409
410        let (tx, rx) = std::sync::mpsc::channel();
411        staging
412            .slice(..)
413            .map_async(wgpu::MapMode::Read, move |res| {
414                let _ = tx.send(res);
415            });
416        self.pending_scaled_readback = Some(crate::PendingReadback {
417            staging,
418            w: dst_w,
419            h: dst_h,
420            padded_bpr,
421            done: rx,
422        });
423        true
424    }
425
426    /// Harvest a completed scaled readback's pixels as tight top-row-first
427    /// RGBA8, or `None` if still pending / none in flight.  Never blocks.
428    ///
429    /// Unlike [`Self::poll_capture_readback`] there is no per-pixel swizzle
430    /// here — the blit already produced RGBA in the surface's own byte
431    /// encoding — so the only CPU work is stripping the row padding.
432    pub fn poll_capture_readback_scaled(&mut self) -> Option<(Vec<u8>, u32, u32)> {
433        let _ = self.device.poll(wgpu::PollType::Poll);
434        match self.pending_scaled_readback.as_ref()?.done.try_recv() {
435            Ok(Ok(())) => {}
436            Err(std::sync::mpsc::TryRecvError::Empty) => return None, // not ready yet
437            Ok(Err(_)) | Err(std::sync::mpsc::TryRecvError::Disconnected) => {
438                self.pending_scaled_readback = None; // failed map — drop it
439                return None;
440            }
441        }
442
443        let crate::PendingReadback {
444            staging,
445            w,
446            h,
447            padded_bpr,
448            ..
449        } = self.pending_scaled_readback.take()?;
450
451        let unpadded_bpr = (w * 4) as usize;
452        let mut out = Vec::with_capacity((w as usize) * (h as usize) * 4);
453        {
454            let view = staging.slice(..).get_mapped_range();
455            for row in 0..h as usize {
456                let start = row * padded_bpr as usize;
457                out.extend_from_slice(&view[start..start + unpadded_bpr]);
458            }
459        }
460        staging.unmap();
461        Some((out, w, h))
462    }
463}