agg-gui-wgpu 0.5.3

Hardware-accelerated wgpu renderer for agg-gui — DrawCtx via Vulkan/DX12/Metal/WebGL2, with SSAA, screenshot capture, and a custom render-pass hook
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
//! Re-usable off-screen **SSAA** framebuffer for widgets that need 3-D / GPU
//! rendering with antialiasing.
//!
//! # Why SSAA, not MSAA
//!
//! WebGPU's spec only guarantees `{1, 4}` for hardware MSAA sample counts on
//! arbitrary renderable formats; `8` / `16` need adapter-specific opt-in
//! features and can be silently absent on the WebGL2 backend.  Supersampling
//! sidesteps that: render into an oversized single-sample texture and
//! bilinear-downsample on composite.  Works on every adapter, and the
//! sample-count granularity is whatever linear-scale you can pay for in
//! pixels (`2² = 4`, `3² = 9`, `4² = 16`, ...).
//!
//! # Pattern
//!
//! A widget allocates an [`SsaaFramebuffer`] sized to **scale × on-screen**
//! pixels, renders into it through its own pipeline (configured for
//! `sample_count = 1`), then calls [`SsaaFramebuffer::blit_to`] which
//! composites the downsampled colour onto the active 2-D render target via
//! the shared `tex_pipeline`.
//!
//! Resolving into a private same-size texture and then alpha-blending it
//! into the target through a textured quad means pixels the widget didn't
//! render stay transparent and the underlying 2-D content shows through —
//! the same property hardware MSAA's automatic resolve would clobber if
//! aimed straight at the surface / layer view.
//!
//! # Minimal downstream usage
//!
//! ```ignore
//! use agg_gui_demo_wgpu::{ssaa_linear_scale, SsaaFramebuffer};
//!
//! // UI knob: 1 = off, 4 / 9 / 16 = 2× / 3× / 4× linear supersampling.
//! let samples = 9u32;
//! let scale = ssaa_linear_scale(samples);
//!
//! // One-time alloc (logical size × scale).
//! let mut fb = SsaaFramebuffer::new(
//!     device,
//!     widget_w * scale,
//!     widget_h * scale,
//!     surface_format,
//!     /* with_depth = */ true,
//! );
//!
//! // Each frame:
//! fb.ensure_size(device, widget_w * scale, widget_h * scale);
//! // ... begin_render_pass with fb.render_view() + fb.depth_view(), draw your 3-D scene ...
//! fb.blit_to(device, encoder, target_view, target_size, dst_rect, parent_clip, pipelines);
//! ```

use agg_gui::geometry::Rect;
use wgpu::util::DeviceExt;

use crate::pipelines::{TexUniforms, WgpuPipelines};

/// Map a UI-facing "samples" choice to the linear render-target scale factor
/// used when supersampling onto an oversized framebuffer.
///
/// Cell values are pixel multipliers (`1` / `4` / `9` / `16`); the linear
/// scale is `sqrt(samples)`, so a 16-sample request allocates a 4× linear
/// (= 16× pixel) backbuffer that gets bilinear-downsampled to the on-screen
/// rect.
///
/// Saved values from the old MSAA-semantics era (`0` for off) are coerced to
/// `1`.  Anything between supported steps rounds to the nearest supported
/// step so out-of-band saves don't fail loudly.
pub fn ssaa_linear_scale(requested_samples: u32) -> u32 {
    match requested_samples {
        0 | 1 => 1,
        2..=5 => 2,
        6..=12 => 3,
        _ => 4,
    }
}

