Skip to main content

agg_gui_wgpu/
ssaa.rs

1//! Re-usable off-screen **SSAA** framebuffer for widgets that need 3-D / GPU
2//! rendering with antialiasing.
3//!
4//! # Why SSAA, not MSAA
5//!
6//! WebGPU's spec only guarantees `{1, 4}` for hardware MSAA sample counts on
7//! arbitrary renderable formats; `8` / `16` need adapter-specific opt-in
8//! features and can be silently absent on the WebGL2 backend.  Supersampling
9//! sidesteps that: render into an oversized single-sample texture and
10//! bilinear-downsample on composite.  Works on every adapter, and the
11//! sample-count granularity is whatever linear-scale you can pay for in
12//! pixels (`2² = 4`, `3² = 9`, `4² = 16`, ...).
13//!
14//! # Pattern
15//!
16//! A widget allocates an [`SsaaFramebuffer`] sized to **scale × on-screen**
17//! pixels, renders into it through its own pipeline (configured for
18//! `sample_count = 1`), then calls [`SsaaFramebuffer::blit_to`] which
19//! composites the downsampled colour onto the active 2-D render target via
20//! the shared `tex_pipeline`.
21//!
22//! Resolving into a private same-size texture and then alpha-blending it
23//! into the target through a textured quad means pixels the widget didn't
24//! render stay transparent and the underlying 2-D content shows through —
25//! the same property hardware MSAA's automatic resolve would clobber if
26//! aimed straight at the surface / layer view.
27//!
28//! # Minimal downstream usage
29//!
30//! ```ignore
31//! use agg_gui_demo_wgpu::{ssaa_linear_scale, SsaaFramebuffer};
32//!
33//! // UI knob: 1 = off, 4 / 9 / 16 = 2× / 3× / 4× linear supersampling.
34//! let samples = 9u32;
35//! let scale = ssaa_linear_scale(samples);
36//!
37//! // One-time alloc (logical size × scale).
38//! let mut fb = SsaaFramebuffer::new(
39//!     device,
40//!     widget_w * scale,
41//!     widget_h * scale,
42//!     surface_format,
43//!     /* with_depth = */ true,
44//! );
45//!
46//! // Each frame:
47//! fb.ensure_size(device, widget_w * scale, widget_h * scale);
48//! // ... begin_render_pass with fb.render_view() + fb.depth_view(), draw your 3-D scene ...
49//! fb.blit_to(device, encoder, target_view, target_size, dst_rect, parent_clip, pipelines);
50//! ```
51
52use agg_gui::geometry::Rect;
53use wgpu::util::DeviceExt;
54
55use crate::pipelines::{TexUniforms, WgpuPipelines};
56
57/// Map a UI-facing "samples" choice to the linear render-target scale factor
58/// used when supersampling onto an oversized framebuffer.
59///
60/// Cell values are pixel multipliers (`1` / `4` / `9` / `16`); the linear
61/// scale is `sqrt(samples)`, so a 16-sample request allocates a 4× linear
62/// (= 16× pixel) backbuffer that gets bilinear-downsampled to the on-screen
63/// rect.
64///
65/// Saved values from the old MSAA-semantics era (`0` for off) are coerced to
66/// `1`.  Anything between supported steps rounds to the nearest supported
67/// step so out-of-band saves don't fail loudly.
68pub fn ssaa_linear_scale(requested_samples: u32) -> u32 {
69    match requested_samples {
70        0 | 1 => 1,
71        2..=5 => 2,
72        6..=12 => 3,
73        _ => 4,
74    }
75}
76
77/// Off-screen single-sample framebuffer for widgets that drive their own GPU
78/// pipeline and composite the result onto the shared 2-D render target.
79///
80/// SSAA happens via *size*: the caller allocates this at `scale × {w, h}`
81/// and bilinear-downsamples through [`Self::blit_to`].  This type holds no
82/// concept of "samples" — it's just an offscreen colour (+ optional depth)
83/// with a composite helper.
84///
85/// Allocated lazily; call [`Self::ensure_size`] each frame to keep the
86/// attachments sized to the current widget rect × scale.
87pub struct SsaaFramebuffer {
88    /// Single-sample texture used as the render target and as the source of
89    /// [`Self::blit_to`].
90    color: (wgpu::Texture, wgpu::TextureView),
91    /// Optional depth attachment, `sample_count = 1`.
92    depth: Option<(wgpu::Texture, wgpu::TextureView)>,
93    format: wgpu::TextureFormat,
94    with_depth: bool,
95    width: u32,
96    height: u32,
97    /// Linear sampler used by `blit_to`.  Linear minification is the
98    /// downsample filter at < 4× linear; at exactly 4× linear (SSAA 16×)
99    /// use [`Self::blit_downsample_4x_to`] instead — a single bilinear tap
100    /// reads only 4 of the 16 source texels, losing half the AA benefit.
101    blit_sampler: wgpu::Sampler,
102}
103
104impl SsaaFramebuffer {
105    /// Build a fresh framebuffer at `(w, h)` with surface `format`.
106    /// `with_depth = true` allocates a matching depth buffer; widgets that
107    /// only need colour can pass `false`.
108    ///
109    /// `(w, h)` is the **physical** size of the texture, already multiplied
110    /// by the desired linear SSAA scale.  Use [`ssaa_linear_scale`] to map
111    /// a samples-style UI value to that multiplier.
112    pub fn new(
113        device: &wgpu::Device,
114        w: u32,
115        h: u32,
116        format: wgpu::TextureFormat,
117        with_depth: bool,
118    ) -> Self {
119        let blit_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
120            label: Some("ssaa_blit"),
121            address_mode_u: wgpu::AddressMode::ClampToEdge,
122            address_mode_v: wgpu::AddressMode::ClampToEdge,
123            address_mode_w: wgpu::AddressMode::ClampToEdge,
124            mag_filter: wgpu::FilterMode::Linear,
125            min_filter: wgpu::FilterMode::Linear,
126            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
127            ..Default::default()
128        });
129        let mut fb = Self {
130            color: alloc_color(device, w.max(1), h.max(1), format),
131            depth: None,
132            format,
133            with_depth,
134            width: w.max(1),
135            height: h.max(1),
136            blit_sampler,
137        };
138        if with_depth {
139            fb.depth = Some(alloc_depth(device, fb.width, fb.height));
140        }
141        fb
142    }
143
144    /// Reallocate the attachments if `(w, h)` has changed since the last call.
145    /// Cheap when the size is stable.
146    pub fn ensure_size(&mut self, device: &wgpu::Device, w: u32, h: u32) {
147        let w = w.max(1);
148        let h = h.max(1);
149        if w == self.width && h == self.height {
150            return;
151        }
152        self.width = w;
153        self.height = h;
154        self.color = alloc_color(device, w, h, self.format);
155        if self.with_depth {
156            self.depth = Some(alloc_depth(device, w, h));
157        }
158    }
159
160    pub fn size(&self) -> (u32, u32) {
161        (self.width, self.height)
162    }
163
164    /// View to use as the **colour attachment** of the widget's render pass.
165    pub fn render_view(&self) -> &wgpu::TextureView {
166        &self.color.1
167    }
168
169    /// Same view, exposed under the historic name used by post-render
170    /// sampling sites (e.g. the screenshot path).
171    pub fn resolve_view(&self) -> &wgpu::TextureView {
172        &self.color.1
173    }
174
175    /// Colour texture handle.  Exposed so a platform shell (currently
176    /// `demo-wasm`) can use this `SsaaFramebuffer` as the intermediate
177    /// "scene" target — pass `resolve_texture().clone()` to
178    /// [`crate::WgpuGfxCtx::set_surface_texture`] so the GPU-direct
179    /// screenshot path copies from this scene texture instead of from the
180    /// real swap-chain surface (which on WebGL2 cannot advertise
181    /// `COPY_SRC` and so can't be the source of a `copy_texture_to_*`).
182    pub fn resolve_texture(&self) -> &wgpu::Texture {
183        &self.color.0
184    }
185
186    /// Depth attachment view, when one was requested at construction.
187    pub fn depth_view(&self) -> Option<&wgpu::TextureView> {
188        self.depth.as_ref().map(|(_, v)| v)
189    }
190
191    /// Composite the framebuffer's colour onto `target_view`'s `dst_rect`
192    /// (Y-up screen-space pixels of the target).  Uses the shared 2-D
193    /// textured-quad pipeline with `BLEND_STANDARD` so transparent pixels
194    /// (where the widget didn't render) preserve the 2-D content
195    /// underneath.
196    ///
197    /// At ≥4× linear minification (e.g. SSAA 16×) a single bilinear tap
198    /// only reads 4 of the 16 source texels per output pixel; in that case
199    /// use [`Self::blit_downsample_4x_to`] instead.  This base `blit_to`
200    /// always runs the single-tap pipeline — fine for 1× / 2× / 3×
201    /// minification (perfect 2×2 box at 2×).
202    ///
203    /// `parent_clip` is intersected with `dst_rect` to set the pass scissor —
204    /// pass the framework scissor that was active when the widget called
205    /// `gl_paint` / pushed its draw command.
206    pub fn blit_to(
207        &self,
208        device: &wgpu::Device,
209        encoder: &mut wgpu::CommandEncoder,
210        target_view: &wgpu::TextureView,
211        target_size: (u32, u32),
212        dst_rect: Rect,
213        parent_clip: Option<[i32; 4]>,
214        pipelines: &WgpuPipelines,
215    ) {
216        self.blit_to_inner(
217            device,
218            encoder,
219            target_view,
220            target_size,
221            dst_rect,
222            parent_clip,
223            &pipelines.tex_pipeline,
224            pipelines,
225        );
226    }
227
228    /// 3×3-box downsample variant of [`Self::blit_to`].  Use when the
229    /// framebuffer is exactly 3× the linear size of `dst_rect` (SSAA 9×):
230    /// runs `tex_downsample_3x_pipeline` so all 9 source texels under each
231    /// output pixel contribute equally.  A single bilinear tap at 3×
232    /// minification reads only the corner 2×2 block (4 of 9 texels) and
233    /// entirely skips one row + one column — visually close to no AA at
234    /// all — so 3× SSAA *needs* this dedicated pipeline to be worth its
235    /// pixel cost.
236    pub fn blit_downsample_3x_to(
237        &self,
238        device: &wgpu::Device,
239        encoder: &mut wgpu::CommandEncoder,
240        target_view: &wgpu::TextureView,
241        target_size: (u32, u32),
242        dst_rect: Rect,
243        parent_clip: Option<[i32; 4]>,
244        pipelines: &WgpuPipelines,
245    ) {
246        self.blit_to_inner(
247            device,
248            encoder,
249            target_view,
250            target_size,
251            dst_rect,
252            parent_clip,
253            &pipelines.tex_downsample_3x_pipeline,
254            pipelines,
255        );
256    }
257
258    /// 4×4-box downsample variant of [`Self::blit_to`].  Use when the
259    /// framebuffer is exactly 4× the linear size of `dst_rect` (SSAA 16×):
260    /// runs `tex_downsample_4x_pipeline` so all 16 source texels under each
261    /// output pixel contribute equally, instead of the 4-of-16 you'd get
262    /// from a single bilinear tap.
263    pub fn blit_downsample_4x_to(
264        &self,
265        device: &wgpu::Device,
266        encoder: &mut wgpu::CommandEncoder,
267        target_view: &wgpu::TextureView,
268        target_size: (u32, u32),
269        dst_rect: Rect,
270        parent_clip: Option<[i32; 4]>,
271        pipelines: &WgpuPipelines,
272    ) {
273        self.blit_to_inner(
274            device,
275            encoder,
276            target_view,
277            target_size,
278            dst_rect,
279            parent_clip,
280            &pipelines.tex_downsample_4x_pipeline,
281            pipelines,
282        );
283    }
284
285    /// Downsample by a factor chosen at **runtime**, dispatching to the
286    /// pipeline that is correct for it.
287    ///
288    /// Consumers that pick their supersample factor per device — rather
289    /// than baking one in at compile time — need this. A renderer
290    /// running on both a desktop at device-pixel-ratio 1 and a phone at
291    /// ratio 3 cannot use a fixed factor: the offscreen targets are
292    /// sized `factor × device pixels`, so a constant 3× multiplies an
293    /// already-tripled resolution and blows past both the memory budget
294    /// and `max_texture_dimension_2d`. Such a consumer computes its
295    /// factor from the device scale and limits, then hands it here.
296    ///
297    /// Mapping, per the analysis in the individual methods:
298    ///
299    /// | factor | pipeline                        | why                                |
300    /// |--------|---------------------------------|------------------------------------|
301    /// | 0, 1   | [`Self::blit_to`]               | identity — texel centres align     |
302    /// | 2      | [`Self::blit_to`]               | one bilinear tap IS the 2×2 box    |
303    /// | 3      | [`Self::blit_downsample_3x_to`] | 9 taps; bilinear misses 5 of 9     |
304    /// | ≥4     | [`Self::blit_downsample_4x_to`] | 4 bilinear taps = 16-texel box     |
305    ///
306    /// Factors above 4 clamp to the 4× pipeline rather than erroring —
307    /// it degrades gracefully (a partial box) instead of failing a
308    /// frame, and no caller supersamples that far today.
309    #[allow(clippy::too_many_arguments)]
310    pub fn blit_downsample_to(
311        &self,
312        device: &wgpu::Device,
313        encoder: &mut wgpu::CommandEncoder,
314        target_view: &wgpu::TextureView,
315        target_size: (u32, u32),
316        dst_rect: Rect,
317        parent_clip: Option<[i32; 4]>,
318        pipelines: &WgpuPipelines,
319        factor: u32,
320    ) {
321        let pipeline = match factor {
322            0 | 1 | 2 => &pipelines.tex_pipeline,
323            3 => &pipelines.tex_downsample_3x_pipeline,
324            _ => &pipelines.tex_downsample_4x_pipeline,
325        };
326        self.blit_to_inner(
327            device,
328            encoder,
329            target_view,
330            target_size,
331            dst_rect,
332            parent_clip,
333            pipeline,
334            pipelines,
335        );
336    }
337
338    #[allow(clippy::too_many_arguments)]
339    fn blit_to_inner(
340        &self,
341        device: &wgpu::Device,
342        encoder: &mut wgpu::CommandEncoder,
343        target_view: &wgpu::TextureView,
344        target_size: (u32, u32),
345        dst_rect: Rect,
346        parent_clip: Option<[i32; 4]>,
347        pipeline: &wgpu::RenderPipeline,
348        pipelines: &WgpuPipelines,
349    ) {
350        let [gl_x, gl_y, gl_w, gl_h] = pixel_rect(dst_rect);
351        if gl_w <= 0 || gl_h <= 0 {
352            return;
353        }
354
355        let tex_uniforms = TexUniforms {
356            resolution: [target_size.0 as f32, target_size.1 as f32],
357            _pad: [0.0; 2],
358            tint: [1.0, 1.0, 1.0, 1.0],
359        };
360        let tex_ub = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
361            label: Some("ssaa_blit_uniforms"),
362            contents: bytemuck::bytes_of(&tex_uniforms),
363            usage: wgpu::BufferUsages::UNIFORM,
364        });
365        let tex_bg0 = device.create_bind_group(&wgpu::BindGroupDescriptor {
366            label: Some("ssaa_blit_bg0"),
367            layout: &pipelines.tex_bgl0,
368            entries: &[wgpu::BindGroupEntry {
369                binding: 0,
370                resource: tex_ub.as_entire_binding(),
371            }],
372        });
373        let tex_bg1 = device.create_bind_group(&wgpu::BindGroupDescriptor {
374            label: Some("ssaa_blit_bg1"),
375            layout: &pipelines.tex_bgl1,
376            entries: &[
377                wgpu::BindGroupEntry {
378                    binding: 0,
379                    resource: wgpu::BindingResource::TextureView(&self.color.1),
380                },
381                wgpu::BindGroupEntry {
382                    binding: 1,
383                    resource: wgpu::BindingResource::Sampler(&self.blit_sampler),
384                },
385            ],
386        });
387
388        // Quad in target_view's Y-up local coords.  bl=v=1, tr=v=0 mirrors
389        // the wgpu UV convention used elsewhere (e.g. `image_blit.rs`).
390        let x0 = gl_x as f32;
391        let y0 = gl_y as f32;
392        let x1 = x0 + gl_w as f32;
393        let y1 = y0 + gl_h as f32;
394        let verts: [f32; 24] = [
395            x0, y0, 0.0, 1.0, x1, y0, 1.0, 1.0, x1, y1, 1.0, 0.0, x0, y0, 0.0, 1.0, x1, y1, 1.0,
396            0.0, x0, y1, 0.0, 0.0,
397        ];
398        let vb = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
399            label: Some("ssaa_blit_vb"),
400            contents: bytemuck::cast_slice(&verts),
401            usage: wgpu::BufferUsages::VERTEX,
402        });
403
404        // Convert parent_clip (Y-up) ∩ dst_rect to wgpu Y-down for set_scissor.
405        let scissor = parent_clip
406            .map(|[px, py, pw, ph]| {
407                let x1 = (gl_x + gl_w).min(px + pw);
408                let y1 = (gl_y + gl_h).min(py + ph);
409                let x0c = gl_x.max(px);
410                let y0c = gl_y.max(py);
411                let w = (x1 - x0c).max(0);
412                let h = (y1 - y0c).max(0);
413                let y_down = (target_size.1 as i32 - (y0c + h)).max(0);
414                (x0c.max(0) as u32, y_down as u32, w as u32, h as u32)
415            })
416            .unwrap_or_else(|| {
417                let y_down = (target_size.1 as i32 - (gl_y + gl_h)).max(0);
418                (gl_x.max(0) as u32, y_down as u32, gl_w as u32, gl_h as u32)
419            });
420        if scissor.2 == 0 || scissor.3 == 0 {
421            return;
422        }
423
424        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
425            label: Some("ssaa_blit_pass"),
426            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
427                view: target_view,
428                resolve_target: None,
429                depth_slice: None,
430                ops: wgpu::Operations {
431                    load: wgpu::LoadOp::Load,
432                    store: wgpu::StoreOp::Store,
433                },
434            })],
435            depth_stencil_attachment: None,
436            timestamp_writes: None,
437            occlusion_query_set: None,
438            multiview_mask: None,
439        });
440        pass.set_viewport(
441            0.0,
442            0.0,
443            target_size.0 as f32,
444            target_size.1 as f32,
445            0.0,
446            1.0,
447        );
448        pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
449        pass.set_pipeline(pipeline);
450        pass.set_bind_group(0, &tex_bg0, &[]);
451        pass.set_bind_group(1, &tex_bg1, &[]);
452        pass.set_vertex_buffer(0, vb.slice(..));
453        pass.draw(0..6, 0..1);
454        // pass dropped here; vb / bind groups kept alive by `_` below.
455        drop(pass);
456        let _ = (tex_ub, tex_bg0, tex_bg1, vb);
457    }
458}
459
460fn alloc_color(
461    device: &wgpu::Device,
462    w: u32,
463    h: u32,
464    format: wgpu::TextureFormat,
465) -> (wgpu::Texture, wgpu::TextureView) {
466    let tex = device.create_texture(&wgpu::TextureDescriptor {
467        label: Some("ssaa_color"),
468        size: wgpu::Extent3d {
469            width: w,
470            height: h,
471            depth_or_array_layers: 1,
472        },
473        mip_level_count: 1,
474        sample_count: 1,
475        dimension: wgpu::TextureDimension::D2,
476        format,
477        // `COPY_SRC` so this texture can serve as the source of a
478        // `copy_texture_to_texture` / `copy_texture_to_buffer` —
479        // specifically when `demo-wasm` uses an `SsaaFramebuffer` as the
480        // intermediate scene buffer behind the screenshot path.  Cost is
481        // a flag bit; backends that don't physically support COPY_SRC on
482        // a non-swap-chain texture are extinct in practice (wgpu's
483        // WebGL2 backend does support it on regular textures).
484        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
485            | wgpu::TextureUsages::TEXTURE_BINDING
486            | wgpu::TextureUsages::COPY_SRC,
487        view_formats: &[],
488    });
489    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
490    (tex, view)
491}
492
493fn alloc_depth(device: &wgpu::Device, w: u32, h: u32) -> (wgpu::Texture, wgpu::TextureView) {
494    let tex = device.create_texture(&wgpu::TextureDescriptor {
495        label: Some("ssaa_depth"),
496        size: wgpu::Extent3d {
497            width: w,
498            height: h,
499            depth_or_array_layers: 1,
500        },
501        mip_level_count: 1,
502        sample_count: 1,
503        dimension: wgpu::TextureDimension::D2,
504        format: wgpu::TextureFormat::Depth32Float,
505        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
506        view_formats: &[],
507    });
508    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
509    (tex, view)
510}
511
512fn pixel_rect(rect: Rect) -> [i32; 4] {
513    let x0 = rect.x.floor() as i32;
514    let y0 = rect.y.floor() as i32;
515    let x1 = (rect.x + rect.width).ceil() as i32;
516    let y1 = (rect.y + rect.height).ceil() as i32;
517    [x0, y0, (x1 - x0).max(0), (y1 - y0).max(0)]
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn pixel_rect_covers_fractional_physical_extent() {
526        let rect = Rect::new(10.25, 20.5, 16.5, 10.25);
527        assert_eq!(pixel_rect(rect), [10, 20, 17, 11]);
528    }
529
530    #[test]
531    fn ssaa_linear_scale_matches_segments() {
532        assert_eq!(ssaa_linear_scale(0), 1);
533        assert_eq!(ssaa_linear_scale(1), 1);
534        assert_eq!(ssaa_linear_scale(4), 2);
535        assert_eq!(ssaa_linear_scale(9), 3);
536        assert_eq!(ssaa_linear_scale(16), 4);
537        // Out-of-band rounds to nearest supported step.
538        assert_eq!(ssaa_linear_scale(7), 3);
539        assert_eq!(ssaa_linear_scale(100), 4);
540    }
541}