From 6f115ad7409af91716fe52b41bea6e6bacffcece Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 03:21:55 -0400 Subject: [PATCH] Optional supersampling (SSAA) for the viewport, adaptive and capped to the texture limit (#1288) (#1321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Optional supersampling (SSAA) for the viewport, adaptive and within the texture limit (#1288) Preferences ▸ Graphics ▸ Supersampling: Off (default), 1.5× or 2×. The frame is drawn into textures of the viewport's own kind (format, depth/stencil, MSAA) at that multiple of its pixels, over transparent black, then filtered down into the viewport with four bilinear taps (the exact 2 × 2 box at 2×) and composited premultiplied over the background. Lines keep their on-screen width; silhouettes and edges come out smoother. Adaptive (on by default): the view is drawn at full speed while the camera moves and supersampled once it has held still for a quarter of a second. The supersampled size stays within the device's largest texture side (#869) and a 384 MiB budget for colour and depth (MSAA samples included); under 1.25× is not worth it and the frame is drawn as usual. Labels in en/de/ja/ko/zh; `ui.prefs` `supersampling` and `supersampling_adaptive`. Co-Authored-By: Claude Opus 5.5 * Supersampling: factor and adaptive on one row; Off reads Off (1×) The Graphics page grew past the colour vision menu in the preferences scenario, and a second Off on the page took its click. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- assets/locales/de.json | 4 + assets/locales/en.json | 4 + assets/locales/ja.json | 4 + assets/locales/ko.json | 4 + assets/locales/zh.json | 4 + crates/ui-egui/src/gpu.rs | 274 ++++++++++++++++++++++++- crates/ui-egui/src/prefs.rs | 41 ++++ crates/ui-egui/src/render_workspace.rs | 1 + crates/ui-egui/src/viewport.rs | 93 ++++++++- 9 files changed, 422 insertions(+), 7 deletions(-) diff --git a/assets/locales/de.json b/assets/locales/de.json index 946872ce..93569ee2 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -49,6 +49,10 @@ "ui.animation": "Animation", "ui.animation_speed": "Animationsgeschwindigkeit", "ui.anti_aliasing": "Kantenglättung", + "ui.supersampling": "Supersampling (SSAA)", + "ui.supersampling_off": "Aus (1×)", + "ui.supersampling_adaptive": "Nur bei ruhender Ansicht", + "settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.", "ui.display_quality": "Darstellungsqualität", "ui.display_quality_coarse": "Grob", "ui.display_quality_normal": "Normal", diff --git a/assets/locales/en.json b/assets/locales/en.json index c06fe8c4..ef1f8c89 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -327,6 +327,10 @@ "ui.animation": "Animation", "ui.animation_speed": "Animation speed", "ui.anti_aliasing": "Anti-aliasing", + "ui.supersampling": "Supersampling (SSAA)", + "ui.supersampling_off": "Off (1×)", + "ui.supersampling_adaptive": "Only when the view is still", + "settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.", "ui.display_quality": "Display quality", "ui.display_quality_coarse": "Coarse", "ui.display_quality_normal": "Normal", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index db1df53b..c48544ec 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -327,6 +327,10 @@ "ui.animation": "アニメーション", "ui.animation_speed": "アニメーション速度", "ui.anti_aliasing": "アンチエイリアス", + "ui.supersampling": "スーパーサンプリング (SSAA)", + "ui.supersampling_off": "オフ (1×)", + "ui.supersampling_adaptive": "ビューが静止しているときのみ", + "settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。", "ui.display_quality": "表示品質", "ui.display_quality_coarse": "粗い", "ui.display_quality_normal": "標準", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index b5e11d4c..4208a776 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -49,6 +49,10 @@ "ui.animation": "애니메이션", "ui.animation_speed": "애니메이션 속도", "ui.anti_aliasing": "안티앨리어싱", + "ui.supersampling": "슈퍼샘플링 (SSAA)", + "ui.supersampling_off": "끔 (1×)", + "ui.supersampling_adaptive": "뷰가 멈춰 있을 때만", + "settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.", "ui.display_quality": "표시 품질", "ui.display_quality_coarse": "거침", "ui.display_quality_normal": "보통", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index c7c0ff28..17b3ce8f 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -326,6 +326,10 @@ "ui.animation": "动画", "ui.animation_speed": "动画速度", "ui.anti_aliasing": "抗锯齿", + "ui.supersampling": "超级采样 (SSAA)", + "ui.supersampling_off": "关 (1×)", + "ui.supersampling_adaptive": "仅在视图静止时", + "settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。", "ui.display_quality": "显示质量", "ui.display_quality_coarse": "粗糙", "ui.display_quality_normal": "标准", diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index dfab108f..842155c4 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -243,6 +243,9 @@ pub struct ViewportCallback { /// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth /// and the depth bias. pub eye: [f32; 4], + /// Supersampling (#1288): draw the frame this many times the viewport's pixels in each + /// direction and filter it down (1: off). + pub ssaa: f32, } /// Heights of the model seen from a direction (an orthographic depth map): a point is reachable @@ -474,8 +477,77 @@ struct Resources { /// The accessibility depth map (a 1 × 1 stand-in when there is none) and its version. access_bind: wgpu::BindGroup, access_version: u64, + /// The target the pipelines draw into (format, depth, samples): a supersampled frame is + /// drawn into textures of the same kind. + target: GpuTarget, + /// The device's largest 2D texture side (#869). + max_texture: u32, + /// Supersampling (#1288): the filter that draws the supersampled frame into the viewport. + ss_blit: wgpu::RenderPipeline, + ss_bgl: wgpu::BindGroupLayout, + /// The supersampled frame's textures, kept while its size stays. + ss: Option, + /// This frame was drawn supersampled (in `prepare`): `paint` only filters it down. + ss_ready: bool, } +/// A supersampled frame: the scene drawn at a multiple of the viewport's pixels. +struct Supersampled { + size: [u32; 2], + /// Drawn into (multisampled with the viewport's MSAA). + color: wgpu::TextureView, + /// The resolved colour (when `color` is multisampled). + resolve: Option, + depth: Option, + /// The single-sampled colour, for the filter. + bind: wgpu::BindGroup, +} + +/// Most memory (bytes) a supersampled frame's colour and depth may take. +const SS_BUDGET: u64 = 384 << 20; + +/// The size (px) to draw a `size` viewport supersampled `factor` times in each direction: +/// within the device's largest texture side (#869) and the memory budget (at `samples` per pixel, +/// four bytes of colour and four of depth each). `None` when that leaves under 1.25×: the frame +/// is drawn as usual. +pub(crate) fn supersampled_size(size: [f32; 2], factor: f32, max_side: u32, samples: u32) -> Option<[u32; 2]> { + let [w, h] = size; + if !(w >= 1.0 && h >= 1.0 && factor.is_finite() && factor > 1.0) { + return None; + } + let side = max_side as f32; + let bytes_per_px = 8.0 * samples.max(1) as f32; + let by_memory = (SS_BUDGET as f32 / (w * h * bytes_per_px)).sqrt(); + let f = factor.min(side / w).min(side / h).min(by_memory); + (f >= 1.25).then(|| [((w * f).floor() as u32).clamp(1, max_side), ((h * f).floor() as u32).clamp(1, max_side)]) +} + +/// The filter: each viewport pixel the mean of the supersampled pixels under it (four bilinear +/// taps a quarter of its footprint from its centre: exactly the 2 × 2 box at 2×). +const SS_SHADER: &str = r#" +@group(0) @binding(0) var src: texture_2d; +@group(0) @binding(1) var smp: sampler; +struct O { @builtin(position) pos: vec4, @location(0) uv: vec2 }; +@vertex +fn vs(@builtin(vertex_index) i: u32) -> O { + let x = f32((i << 1u) & 2u); + let y = f32(i & 2u); + var o: O; + o.pos = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + o.uv = vec2(x, y); + return o; +} +@fragment +fn fs(i: O) -> @location(0) vec4 { + let d = vec2(dpdx(i.uv.x), dpdy(i.uv.y)) * 0.25; + let a = textureSampleLevel(src, smp, i.uv + vec2(-d.x, -d.y), 0.0); + let b = textureSampleLevel(src, smp, i.uv + vec2(d.x, -d.y), 0.0); + let c = textureSampleLevel(src, smp, i.uv + vec2(-d.x, d.y), 0.0); + let e = textureSampleLevel(src, smp, i.uv + vec2(d.x, d.y), 0.0); + return (a + b + c + e) * 0.25; +} +"#; + fn access_texture(device: &wgpu::Device, queue: Option<&wgpu::Queue>, bgl: &wgpu::BindGroupLayout, size: u32, depth: &[f32]) -> wgpu::BindGroup { let extent = wgpu::Extent3d { width: size.max(1), height: size.max(1), depth_or_array_layers: 1 }; let tex = device.create_texture(&wgpu::TextureDescriptor { @@ -766,7 +838,63 @@ impl Resources { min_filter: wgpu::FilterMode::Linear, ..Default::default() }); + let ss_module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_ssaa"), source: wgpu::ShaderSource::Wgsl(SS_SHADER.into()) }); + let ss_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_ssaa"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let ss_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_ssaa"), + bind_group_layouts: &[Some(&ss_bgl)], + immediate_size: 0, + }); + // The frame is premultiplied (drawn over transparent black), so it goes over the + // viewport's background premultiplied; the viewport's depth is left as it is. + let ss_blit = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_ssaa"), + layout: Some(&ss_layout), + vertex: wgpu::VertexState { module: &ss_module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: depth(wgpu::CompareFunction::Always, false), + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &ss_module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { + format: t.format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); Resources { + target: t, + max_texture: device.limits().max_texture_dimension_2d, + ss_blit, + ss_bgl, + ss: None, + ss_ready: false, image, image_bgl, sampler, @@ -998,6 +1126,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { res.quads.push((img.id, b)); } } + res.ss_ready = self.ssaa > 1.0 && res.supersample(device, encoder, self); Vec::new() } @@ -1008,6 +1137,23 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { return; } pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0); + // Drawn supersampled in `prepare`: filter it down into the viewport. + if res.ss_ready + && let Some(ss) = &res.ss + { + pass.set_pipeline(&res.ss_blit); + pass.set_bind_group(0, &ss.bind, &[]); + pass.draw(0..3, 0..1); + return; + } + res.draw(self, pass); + } +} + +impl Resources { + /// Draw the frame's scenes into `pass` (its viewport already set). + fn draw(&self, cb: &ViewportCallback, pass: &mut wgpu::RenderPass<'static>) { + let res = self; pass.set_bind_group(0, &res.bind, &[]); let tris = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option| { if let Some(b) = b { @@ -1028,7 +1174,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { } }; let (m, sk, h, pv) = - if self.product { (&res.product, &res.empty, &res.empty, &res.empty) } else { (&res.model, &res.sketch, &res.highlight, &res.preview) }; + if cb.product { (&res.product, &res.empty, &res.empty, &res.empty) } else { (&res.model, &res.sketch, &res.highlight, &res.preview) }; lines(pass, &res.overlay, &m.under); lines(pass, &res.overlay, &sk.under); pass.set_bind_group(1, &res.access_bind, &[]); @@ -1041,7 +1187,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { pass.draw_indexed(0..d.mesh.index_count, 0, 0..1); } }; - let sectioned = self.clip[..3].iter().map(|c| c * c).sum::() > 0.25; + let sectioned = cb.clip[..3].iter().map(|c| c * c).sum::() > 0.25; match (res.tri_cut.as_ref().filter(|_| sectioned), res.tri_cut_planned.as_ref()) { (Some((mark, cap)), Some((mark_planned, cap_planned))) => { pass.set_stencil_reference(1); @@ -1067,7 +1213,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { lines(pass, &res.line, &pv.lines); lines(pass, &res.line, &h.lines); lines(pass, &res.guide_line, &h.guide_lines); - for (id, q) in res.quads.iter().filter(|_| !self.product) { + for (id, q) in res.quads.iter().filter(|_| !cb.product) { if let Some((_, bind)) = res.textures.get(id) { pass.set_pipeline(&res.image); pass.set_bind_group(1, bind, &[]); @@ -1091,6 +1237,83 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { } } +impl Resources { + /// Draw the frame supersampled (`cb.ssaa` times the viewport's pixels each way, within the + /// device's texture limit and the memory budget) into textures of the viewport's kind. + /// `false` when it cannot be (too small a gain, a zero-sized viewport): drawn as usual. + fn supersample(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, cb: &ViewportCallback) -> bool { + let Some(size) = supersampled_size(cb.size_px, cb.ssaa, self.max_texture, self.target.samples) else { + self.ss = None; + return false; + }; + if self.ss.as_ref().is_none_or(|ss| ss.size != size) { + self.ss = Some(self.supersampled(device, size)); + } + let Some(ss) = &self.ss else { return false }; + let depth_format = self.target.depth; + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("sc_ssaa"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &ss.color, + depth_slice: None, + resolve_target: ss.resolve.as_ref(), + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, + })], + depth_stencil_attachment: ss.depth.as_ref().map(|view| wgpu::RenderPassDepthStencilAttachment { + view, + depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), + stencil_ops: depth_format + .filter(|f| f.has_stencil_aspect()) + .map(|_| wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }), + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + pass.set_viewport(0.0, 0.0, size[0] as f32, size[1] as f32, 0.0, 1.0); + self.draw(cb, &mut pass); + true + } + + /// Textures for a supersampled frame of `size` pixels. + fn supersampled(&self, device: &wgpu::Device, size: [u32; 2]) -> Supersampled { + let t = self.target; + let texture = |label: &str, format: wgpu::TextureFormat, samples: u32, usage: wgpu::TextureUsages| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: samples, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + .create_view(&Default::default()) + }; + let samples = t.samples.max(1); + let sampled = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; + let (color, resolve) = if samples > 1 { + (texture("sc_ssaa_ms", t.format, samples, wgpu::TextureUsages::RENDER_ATTACHMENT), Some(texture("sc_ssaa", t.format, 1, sampled))) + } else { + (texture("sc_ssaa", t.format, 1, sampled), None) + }; + let depth = t.depth.map(|f| texture("sc_ssaa_depth", f, samples, wgpu::TextureUsages::RENDER_ATTACHMENT)); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_ssaa"), + layout: &self.ss_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(resolve.as_ref().unwrap_or(&color)) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + ], + }); + Supersampled { size, color, resolve, depth, bind } + } +} + /// A device of its own for an offscreen render or check (`None` without a GPU adapter). #[cfg(all(test, not(target_arch = "wasm32")))] fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { @@ -1132,7 +1355,15 @@ pub(crate) fn render_offscreen( analysis: [f32; 8], colors: [f32; 36], ) -> Option> { - offscreen(scene, highlight, view_proj, back, eye, size, Offscreen { tess, product: false, srgb: false, analysis, analysis_colors: colors }) + offscreen( + scene, + highlight, + view_proj, + back, + eye, + size, + Offscreen { tess, product: false, srgb: false, analysis, analysis_colors: colors, ssaa: 1.0 }, + ) } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -1144,7 +1375,15 @@ pub(crate) fn render_product_offscreen( size: u32, srgb: bool, ) -> Option> { - offscreen(scene, None, view_proj, back, eye, size, Offscreen { tess: None, product: true, srgb, analysis: [0.0; 8], analysis_colors: [0.0; 36] }) + offscreen( + scene, + None, + view_proj, + back, + eye, + size, + Offscreen { tess: None, product: true, srgb, analysis: [0.0; 8], analysis_colors: [0.0; 36], ssaa: 1.0 }, + ) } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -1154,6 +1393,28 @@ struct Offscreen<'a> { srgb: bool, analysis: [f32; 8], analysis_colors: [f32; 36], + ssaa: f32, +} + +/// [`render_offscreen`] of a scene supersampled `ssaa` times (1: off). +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn render_offscreen_ssaa( + scene: GpuScene, + view_proj: [[f32; 4]; 4], + back: [f32; 3], + eye: [f32; 4], + size: u32, + ssaa: f32, +) -> Option> { + offscreen( + scene, + None, + view_proj, + back, + eye, + size, + Offscreen { tess: None, product: false, srgb: false, analysis: [0.0; 8], analysis_colors: [0.0; 36], ssaa }, + ) } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -1166,7 +1427,7 @@ fn offscreen( size: u32, options: Offscreen<'_>, ) -> Option> { - let Offscreen { tess, product, srgb, analysis, analysis_colors } = options; + let Offscreen { tess, product, srgb, analysis, analysis_colors, ssaa } = options; let (adapter, device, queue) = offscreen_device()?; eprintln!("offscreen adapter: {:?}", adapter.get_info().backend); let format = if srgb { wgpu::TextureFormat::Rgba8UnormSrgb } else { wgpu::TextureFormat::Rgba8Unorm }; @@ -1197,6 +1458,7 @@ fn offscreen( access: None, outline: false, eye, + ssaa, }; let texture = |format, usage| { device.create_texture(&wgpu::TextureDescriptor { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 6a6bc5f1..8b801961 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -205,6 +205,8 @@ pub const PAGE_DESIGN: usize = 3; pub const PAGE_GRAPHICS: usize = 4; pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")]; pub const MSAA: [u32; 4] = [1, 2, 4, 8]; +/// Supersampling factors (#1288): off, 1.5× and 2× the viewport's pixels each way. +pub const SUPERSAMPLING: [f32; 3] = [1.0, 1.5, 2.0]; /// Original view navigators, independent of the mouse navigation preset. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -251,6 +253,11 @@ pub struct Prefs { /// How finely the viewport tessellates curved faces and edges: `coarse`, `normal`, `fine` or /// `ultra` (#1287). Only the display plan the viewport draws; never the design or exports. pub display_quality: String, + /// Supersampling (#1288): the view drawn at this many times its pixels each way and filtered + /// down (1, 1.5 or 2; 1 is off). + pub supersampling: f32, + /// Supersample only while the view holds still (full speed while orbiting). + pub supersampling_adaptive: bool, /// Decimals shown for lengths and angles. pub length_decimals: u8, pub angle_decimals: u8, @@ -331,6 +338,8 @@ impl Default for Prefs { space_mouse_speed: 1.0, msaa: 4, display_quality: "normal".into(), + supersampling: 1.0, + supersampling_adaptive: true, length_decimals: 2, angle_decimals: 1, theme: "dark".into(), @@ -472,6 +481,8 @@ fn defaults(app: &mut SolveApp, page: usize) { let msaa = p.msaa != d.msaa; p.msaa = d.msaa; p.display_quality = d.display_quality; + p.supersampling = d.supersampling; + p.supersampling_adaptive = d.supersampling_adaptive; p.color_vision = d.color_vision; app.ui.visual_style = 0; app.ui.show_grid = true; @@ -1118,6 +1129,19 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { } ui.end_row(); note(ui, app.tr("settings.display_quality_note")); + ui.label(app.tr("ui.supersampling")); + let ss_label = |f: f32| if f > 1.0 { format!("{f}×") } else { crate::i18n::tr("ui.supersampling_off").to_string() }; + let adaptive = app.tr("ui.supersampling_adaptive").to_string(); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("pref_ssaa").selected_text(ss_label(app.preferences.supersampling)).show_ui(ui, |ui| { + for f in SUPERSAMPLING { + ui.selectable_value(&mut app.preferences.supersampling, f, ss_label(f)); + } + }); + ui.add_enabled(app.preferences.supersampling > 1.0, egui::Checkbox::new(&mut app.preferences.supersampling_adaptive, adaptive)); + }); + ui.end_row(); + note(ui, app.tr("settings.supersampling_note")); ui.label(crate::i18n::tr_label("Renderer")); ui.label(match (&app.viewport.gpu, &app.help.gpu) { (Some(_), Some(g)) => format!("GPU: {g}"), @@ -1332,6 +1356,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result 0.0 { 1.0 } else { 0.0 }], + ssaa: 1.0, }; ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback)); Ok(()) diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 3f811d7b..d381e926 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -96,6 +96,9 @@ pub struct ViewportState { /// Spring toward the chosen orientation, including after the drag ends. pub(crate) orbit_snap: Option, pub build_ms: f64, + /// Adaptive supersampling (#1288): the view drawn last (its view-projection and pixels) and + /// since when it has held still (app time, s). + pub(crate) ss_view: (u64, f64), /// The right-click menu, open at this screen position. pub context_menu: Option, /// Select Other: its list, a press being held, and click cycling. @@ -331,6 +334,37 @@ fn body_faded(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, active_bodi } } +/// How many times the viewport's pixels to draw this frame each way (#1288; 1: off): the +/// preference, and with adaptive supersampling only once the view has held still for a moment, +/// so orbiting and zooming stay as quick as without it. +fn supersampling(app: &mut SolveApp, ctx: &egui::Context, view_proj: [[f32; 4]; 4], size_px: [f32; 2]) -> f32 { + let factor = app.preferences.supersampling; + if !(factor > 1.0) { + return 1.0; + } + if !app.preferences.supersampling_adaptive { + return factor; + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + view_proj.iter().flatten().chain(&size_px).for_each(|x| x.to_bits().hash(&mut h)); + let view = h.finish(); + let now = ctx.input(|i| i.time); + if view != app.viewport.ss_view.0 { + app.viewport.ss_view = (view, now); + } + let still = now - app.viewport.ss_view.1; + if still >= SS_SETTLE { + factor + } else { + // Draw it supersampled once the view settles. + ctx.request_repaint_after(std::time::Duration::from_secs_f64(SS_SETTLE - still + 0.01)); + 1.0 + } +} + +/// How long the view holds still before adaptive supersampling draws it at full quality (s). +const SS_SETTLE: f64 = 0.25; + /// The display plan the model scene draws `b` from on the GPU, or `None` when its faces are built /// here from the reference mesh. Opaque shaded bodies (`opaque`: not faded, and no see-through /// body or face) are drawn from their plans where the device evaluates them; faded and @@ -2423,7 +2457,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { app.viewport.hl_key = hk; } let ppp = ui.ctx().pixels_per_point(); + let size_px = [rect.width() * ppp, rect.height() * ppp]; + let ssaa = supersampling(app, ui.ctx(), proj.vp.to_f32(), size_px); let cb = ViewportCallback { + ssaa, product: false, key: app.viewport.key, slot: app.viewport.slot.clone(), @@ -2437,7 +2474,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { cap: t.section_cap.to_normalized_gamma_f32(), view_proj: proj.vp.to_f32(), back: proj.cam.back().to_f32(), - size_px: [rect.width() * ppp, rect.height() * ppp], + size_px, images: crate::ref_images::gpu_images(app), analysis: analysis_uniform(app), analysis_colors: crate::gpu::analysis_colors(&t.analysis), @@ -3739,6 +3776,60 @@ mod tests { assert!(drawn > n / 20, "the model is drawn: {drawn} pixels"); } + /// Supersampling (#1288): the sample design drawn at 2× and filtered down shows the same + /// model (the same pixels covered, give or take its outline) with smoothed edges: pixels part + /// model, part background, which a single-sampled frame does not have. Skipped without a GPU + /// adapter. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn supersampling_draws_the_same_model_with_smooth_edges() { + let mut s = solidcraft_engine::Session::default(); + s.run_script(&solidcraft_engine::sample::script()).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.viewport.no_pixels = true; + app.fit_view(); + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); + const SIZE: u32 = 256; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); + let proj = projection(&app, rect); + let e = proj.cam.eye().to_f32(); + let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]; + let render = + |ssaa: f32| crate::gpu::render_offscreen_ssaa(build_scene(&app).merged(), proj.vp.to_f32(), proj.cam.back().to_f32(), eye, SIZE, ssaa); + let (Some(plain), Some(super2)) = (render(1.0), render(2.0)) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let covered = |img: &[u8]| img.chunks(4).filter(|p| p[..3] != [255, 255, 255]).count(); + let (a, b) = (covered(&plain), covered(&super2)); + assert!(a > 2000, "the model is drawn: {a} pixels"); + assert!((a as f64 - b as f64).abs() < a as f64 * 0.1, "same model: {a} vs {b} pixels covered"); + // Edge pixels against the white background: neither white nor any colour the plain frame + // has, so blended. A count well above the plain frame's own. + let palette: std::collections::HashSet<[u8; 3]> = plain.chunks(4).map(|p| [p[0], p[1], p[2]]).collect(); + let new_colours = super2.chunks(4).filter(|p| !palette.contains(&[p[0], p[1], p[2]])).count(); + assert!(new_colours > 100, "the supersampled frame blends its edges: {new_colours} new colours"); + } + + /// The supersampled frame stays within the device's largest texture (#869) and a memory + /// budget, and is not drawn at all when that leaves too little to gain. + #[test] + fn supersampling_keeps_within_the_device_texture_limit() { + use crate::gpu::supersampled_size; + assert_eq!(supersampled_size([1920.0, 1080.0], 2.0, 16384, 1), Some([3840, 2160])); + assert_eq!(supersampled_size([1920.0, 1080.0], 1.5, 16384, 1), Some([2880, 1620])); + // A 4096 texture limit: a 2560 px wide view gets 1.6×. + let [w, h] = supersampled_size([2560.0, 1440.0], 2.0, 4096, 1).unwrap(); + assert!((4095..=4096).contains(&w) && (2303..=2304).contains(&h), "{w} x {h}"); + // Under 1.25× is not worth it: drawn as usual. + assert_eq!(supersampled_size([3840.0, 2160.0], 2.0, 4096, 1), None); + assert_eq!(supersampled_size([1920.0, 1080.0], 1.0, 16384, 1), None); + assert_eq!(supersampled_size([0.0, 1080.0], 2.0, 16384, 1), None); + // 4× MSAA at 1440p: the memory budget caps it below 2×. + let [w, h] = supersampled_size([2560.0, 1440.0], 2.0, 16384, 4).unwrap(); + assert!(w < 5120 && w as f32 >= 2560.0 * 1.25 && u64::from(w) * u64::from(h) * 32 <= 384 << 20, "{w} x {h}"); + } + /// A hovered face lights solidly over a body drawn from its display plan on the GPU: the /// highlight uses the plan's triangles, not the reference mesh's other triangulation of the /// same face, which fought the model at equal depth and shimmered in wedges. Every face of a