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:
1 parent
b3df2c382b
commit
6f115ad740
9 files changed
+422
-7
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "標準",
|
||||
|
||||
@@ -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": "보통",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user