/// Off-screen single-sample framebuffer for widgets that drive their own GPU
/// pipeline and composite the result onto the shared 2-D render target.
///
/// SSAA happens via *size*: the caller allocates this at `scale × {w, h}`
/// and bilinear-downsamples through [`Self::blit_to`].  This type holds no
/// concept of "samples" — it's just an offscreen colour (+ optional depth)
/// with a composite helper.
///
/// Allocated lazily; call [`Self::ensure_size`] each frame to keep the
/// attachments sized to the current widget rect × scale.
pub struct SsaaFramebuffer {
    /// Single-sample texture used as the render target and as the source of
    /// [`Self::blit_to`].
    color: (wgpu::Texture, wgpu::TextureView),
    /// Optional depth attachment, `sample_count = 1`.
    depth: Option<(wgpu::Texture, wgpu::TextureView)>,
    format: wgpu::TextureFormat,
    with_depth: bool,
    width: u32,
    height: u32,
    /// Linear sampler used by `blit_to`.  Linear minification is the
    /// downsample filter at < 4× linear; at exactly 4× linear (SSAA 16×)
    /// use [`Self::blit_downsample_4x_to`] instead — a single bilinear tap
    /// reads only 4 of the 16 source texels, losing half the AA benefit.
    blit_sampler: wgpu::Sampler,
}

