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}