Optional supersampling (SSAA) for the viewport, adaptive and capped to the texture limit (#1288) (#1321)

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 03:21:55 -04:00
1 parent b3df2c382b
commit 6f115ad740
9 files changed
+422 -7

No files matched your search

+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "標準",
+4
View File
@@ -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": "보통",
+4
View File
@@ -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": "标准",
+268 -6
View File
@@ -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<Supersampled>,
/// 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<wgpu::TextureView>,
depth: Option<wgpu::TextureView>,
/// 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<f32>;
@group(0) @binding(1) var smp: sampler;
struct O { @builtin(position) pos: vec4<f32>, @location(0) uv: vec2<f32> };
@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<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
o.uv = vec2<f32>(x, y);
return o;
}
@fragment
fn fs(i: O) -> @location(0) vec4<f32> {
let d = vec2<f32>(dpdx(i.uv.x), dpdy(i.uv.y)) * 0.25;
let a = textureSampleLevel(src, smp, i.uv + vec2<f32>(-d.x, -d.y), 0.0);
let b = textureSampleLevel(src, smp, i.uv + vec2<f32>(d.x, -d.y), 0.0);
let c = textureSampleLevel(src, smp, i.uv + vec2<f32>(-d.x, d.y), 0.0);
let e = textureSampleLevel(src, smp, i.uv + vec2<f32>(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<Batch>| {
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::<f32>() > 0.25;
let sectioned = cb.clip[..3].iter().map(|c| c * c).sum::<f32>() > 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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 {
+41
View File
@@ -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<serde_json::Valu
if solidcraft_engine::doc::display_quality::DisplayQuality::parse(&new.display_quality).is_none() {
return Err("`display_quality` must be coarse, normal, fine or ultra".into());
}
if !SUPERSAMPLING.contains(&new.supersampling) {
return Err("`supersampling` must be 1 (off), 1.5 or 2".into());
}
if MiddleDoubleClick::parse(&new.middle_double_click).is_none() {
return Err("`middle_double_click` must be home, fit or off".into());
}
@@ -1368,6 +1395,20 @@ mod tests {
assert_eq!(app.preferences.display_quality, "normal");
}
/// Supersampling (#1288): off by default, adaptive, set by factor and checked.
#[test]
fn supersampling_is_off_by_default_and_checked() {
use serde_json::json;
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
assert_eq!((app.preferences.supersampling, app.preferences.supersampling_adaptive), (1.0, true));
super::set(&mut app, &json!({"supersampling": 2.0})).unwrap();
assert_eq!(app.preferences.supersampling, 2.0);
assert!(super::set(&mut app, &json!({"supersampling": 3.0})).unwrap_err().contains("1 (off), 1.5 or 2"));
super::set(&mut app, &json!({"supersampling": 1.5, "supersampling_adaptive": false})).unwrap();
super::defaults(&mut app, super::PAGE_GRAPHICS);
assert_eq!((app.preferences.supersampling, app.preferences.supersampling_adaptive), (1.0, true));
}
#[test]
fn a_value_this_version_does_not_understand_keeps_the_other_preferences() {
use serde_json::json;
+1
View File
@@ -525,6 +525,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
access: None,
outline: false,
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }],
ssaa: 1.0,
};
ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback));
Ok(())
+92 -1
View File
@@ -96,6 +96,9 @@ pub struct ViewportState {
/// Spring toward the chosen orientation, including after the drag ends.
pub(crate) orbit_snap: Option<OrbitSnap>,
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<Pos2>,
/// 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