impl SsaaFramebuffer {
    /// Build a fresh framebuffer at `(w, h)` with surface `format`.
    /// `with_depth = true` allocates a matching depth buffer; widgets that
    /// only need colour can pass `false`.
    ///
    /// `(w, h)` is the **physical** size of the texture, already multiplied
    /// by the desired linear SSAA scale.  Use [`ssaa_linear_scale`] to map
    /// a samples-style UI value to that multiplier.
    pub fn new(
        device: &wgpu::Device,
        w: u32,
        h: u32,
        format: wgpu::TextureFormat,
        with_depth: bool,
    ) -> Self {
        let blit_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
            label: Some("ssaa_blit"),
            address_mode_u: wgpu::AddressMode::ClampToEdge,
            address_mode_v: wgpu::AddressMode::ClampToEdge,
            address_mode_w: wgpu::AddressMode::ClampToEdge,
            mag_filter: wgpu::FilterMode::Linear,
            min_filter: wgpu::FilterMode::Linear,
            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
            ..Default::default()
        });
        let mut fb = Self {
            color: alloc_color(device, w.max(1), h.max(1), format),
            depth: None,
            format,
            with_depth,
            width: w.max(1),
            height: h.max(1),
            blit_sampler,
        };
        if with_depth {
            fb.depth = Some(alloc_depth(device, fb.width, fb.height));
        }
        fb
    }

    /// Reallocate the attachments if `(w, h)` has changed since the last call.
    /// Cheap when the size is stable.
    pub fn ensure_size(&mut self, device: &wgpu::Device, w: u32, h: u32) {
        let w = w.max(1);
        let h = h.max(1);
        if w == self.width && h == self.height {
            return;
        }
        self.width = w;
        self.height = h;
        self.color = alloc_color(device, w, h, self.format);
        if self.with_depth {
            self.depth = Some(alloc_depth(device, w, h));
        }
    }

    pub fn size(&self) -> (u32, u32) {
        (self.width, self.height)
    }

    /// View to use as the **colour attachment** of the widget's render pass.
    pub fn render_view(&self) -> &wgpu::TextureView {
        &self.color.1
    }

    /// Same view, exposed under the historic name used by post-render
    /// sampling sites (e.g. the screenshot path).
    pub fn resolve_view(&self) -> &wgpu::TextureView {
        &self.color.1
    }

    /// Colour texture handle.  Exposed so a platform shell (currently
    /// `demo-wasm`) can use this `SsaaFramebuffer` as the intermediate
    /// "scene" target — pass `resolve_texture().clone()` to
    /// [`crate::WgpuGfxCtx::set_surface_texture`] so the GPU-direct
    /// screenshot path copies from this scene texture instead of from the
    /// real swap-chain surface (which on WebGL2 cannot advertise
    /// `COPY_SRC` and so can't be the source of a `copy_texture_to_*`).
    pub fn resolve_texture(&self) -> &wgpu::Texture {
        &self.color.0
    }

    /// Depth attachment view, when one was requested at construction.
    pub fn depth_view(&self) -> Option<&wgpu::TextureView> {
        self.depth.as_ref().map(|(_, v)| v)
    }

    /// Composite the framebuffer's colour onto `target_view`'s `dst_rect`
    /// (Y-up screen-space pixels of the target).  Uses the shared 2-D
    /// textured-quad pipeline with `BLEND_STANDARD` so transparent pixels
    /// (where the widget didn't render) preserve the 2-D content
    /// underneath.
    ///
    /// At ≥4× linear minification (e.g. SSAA 16×) a single bilinear tap
    /// only reads 4 of the 16 source texels per output pixel; in that case
    /// use [`Self::blit_downsample_4x_to`] instead.  This base `blit_to`
    /// always runs the single-tap pipeline — fine for 1× / 2× / 3×
    /// minification (perfect 2×2 box at 2×).
    ///
    /// `parent_clip` is intersected with `dst_rect` to set the pass scissor —
    /// pass the framework scissor that was active when the widget called
    /// `gl_paint` / pushed its draw command.
    pub fn blit_to(
        &self,
        device: &wgpu::Device,
        encoder: &mut wgpu::CommandEncoder,
        target_view: &wgpu::TextureView,
        target_size: (u32, u32),
        dst_rect: Rect,
        parent_clip: Option<[i32; 4]>,
        pipelines: &WgpuPipelines,
    ) {
        self.blit_to_inner(
            device,
            encoder,
            target_view,
            target_size,
            dst_rect,
            parent_clip,
            &pipelines.tex_pipeline,
            pipelines,
        );
    }

    /// 3×3-box downsample variant of [`Self::blit_to`].  Use when the
    /// framebuffer is exactly 3× the linear size of `dst_rect` (SSAA 9×):
    /// runs `tex_downsample_3x_pipeline` so all 9 source texels under each
    /// output pixel contribute equally.  A single bilinear tap at 3×
    /// minification reads only the corner 2×2 block (4 of 9 texels) and
    /// entirely skips one row + one column — visually close to no AA at
    /// all — so 3× SSAA *needs* this dedicated pipeline to be worth its
    /// pixel cost.
    pub fn blit_downsample_3x_to(
        &self,
        device: &wgpu::Device,
        encoder: &mut wgpu::CommandEncoder,
        target_view: &wgpu::TextureView,
        target_size: (u32, u32),
        dst_rect: Rect,
        parent_clip: Option<[i32; 4]>,
        pipelines: &WgpuPipelines,
    ) {
        self.blit_to_inner(
            device,
            encoder,
            target_view,
            target_size,
            dst_rect,
            parent_clip,
            &pipelines.tex_downsample_3x_pipeline,
            pipelines,
        );
    }

    /// 4×4-box downsample variant of [`Self::blit_to`].  Use when the
    /// framebuffer is exactly 4× the linear size of `dst_rect` (SSAA 16×):
    /// runs `tex_downsample_4x_pipeline` so all 16 source texels under each
    /// output pixel contribute equally, instead of the 4-of-16 you'd get
    /// from a single bilinear tap.
    pub fn blit_downsample_4x_to(
        &self,
        device: &wgpu::Device,
        encoder: &mut wgpu::CommandEncoder,
        target_view: &wgpu::TextureView,
        target_size: (u32, u32),
        dst_rect: Rect,
        parent_clip: Option<[i32; 4]>,
        pipelines: &WgpuPipelines,
    ) {
        self.blit_to_inner(
            device,
            encoder,
            target_view,
            target_size,
            dst_rect,
            parent_clip,
            &pipelines.tex_downsample_4x_pipeline,
            pipelines,
        );
    }

    /// Downsample by a factor chosen at **runtime**, dispatching to the
    /// pipeline that is correct for it.
    ///
    /// Consumers that pick their supersample factor per device — rather
    /// than baking one in at compile time — need this. A renderer
    /// running on both a desktop at device-pixel-ratio 1 and a phone at
    /// ratio 3 cannot use a fixed factor: the offscreen targets are
    /// sized `factor × device pixels`, so a constant 3× multiplies an
    /// already-tripled resolution and blows past both the memory budget
    /// and `max_texture_dimension_2d`. Such a consumer computes its
    /// factor from the device scale and limits, then hands it here.
    ///
    /// Mapping, per the analysis in the individual methods:
    ///
    /// | factor | pipeline                        | why                                |
    /// |--------|---------------------------------|------------------------------------|
    /// | 0, 1   | [`Self::blit_to`]               | identity — texel centres align     |
    /// | 2      | [`Self::blit_to`]               | one bilinear tap IS the 2×2 box    |
    /// | 3      | [`Self::blit_downsample_3x_to`] | 9 taps; bilinear misses 5 of 9     |
    /// | ≥4     | [`Self::blit_downsample_4x_to`] | 4 bilinear taps = 16-texel box     |
    ///
    /// Factors above 4 clamp to the 4× pipeline rather than erroring —
    /// it degrades gracefully (a partial box) instead of failing a
    /// frame, and no caller supersamples that far today.
    #[allow(clippy::too_many_arguments)]
    pub fn blit_downsample_to(
        &self,
        device: &wgpu::Device,
        encoder: &mut wgpu::CommandEncoder,
        target_view: &wgpu::TextureView,
        target_size: (u32, u32),
        dst_rect: Rect,
        parent_clip: Option<[i32; 4]>,
        pipelines: &WgpuPipelines,
        factor: u32,
    ) {
        let pipeline = match factor {
            0 | 1 | 2 => &pipelines.tex_pipeline,
            3 => &pipelines.tex_downsample_3x_pipeline,
            _ => &pipelines.tex_downsample_4x_pipeline,
        };
        self.blit_to_inner(
            device,
            encoder,
            target_view,
            target_size,
            dst_rect,
            parent_clip,
            pipeline,
            pipelines,
        );
    }

    #[allow(clippy::too_many_arguments)]
    fn blit_to_inner(
        &self,
        device: &wgpu::Device,
        encoder: &mut wgpu::CommandEncoder,
        target_view: &wgpu::TextureView,
        target_size: (u32, u32),
        dst_rect: Rect,
        parent_clip: Option<[i32; 4]>,
        pipeline: &wgpu::RenderPipeline,
        pipelines: &WgpuPipelines,
    ) {
        let [gl_x, gl_y, gl_w, gl_h] = pixel_rect(dst_rect);
        if gl_w <= 0 || gl_h <= 0 {
            return;
        }

        let tex_uniforms = TexUniforms {
            resolution: [target_size.0 as f32, target_size.1 as f32],
            _pad: [0.0; 2],
            tint: [1.0, 1.0, 1.0, 1.0],
        };
        let tex_ub = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("ssaa_blit_uniforms"),
            contents: bytemuck::bytes_of(&tex_uniforms),
            usage: wgpu::BufferUsages::UNIFORM,
        });
        let tex_bg0 = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("ssaa_blit_bg0"),
            layout: &pipelines.tex_bgl0,
            entries: &[wgpu::BindGroupEntry {
                binding: 0,
                resource: tex_ub.as_entire_binding(),
            }],
        });
        let tex_bg1 = device.create_bind_group(&wgpu::BindGroupDescriptor {
            label: Some("ssaa_blit_bg1"),
            layout: &pipelines.tex_bgl1,
            entries: &[
                wgpu::BindGroupEntry {
                    binding: 0,
                    resource: wgpu::BindingResource::TextureView(&self.color.1),
                },
                wgpu::BindGroupEntry {
                    binding: 1,
                    resource: wgpu::BindingResource::Sampler(&self.blit_sampler),
                },
            ],
        });

        // Quad in target_view's Y-up local coords.  bl=v=1, tr=v=0 mirrors
        // the wgpu UV convention used elsewhere (e.g. `image_blit.rs`).
        let x0 = gl_x as f32;
        let y0 = gl_y as f32;
        let x1 = x0 + gl_w as f32;
        let y1 = y0 + gl_h as f32;
        let verts: [f32; 24] = [
            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,
            0.0, x0, y1, 0.0, 0.0,
        ];
        let vb = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
            label: Some("ssaa_blit_vb"),
            contents: bytemuck::cast_slice(&verts),
            usage: wgpu::BufferUsages::VERTEX,
        });

        // Convert parent_clip (Y-up) ∩ dst_rect to wgpu Y-down for set_scissor.
        let scissor = parent_clip
            .map(|[px, py, pw, ph]| {
                let x1 = (gl_x + gl_w).min(px + pw);
                let y1 = (gl_y + gl_h).min(py + ph);
                let x0c = gl_x.max(px);
                let y0c = gl_y.max(py);
                let w = (x1 - x0c).max(0);
                let h = (y1 - y0c).max(0);
                let y_down = (target_size.1 as i32 - (y0c + h)).max(0);
                (x0c.max(0) as u32, y_down as u32, w as u32, h as u32)
            })
            .unwrap_or_else(|| {
                let y_down = (target_size.1 as i32 - (gl_y + gl_h)).max(0);
                (gl_x.max(0) as u32, y_down as u32, gl_w as u32, gl_h as u32)
            });
        if scissor.2 == 0 || scissor.3 == 0 {
            return;
        }

        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: Some("ssaa_blit_pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view: target_view,
                resolve_target: None,
                depth_slice: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Load,
                    store: wgpu::StoreOp::Store,
                },
            })],
            depth_stencil_attachment: None,
            timestamp_writes: None,
            occlusion_query_set: None,
            multiview_mask: None,
        });
        pass.set_viewport(
            0.0,
            0.0,
            target_size.0 as f32,
            target_size.1 as f32,
            0.0,
            1.0,
        );
        pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
        pass.set_pipeline(pipeline);
        pass.set_bind_group(0, &tex_bg0, &[]);
        pass.set_bind_group(1, &tex_bg1, &[]);
        pass.set_vertex_buffer(0, vb.slice(..));
        pass.draw(0..6, 0..1);
        // pass dropped here; vb / bind groups kept alive by `_` below.
        drop(pass);
        let _ = (tex_ub, tex_bg0, tex_bg1, vb);
    }
}

