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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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

+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