fn alloc_color(
    device: &wgpu::Device,
    w: u32,
    h: u32,
    format: wgpu::TextureFormat,
) -> (wgpu::Texture, wgpu::TextureView) {
    let tex = device.create_texture(&wgpu::TextureDescriptor {
        label: Some("ssaa_color"),
        size: wgpu::Extent3d {
            width: w,
            height: h,
            depth_or_array_layers: 1,
        },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format,
        // `COPY_SRC` so this texture can serve as the source of a
        // `copy_texture_to_texture` / `copy_texture_to_buffer` —
        // specifically when `demo-wasm` uses an `SsaaFramebuffer` as the
        // intermediate scene buffer behind the screenshot path.  Cost is
        // a flag bit; backends that don't physically support COPY_SRC on
        // a non-swap-chain texture are extinct in practice (wgpu's
        // WebGL2 backend does support it on regular textures).
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
            | wgpu::TextureUsages::TEXTURE_BINDING
            | wgpu::TextureUsages::COPY_SRC,
        view_formats: &[],
    });
    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
    (tex, view)
}

fn alloc_depth(device: &wgpu::Device, w: u32, h: u32) -> (wgpu::Texture, wgpu::TextureView) {
    let tex = device.create_texture(&wgpu::TextureDescriptor {
        label: Some("ssaa_depth"),
        size: wgpu::Extent3d {
            width: w,
            height: h,
            depth_or_array_layers: 1,
        },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format: wgpu::TextureFormat::Depth32Float,
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
        view_formats: &[],
    });
    let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
    (tex, view)
}

fn pixel_rect(rect: Rect) -> [i32; 4] {
    let x0 = rect.x.floor() as i32;
    let y0 = rect.y.floor() as i32;
    let x1 = (rect.x + rect.width).ceil() as i32;
    let y1 = (rect.y + rect.height).ceil() as i32;
    [x0, y0, (x1 - x0).max(0), (y1 - y0).max(0)]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pixel_rect_covers_fractional_physical_extent() {
        let rect = Rect::new(10.25, 20.5, 16.5, 10.25);
        assert_eq!(pixel_rect(rect), [10, 20, 17, 11]);
    }

    #[test]
    fn ssaa_linear_scale_matches_segments() {
        assert_eq!(ssaa_linear_scale(0), 1);
        assert_eq!(ssaa_linear_scale(1), 1);
        assert_eq!(ssaa_linear_scale(4), 2);
        assert_eq!(ssaa_linear_scale(9), 3);
        assert_eq!(ssaa_linear_scale(16), 4);
        // Out-of-band rounds to nearest supported step.
        assert_eq!(ssaa_linear_scale(7), 3);
        assert_eq!(ssaa_linear_scale(100), 4);
    }
}