From c5f351507337b30025ce2d76f3fb6c93876c9f29 Mon Sep 17 00:00:00 2001 From: CamWight <91156642+CamWight@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:26:19 +0100 Subject: [PATCH] Viewport: hidden edges no longer show through faces, and thin or coplanar faces stop z-fighting (#1434, #1739) (#1768) Thanks @CamWight. --- apps/solidcraft-web/src/main.rs | 4 +- apps/solidcraft/src/main.rs | 19 ++- apps/solidcraft/src/startup.rs | 17 +++ assets/shaders/viewport.wgsl | 66 +++++---- crates/gpu/src/capture.rs | 41 ++++-- crates/gpu/src/lib.rs | 21 +++ crates/gpu/src/view.rs | 172 +++++++++++++++++++--- crates/gpu/tests/depth.rs | 243 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/gpu.rs | 3 +- crates/ui-egui/src/viewport.rs | 75 ++++++++++ 10 files changed, 599 insertions(+), 62 deletions(-) create mode 100644 crates/gpu/tests/depth.rs diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index ad0cd875..e50cb9da 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -27,7 +27,9 @@ mod web { const CANVAS_ID: &str = "solidcraft_canvas"; const LOADING_ID: &str = "solidcraft_loading"; - const DEPTH_BITS: u8 = 24; + /// A 32-bit float depth buffer (no stencil): WebGPU and WebGL2 both have it, and the viewport + /// draws it with reversed depth, whose precision follows the distance from the eye (#1434). + const DEPTH_BITS: u8 = 32; /// Seconds between autosaves of unsaved work. const AUTOSAVE_S: f64 = 30.0; diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 3d36ed26..8e6ebea2 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -416,6 +416,10 @@ fn msaa() -> u16 { const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); const DEPTH_BITS: u8 = 24; +/// A 32-bit float depth buffer, drawn with reversed depth (1 near, 0 far) so its precision follows +/// the distance from the eye (#1434). Only where every adapter offers it with a stencil +/// ([`startup::float_depth_everywhere`]); else [`DEPTH_BITS`]. Not in safe mode. +const FLOAT_DEPTH_BITS: u8 = 32; /// The section cap marks where it shows in the stencil. const STENCIL_BITS: u8 = 8; const MSAA: u16 = 4; @@ -497,6 +501,8 @@ fn main() -> eframe::Result { notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into()); } let samples = if safe_mode { MSAA } else { msaa() }; + let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()); + let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS }; // Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md). let crashed = solidcraft_engine::crash::default_dir() .map(|dir| { @@ -516,7 +522,7 @@ fn main() -> eframe::Result { solidcraft_engine::crash::phase("window"); let options = eframe::NativeOptions { viewport: app_viewport()?, - depth_buffer: DEPTH_BITS, + depth_buffer: depth_bits, stencil_buffer: STENCIL_BITS, multisampling: samples, // Kept with the other settings, not in a folder named after the app id. Safe mode reads @@ -529,7 +535,7 @@ fn main() -> eframe::Result { settings.as_ref().map(|d| d.join("app.ron")) }, persist_window: !safe_mode, - wgpu_options: wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()), + wgpu_options, ..Default::default() }; #[cfg(all(unix, not(target_os = "macos")))] @@ -579,7 +585,7 @@ fn main() -> eframe::Result { if let Some(rs) = &cc.wgpu_render_state { eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples); solidcraft_engine::crash::phase("viewport pipelines (shader compile)"); - app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples); + app.set_wgpu(rs, depth_bits, STENCIL_BITS, rs.msaa_samples); solidcraft_ui_egui::gpu::install_picture_renderer(rs); // The graphics driver is loaded now; it may have put its own exception filter // in front of ours. @@ -685,6 +691,13 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC if cpu { setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter)); } + // A float depth buffer with a stencil, wherever the adapter has it (see `FLOAT_DEPTH_BITS`). + let base = setup.device_descriptor.clone(); + setup.device_descriptor = std::sync::Arc::new(move |adapter| { + let mut d = base(adapter); + d.required_features |= adapter.features() & startup::FLOAT_DEPTH_STENCIL; + d + }); } options } diff --git a/apps/solidcraft/src/startup.rs b/apps/solidcraft/src/startup.rs index 0fda0365..cb94b441 100644 --- a/apps/solidcraft/src/startup.rs +++ b/apps/solidcraft/src/startup.rs @@ -157,6 +157,23 @@ pub fn choose_backends( Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12)) } +/// The device feature for a 32-bit float depth buffer with a stencil (reversed depth, #1434). +pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::DEPTH32FLOAT_STENCIL8; + +/// Whether every adapter `options` could start offers a float depth buffer with a stencil. eframe +/// makes the window's depth buffer from the depth bits before it picks the adapter, so the window +/// asks for a float one only when whichever it picks has it. +pub fn float_depth_everywhere(options: &eframe::egui_wgpu::WgpuConfiguration) -> bool { + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &options.wgpu_setup else { return false }; + let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle_from_env(); + desc.backends = setup.instance_descriptor.backends; + let instance = eframe::wgpu::Instance::new(desc); + let adapters = pollster::block_on(instance.enumerate_adapters(setup.instance_descriptor.backends)); + let all = !adapters.is_empty() && adapters.iter().all(|a| a.features().contains(FLOAT_DEPTH_STENCIL)); + log::info!("graphics: float depth with stencil on every adapter: {all}"); + all +} + /// The order backends are tried in on Windows. pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL]; diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl index 49479153..4842fbe1 100644 --- a/assets/shaders/viewport.wgsl +++ b/assets/shaders/viewport.wgsl @@ -2,7 +2,7 @@ struct U { vp: mat4x4, back: vec4, // xyz = toward the eye, w = 1 for linear output - screen: vec4, // viewport width, height (px), depth bias (see `biased_z`), silhouettes on (1) + screen: vec4, // viewport width, height (px), unused, silhouettes on (1) clip: vec4, // section plane: normal, d (zero normal: no section) cap: vec4, // colour of the inside of cut bodies ana: vec4, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter @@ -14,8 +14,19 @@ struct U { // blocked; curvature flat, middle, tight ana_c: array, 8>, ana_f: vec4, // x = 1: stripe the negative draft and blocked faces + depth: vec4, // x = 1: reversed depth (1 near, 0 far); y, z: the faces' push (`FACE_BIAS`) }; +fn reversed() -> bool { + return u.depth.x > 0.5; +} + +/// Clip-space depth measured the usual way (0 at the near plane, w at the far one), whichever +/// way the depth buffer runs. +fn near_z(c: vec4) -> f32 { + return select(c.z, c.w - c.z, reversed()); +} + @group(1) @binding(2) var acc_t: texture_2d; fn clipped(p: vec3) -> bool { @@ -70,11 +81,22 @@ struct CutOut { @fragment fn fs_solid_cut(i: TOut, @builtin(front_facing) front: bool) -> CutOut { var o: CutOut; + // Derivatives (in `pushed`) first, before `solid` may discard. + o.depth = pushed(cap_depth(i.wp, i.pos.z)); o.c = solid(i, front); - o.depth = cap_depth(i.wp, i.pos.z); return o; } +/// A depth the shader writes itself, pushed back like the faces the GPU pushes (`FACE_BIAS`, +/// which does not apply then): its own slope over `depth.z` pixels and `depth.y` depth steps. +fn pushed(z: f32) -> f32 { + let slope = max(abs(dpdx(z)), abs(dpdy(z))); + // One step: 2⁻²⁴ in a 24-bit buffer; a float buffer's steps follow the value (2⁻²³ of it). + let step = select(1.0 / 16777216.0, abs(z) * 1.1920929e-7, reversed()); + let push = slope * u.depth.z + step * u.depth.y; + return clamp(z - select(-push, push, reversed()), 0.0, 1.0); +} + /// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut), /// never behind the wall itself (`z`). fn cap_depth(wp: vec3, z: f32) -> f32 { @@ -85,6 +107,7 @@ fn cap_depth(wp: vec3, z: f32) -> f32 { let p = wp + dir * ((u.clip.w - dot(u.clip.xyz, wp)) / dn); let c = u.vp * vec4(p, 1.0); if (c.w <= 0.0) { return z; } + if (reversed()) { return clamp(c.z / c.w, z, 1.0); } return clamp(c.z / c.w, 0.0, z); } @@ -223,28 +246,8 @@ fn shade(i: TOut) -> vec4 { return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), i.c.a)); } -/// Clip-space z of `p` (clipped to `c`) pulled `s` depth biases toward the eye (pushed away -/// when negative). Orthographic depth is linear in distance, so the bias is a fixed step of it. -/// Perspective depth is not: near the far plane a fixed step spans tens of millimetres (edges -/// showed through walls), so the point moves toward the eye by a fraction of its distance. -fn biased_z(p: vec3, c: vec4, s: f32) -> f32 { - if (u.eye.w > 0.5) { - let q = u.vp * vec4(mix(p, u.eye.xyz, u.screen.z * 8.0 * s), 1.0); - return q.z / max(q.w, 1e-6) * c.w; - } - return c.z - u.screen.z * s * c.w; -} - -@vertex -fn vs_ghost(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { - var o: TOut; - o.pos = u.vp * vec4(p, 1.0); - o.pos.z = biased_z(p, o.pos, -4.0); - o.n = n; - o.c = c; - o.wp = p; - return o; -} +// Lines, decals and ghosts are drawn at their own depth: opaque faces are pushed back by their +// slope instead (`FACE_BIAS`, #1434), so what lies on a face draws over it at any angle. struct LOut { @builtin(position) pos: vec4, @@ -261,12 +264,14 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location( // An end behind the near plane (clip z < 0) has no screen point: its divide flips the // line or drops it whole (ground grid lines running past the eye at a low angle). Cut // the segment at the plane instead; clip space is linear in the world point. - if (ca.z < 0.0 && cb.z > 0.0) { - let k = ca.z / (ca.z - cb.z); + let za = near_z(ca); + let zb = near_z(cb); + if (za < 0.0 && zb > 0.0) { + let k = za / (za - zb); ca = mix(ca, cb, k); pa = mix(a, b, k); - } else if (cb.z < 0.0 && ca.z > 0.0) { - let k = cb.z / (cb.z - ca.z); + } else if (zb < 0.0 && za > 0.0) { + let k = zb / (zb - za); cb = mix(cb, ca, k); pb = mix(b, a, k); } @@ -285,7 +290,7 @@ fn vs_line(@builtin(vertex_index) vi: u32, @location(0) a: vec3, @location( let base = select(ca, cb, t > 0.5); let off = nrm * s * w * 0.5 + dir * (t * 2.0 - 1.0) * w * 0.5; var o: LOut; - o.pos = vec4(base.xy + off / half * base.w, biased_z(select(pa, pb, t > 0.5), base, 1.0), base.w); + o.pos = vec4(base.xy + off / half * base.w, base.z, base.w); o.c = c; o.wp = select(pa, pb, t > 0.5); return o; @@ -310,9 +315,8 @@ struct IOut { @vertex fn vs_img(@location(0) p: vec3, @location(1) uv: vec2, @location(2) a: f32) -> IOut { var o: IOut; + // On a face it shows over it: the face is pushed back (`FACE_BIAS`). o.pos = u.vp * vec4(p, 1.0); - // Pulled toward the eye like lines, so a decal on a face is not lost in it. - o.pos.z = biased_z(p, o.pos, 1.0); o.uv = uv; o.a = a; o.wp = p; diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs index 07d4e1b9..00a4c930 100644 --- a/crates/gpu/src/capture.rs +++ b/crates/gpu/src/capture.rs @@ -20,7 +20,6 @@ use crate::{GpuError, Result, popped}; /// The colour format pictures are drawn in: sRGB bytes as they are, like the viewport's usual /// (non-sRGB) target. pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; -const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8; /// Samples per pixel (MSAA), as the desktop viewport's default; 1 where the device has no 4×. pub const SAMPLES: u32 = 4; /// The largest picture side (pixels); the device's texture limit may lower it. @@ -39,6 +38,8 @@ pub struct Capturer { /// The adapter is a GPU driver running on the CPU (llvmpipe, lavapipe, WARP). software: bool, samples: u32, + /// The depth format: 32-bit float (reversed depth) where the device has it ([`crate::depth_format`]). + depth: wgpu::TextureFormat, max_side: u32, state: Mutex, } @@ -54,17 +55,25 @@ struct State { impl Capturer { /// The pipelines on an existing device (the application's). pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result { + let depth = crate::depth_format(&device, true); + Self::with_depth(adapter, device, queue, depth) + } + + /// As [`Capturer::new`], drawing with the depth format `depth` (with a stencil): the fallback + /// `Depth24PlusStencil8` on a device that has a float one too, to compare the two. + pub fn with_depth(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue, depth: wgpu::TextureFormat) -> Result { let flags = |f: wgpu::TextureFormat| adapter.get_texture_format_features(f).flags; - let samples = if flags(FORMAT).sample_count_supported(SAMPLES) && flags(DEPTH).sample_count_supported(SAMPLES) { SAMPLES } else { 1 }; + let samples = if flags(FORMAT).sample_count_supported(SAMPLES) && flags(depth).sample_count_supported(SAMPLES) { SAMPLES } else { 1 }; let scopes = error_scopes(&device); - let renderer = Renderer::new(&device, GpuTarget { format: FORMAT, depth: Some(DEPTH), samples, tess: false }, None); - let background = background_pipeline(&device, samples); + let renderer = Renderer::new(&device, GpuTarget { format: FORMAT, depth: Some(depth), samples, tess: false }, None); + let background = background_pipeline(&device, samples, depth); caught(scopes).map_or(Ok(()), |e| Err(GpuError::Pipeline(e)))?; let max_side = device.limits().max_texture_dimension_2d.min(MAX_SIDE); Ok(Capturer { adapter: adapter.get_info().name, software: adapter.get_info().device_type == wgpu::DeviceType::Cpu, samples, + depth, max_side, state: Mutex::new(State { renderer, background, key: 0, access_version: 0 }), device, @@ -82,6 +91,19 @@ impl Capturer { Self::new(&adapter, device, queue) } + /// As [`Capturer::headless`], drawing with the depth format `depth` ([`Capturer::with_depth`]). + #[cfg(not(target_arch = "wasm32"))] + pub fn headless_with_depth(depth: wgpu::TextureFormat) -> Result { + let (adapter, device, queue) = crate::open_headless()?; + device.on_uncaptured_error(Arc::new(|e: wgpu::Error| log::error!("GPU: {e}"))); + Self::with_depth(&adapter, device, queue, depth) + } + + /// The depth format pictures are drawn with; float formats are drawn with reversed depth. + pub fn depth_format(&self) -> wgpu::TextureFormat { + self.depth + } + /// The adapter's name. pub fn adapter_name(&self) -> &str { &self.adapter @@ -132,7 +154,7 @@ impl Capturer { }; let resolved = texture("sc_capture", FORMAT, 1, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC); let multisampled = (self.samples > 1).then(|| texture("sc_capture_ms", FORMAT, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT)); - let depth = texture("sc_capture_depth", DEPTH, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT); + let depth = texture("sc_capture_depth", self.depth, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT); let (resolved_view, depth_view) = (resolved.create_view(&Default::default()), depth.create_view(&Default::default())); let multisampled_view = multisampled.as_ref().map(|t| t.create_view(&Default::default())); let (top, bottom) = scene.background.unwrap_or(BACKGROUND); @@ -155,7 +177,10 @@ impl Capturer { })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &depth_view, - depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(crate::view::far_depth(crate::view::reversed_depth(Some(self.depth)))), + store: wgpu::StoreOp::Discard, + }), stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }), }), timestamp_writes: None, @@ -387,7 +412,7 @@ fn background_vertices(device: &wgpu::Device, top: Rgb, bottom: Rgb) -> wgpu::Bu }) } -fn background_pipeline(device: &wgpu::Device, samples: u32) -> wgpu::RenderPipeline { +fn background_pipeline(device: &wgpu::Device, samples: u32, depth: wgpu::TextureFormat) -> wgpu::RenderPipeline { let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_capture_background"), source: wgpu::ShaderSource::Wgsl(BACKGROUND_SHADER.into()), @@ -409,7 +434,7 @@ fn background_pipeline(device: &wgpu::Device, samples: u32) -> wgpu::RenderPipel }, primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, depth_stencil: Some(wgpu::DepthStencilState { - format: DEPTH, + format: depth, depth_write_enabled: Some(false), depth_compare: Some(wgpu::CompareFunction::Always), stencil: Default::default(), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 85200480..6be54f70 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -29,6 +29,26 @@ pub mod view; /// The tessellation compute shader (WGSL). pub const SHADER: &str = include_str!("tess.wgsl"); +/// The device feature for a 32-bit float depth buffer with a stencil, which the viewport draws +/// with reversed depth ([`view::reversed_depth`]) where the device has it. A device is opened +/// with it whenever its adapter offers it ([`depth_features`]). +pub const FLOAT_DEPTH_STENCIL: wgpu::Features = wgpu::Features::DEPTH32FLOAT_STENCIL8; + +/// The depth features to open a device on `adapter` with: [`FLOAT_DEPTH_STENCIL`] if it has it. +pub fn depth_features(adapter: &wgpu::Adapter) -> wgpu::Features { + adapter.features() & FLOAT_DEPTH_STENCIL +} + +/// The depth format to draw with on `device`: 32-bit float (reversed depth) where it can, with a +/// stencil when `stencil` (the section cap needs one), else 24 bits with a stencil. +pub fn depth_format(device: &wgpu::Device, stencil: bool) -> wgpu::TextureFormat { + match (stencil, device.features().contains(FLOAT_DEPTH_STENCIL)) { + (false, _) => wgpu::TextureFormat::Depth32Float, + (true, true) => wgpu::TextureFormat::Depth32FloatStencil8, + (true, false) => wgpu::TextureFormat::Depth24PlusStencil8, + } +} + /// The error a popped error scope caught. Native devices answer at once; a browser's answer /// comes later, and is not waited for (no error is assumed). fn popped(f: impl std::future::Future>) -> Option { @@ -48,6 +68,7 @@ fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { let (adapter, _) = select::pick(policy).ok_or(GpuError::NoAdapter(policy.name()))?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("solidcraft headless"), + required_features: depth_features(&adapter), required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), ..Default::default() })) diff --git a/crates/gpu/src/view.rs b/crates/gpu/src/view.rs index d3f8e801..3ac065fe 100644 --- a/crates/gpu/src/view.rs +++ b/crates/gpu/src/view.rs @@ -19,9 +19,9 @@ pub const TRI_SIZE: usize = 28; pub const PBR_SIZE: usize = pbr::VERTEX_SIZE; /// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32). pub const LINE_SIZE: usize = 32; -/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their -/// flags (`uniform_bytes`). -const UNIFORM_SIZE: u64 = 352; +/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours, their +/// flags and the depth direction (`uniform_bytes`). +const UNIFORM_SIZE: u64 = 368; /// A body drawn from its display plan, evaluated on the GPU: the plan, and a colour per face /// (the plan's face numbering; faces past the end take the last colour). @@ -599,8 +599,26 @@ pub struct Renderer { ss: Option, /// This frame was drawn supersampled (in `prepare`): `paint` only filters it down. ss_ready: bool, + /// Reversed depth drawn into the window's pass: egui clears its depth to 1, which is the near + /// plane then, so the viewport first sets its rectangle to the far plane (0). + depth_reset: Option, } +/// A triangle over the whole viewport at depth 0 (the far plane of reversed depth), writing no +/// colour: [`Renderer::depth_reset`]. +const DEPTH_RESET_SHADER: &str = r#" +@vertex +fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let x = f32((i << 1u) & 2u); + let y = f32(i & 2u); + return vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); +} +@fragment +fn fs() -> @location(0) vec4 { + return vec4(0.0); +} +"#; + /// A supersampled frame: the scene drawn at a multiple of the viewport's pixels. struct Supersampled { size: [u32; 2], @@ -735,6 +753,55 @@ pub struct GpuTarget { pub tess: bool, } +impl GpuTarget { + /// Depth runs from 1 at the near plane to 0 at the far one: see [`reversed_depth`]. + pub fn reversed_depth(&self) -> bool { + reversed_depth(self.depth) + } +} + +/// Is depth reversed (1 at the near plane, 0 at the far one) in a buffer of `format`? It is with a +/// 32-bit float buffer: a float's steps grow with its value, so with depth falling away from the +/// eye they follow the distance, about 2⁻²⁴ of it everywhere, instead of piling up at the near +/// plane (#1434: a face 0.3 mm in front of another speckled at 10 m). Other formats keep 0 near. +pub fn reversed_depth(format: Option) -> bool { + matches!(format, Some(wgpu::TextureFormat::Depth32Float | wgpu::TextureFormat::Depth32FloatStencil8)) +} + +/// The depth test `compare`, written for depth growing away from the eye, for a buffer whose depth +/// may run the other way ([`reversed_depth`]). Every pass drawn with the viewport's depth takes +/// its tests from here, so it agrees with the viewport whichever way depth runs. +pub fn depth_compare(reversed: bool, compare: wgpu::CompareFunction) -> wgpu::CompareFunction { + use wgpu::CompareFunction::{Greater, GreaterEqual, Less, LessEqual}; + match compare { + Less if reversed => Greater, + LessEqual if reversed => GreaterEqual, + Greater if reversed => Less, + GreaterEqual if reversed => LessEqual, + c => c, + } +} + +/// A depth bias written for depth growing away from the eye (negative: toward it), turned round +/// for reversed depth. +pub fn depth_bias(reversed: bool, b: wgpu::DepthBiasState) -> wgpu::DepthBiasState { + if reversed { wgpu::DepthBiasState { constant: -b.constant, slope_scale: -b.slope_scale, clamp: -b.clamp } } else { b } +} + +/// The depth of the far plane, which a depth buffer is cleared to. +pub fn far_depth(reversed: bool) -> f32 { + if reversed { 0.0 } else { 1.0 } +} + +/// The view-projection the GPU draws with: `vp` (depth 0 near, 1 far, as the CPU uses it), with +/// depth turned round (z' = w - z) for reversed depth. Columns, as `Mat4::to_f32` gives them. +pub fn gpu_view_proj(vp: [[f32; 4]; 4], reversed: bool) -> [[f32; 4]; 4] { + if !reversed { + return vp; + } + vp.map(|c| [c[0], c[1], c[3] - c[2], c[3]]) +} + impl Renderer { /// The pipelines for target `t` on `device`; `tess` evaluates planned bodies on the GPU. pub fn new(device: &wgpu::Device, t: GpuTarget, tess: Option) -> Self { @@ -766,15 +833,20 @@ impl Renderer { layout: &bgl, entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], }); + let reversed = t.reversed_depth(); let depth = |compare: wgpu::CompareFunction, write: bool| -> Option { t.depth.map(|format| wgpu::DepthStencilState { format, depth_write_enabled: Some(write), - depth_compare: Some(compare), + depth_compare: Some(depth_compare(reversed, compare)), stencil: Default::default(), bias: Default::default(), }) }; + // Opaque faces pushed back by their own slope (FACE_BIAS); ghosts twice as far. + let pushed = |compare: wgpu::CompareFunction, write: bool, bias: wgpu::DepthBiasState| -> Option { + depth(compare, write).map(|d| wgpu::DepthStencilState { bias: depth_bias(reversed, bias), ..d }) + }; let pipeline = |label: &str, vs: &str, fs: &str, @@ -926,9 +998,9 @@ impl Renderer { let mark = wgpu::DepthStencilState { format, depth_write_enabled: Some(true), - depth_compare: Some(wgpu::CompareFunction::Less), + depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::Less)), stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff }, - bias: Default::default(), + bias: depth_bias(reversed, FACE_BIAS), }; // Then the cap's back faces again, only there, at the section plane's depth. let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) }; @@ -1002,7 +1074,41 @@ impl Renderer { multiview_mask: None, cache: None, }); + let depth_reset = t.depth.filter(|_| reversed).map(|format| { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("sc_depth_reset"), + source: wgpu::ShaderSource::Wgsl(DEPTH_RESET_SHADER.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_depth_reset"), + bind_group_layouts: &[], + immediate_size: 0, + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_depth_reset"), + layout: Some(&layout), + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: Some(wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::empty() })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }); Renderer { + depth_reset, target: t, max_texture: device.limits().max_texture_dimension_2d, ss_blit, @@ -1014,20 +1120,20 @@ impl Renderer { sampler, textures: Default::default(), quads: Vec::new(), - tri: solid("sc_tris", "fs_solid", None, depth(wgpu::CompareFunction::Less, true), false), + tri: solid("sc_tris", "fs_solid", None, pushed(wgpu::CompareFunction::Less, true, FACE_BIAS), false), tri_cut: cut(false), - tri_planned: solid("sc_tris_planned", "fs_solid", None, depth(wgpu::CompareFunction::Less, true), true), + tri_planned: solid("sc_tris_planned", "fs_solid", None, pushed(wgpu::CompareFunction::Less, true, FACE_BIAS), true), tri_cut_planned: cut(true), tess, access_bgl, access_bind, access_version: 0, tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha), - pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), depth(wgpu::CompareFunction::Less, true), None), + pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), pushed(wgpu::CompareFunction::Less, true, FACE_BIAS), None), trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), glass_far: glass("sc_glass_far", wgpu::Face::Front), glass_near: glass("sc_glass_near", wgpu::Face::Back), - ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + ghost: pipeline("sc_ghost", "vs_tri", "fs_tri", tri_layout(), pushed(wgpu::CompareFunction::LessEqual, false, GHOST_BIAS), alpha), xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha), line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha), @@ -1053,18 +1159,40 @@ impl Renderer { /// depths differ by rounding and an unbiased equal test lights the face in speckles and wedges. /// The bias is a few depth steps plus the face's own slope: far less than any real gap. fn highlight_depth(format: Option) -> Option { + let reversed = reversed_depth(format); format.map(|format| wgpu::DepthStencilState { format, depth_write_enabled: Some(false), - depth_compare: Some(wgpu::CompareFunction::LessEqual), + depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::LessEqual)), stencil: Default::default(), - bias: HIGHLIGHT_BIAS, + bias: depth_bias(reversed, HIGHLIGHT_BIAS), }) } /// See [`highlight_depth`]. Negative: toward the eye (depth grows away from it). const HIGHLIGHT_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: -8, slope_scale: -1.0, clamp: 0.0 }; +/// Opaque faces are drawn pushed back from the eye by a few depth steps and by their own depth +/// slope over [`FACE_SLOPE`] pixels, so whatever lies on a face (its edges, an origin plane, a +/// sketch, a decal) is drawn over it at any angle, and an edge behind it stays hidden however +/// large the scene (#1434, #1739). The CPU renderer has the same rule (`raster.rs`, #983). +/// Lines used to be pulled toward the eye by a share of the depth range instead, which on a large +/// design reached tens of millimetres. Positive: away from the eye. +pub const FACE_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: FACE_STEPS, slope_scale: FACE_SLOPE, clamp: 0.0 }; +/// The depth steps of [`FACE_BIAS`]: f32 rounding between a face and what lies on it (the GPU +/// evaluates planned bodies in f32; edges and overlays come from the CPU). +pub const FACE_STEPS: i32 = 8; +/// The pixels of [`FACE_BIAS`]: half a 2 px line, so edges (1.2 px) draw whole on a steep face. +/// It is also how far behind a face an edge may show, in that face's own depth per pixel: at 10 m +/// a pixel is about 9 mm, and a face turned 25° gains 4 mm of depth across it, so a larger value +/// let a washer 12 mm behind a plate show through it (#1434). A thicker line (a selected edge, +/// 3.5 px) may lose its outer edge on one side where it lies on a steep face. The section cap, +/// which writes its own depth, adds the same in the shader. +pub const FACE_SLOPE: f32 = 1.0; +/// The removed material of a preview, drawn translucent: behind the faces it shares with the new +/// shape, so twice [`FACE_BIAS`]. +const GHOST_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: 2 * FACE_STEPS, slope_scale: 2.0 * FACE_SLOPE, clamp: 0.0 }; + fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option { upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX) } @@ -1162,7 +1290,7 @@ impl Renderer { if cb.product { res.product.take(device, cb.key, &cb.slot, None); res.product.sort_glass(queue, cb.back); - queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out)); + queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out, res.target.reversed_depth())); return; } res.model.take(device, cb.key, &cb.slot, res.tess.as_ref().map(|t| (t, &mut *encoder))); @@ -1170,7 +1298,7 @@ impl Renderer { res.highlight.take(device, cb.hl_key, &cb.hl_slot, None); res.preview.take(device, cb.pv_key, &cb.pv_slot, None); res.model.sort_glass(queue, cb.back); - queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out)); + queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out, res.target.reversed_depth())); if let Some(a) = &cb.access && a.version != res.access_version { @@ -1245,6 +1373,10 @@ impl Renderer { pass.draw(0..3, 0..1); return; } + if let Some(reset) = &self.depth_reset { + pass.set_pipeline(reset); + pass.draw(0..3, 0..1); + } self.draw(frame, pass); } @@ -1367,7 +1499,10 @@ impl Renderer { })], 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 }), + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(far_depth(self.target.reversed_depth())), + 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 }), @@ -1420,10 +1555,10 @@ impl Renderer { } /// Uniform bytes for a frame. -pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool) -> Vec { - let mut v: Vec = cb.view_proj.iter().flatten().copied().collect(); +pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool, reversed: bool) -> Vec { + let mut v: Vec = gpu_view_proj(cb.view_proj, reversed).iter().flatten().copied().collect(); v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]); - v.extend([w.max(1.0), h.max(1.0), 2e-4, if cb.outline { 1.0 } else { 0.0 }]); + v.extend([w.max(1.0), h.max(1.0), 0.0, if cb.outline { 1.0 } else { 0.0 }]); v.extend(cb.clip); v.extend(cb.cap); v.extend(cb.analysis); @@ -1436,6 +1571,7 @@ pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool) -> Vec { } v.extend(cb.eye); v.extend(cb.analysis_colors); + v.extend([if reversed { 1.0 } else { 0.0 }, FACE_STEPS as f32, FACE_SLOPE, 0.0]); v.iter().flat_map(|x| x.to_le_bytes()).collect() } diff --git a/crates/gpu/tests/depth.rs b/crates/gpu/tests/depth.rs new file mode 100644 index 00000000..32e1cbd1 --- /dev/null +++ b/crates/gpu/tests/depth.rs @@ -0,0 +1,243 @@ +//! Depth in headless pictures, drawn with the viewport's pipelines (#1434, #1739): an edge behind +//! a face stays hidden however large the design and however far the camera, and whatever lies on +//! a face (its edges, a thin label) draws over it at any angle. Each check runs with the depth the +//! device draws with (a 32-bit float buffer, reversed, where it has one) and with the 24-bit +//! fallback. Skipped, with the reason printed, without a GPU adapter (`SOLIDCRAFT_REQUIRE_GPU=1` +//! makes that a failure). + +use serde_json::json; +use solidcraft_engine::Session; +use solidcraft_engine::geom::{Mesh, Vec3}; +use solidcraft_engine::render::{Camera, Rgb, Scene, SceneLine, SceneMesh, StandardView}; +use solidcraft_engine::view::{self, Guides}; +use solidcraft_gpu::GpuError; +use solidcraft_gpu::capture::{self, Capturer}; +use std::sync::{Arc, OnceLock}; + +fn or_skip(c: Result<&'static Capturer, &'static GpuError>) -> Option<&'static Capturer> { + match c { + Ok(c) => Some(c), + Err(e @ GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { + eprintln!("skipping: {e}"); + None + } + Err(e) => panic!("{e}"), + } +} + +/// The device's own depth, then the 24-bit fallback (the same device kind, its own instance). +fn capturers() -> Vec<(&'static str, &'static Capturer)> { + static FALLBACK: OnceLock> = OnceLock::new(); + let Some(own) = or_skip(capture::shared()) else { return Vec::new() }; + let Some(fallback) = or_skip(FALLBACK.get_or_init(|| Capturer::headless_with_depth(wgpu::TextureFormat::Depth24PlusStencil8)).as_ref()) else { + return Vec::new(); + }; + eprintln!("GPU: {}, depth {:?} and {:?}", own.adapter_name(), own.depth_format(), fallback.depth_format()); + vec![("own", own), ("24-bit", fallback)] +} + +fn reversed(c: &Capturer) -> bool { + solidcraft_gpu::view::reversed_depth(Some(c.depth_format())) +} + +/// RGBA pixels as `[r, g, b, a]`. +fn pixels(img: &[u8]) -> impl Iterator + '_ { + img.as_chunks::<4>().0.iter().copied() +} + +fn red(p: [u8; 4]) -> bool { + p[0] > 150 && p[1] < 90 && p[2] < 90 +} + +fn session(script: &[(&str, serde_json::Value)]) -> Session { + let mut s = Session::default(); + for (c, p) in script { + s.execute(c, p).unwrap(); + } + s +} + +fn picture(c: &Capturer, s: &Session, cam: &Camera, w: u32, h: u32) -> Vec { + c.rgba(&view::scene_with_guides(s, cam, None, Guides { grid: false, axes: false }), cam, w, h).unwrap() +} + +fn differing(a: &[u8], b: &[u8]) -> usize { + pixels(a).zip(pixels(b)).filter(|(p, q)| p.iter().zip(q).any(|(x, y)| x.abs_diff(*y) > 24)).count() +} + +/// The reproduction of #1434: a 20 mm plate with a washer 22–25 mm behind its front face, and a +/// 30 m slab far away that makes the design 34 m across. The washer never shows: the picture is +/// the same without it. (Lines were pulled toward the eye by 2e-4 of the depth range, about +/// 27 mm here in orthographic.) +#[test] +fn a_washer_behind_a_plate_stays_hidden_on_a_large_design() { + let plate = [ + ("solid.box", json!({"length": 30000, "width": 8000, "height": 100, "corner": [-15000, 3000, -3000], "body_name": "slab"})), + ("solid.box", json!({"length": 200, "width": 20, "height": 200, "corner": [0, 0, 0], "body_name": "plate"})), + ("solid.cylinder", json!({"radius": 8, "height": 60, "base": [100, -20, 100], "axis": "Y", "body_name": "bolt"})), + ]; + let washer = ("solid.cylinder", json!({"radius": 15, "height": 3, "base": [100, 22, 100], "axis": "Y", "body_name": "washer"})); + let without = session(&plate); + let with = session(&[plate[0].clone(), plate[1].clone(), plate[2].clone(), washer]); + for (mode, c) in capturers() { + for fov in [0.0, 0.6] { + let cam = Camera { target: Vec3::new(100.0, 10.0, 100.0), yaw: 0.35, pitch: 0.25, roll: 0.0, distance: 450.0, fov, ..Default::default() }; + let n = differing(&picture(c, &with, &cam, 400, 300), &picture(c, &without, &cam, 400, 300)); + assert_eq!(n, 0, "{mode} depth, fov {fov}: the washer shows through the plate in {n} pixels"); + } + } +} + +/// #1434's second reproduction, in perspective at 10 m: a 2 m plate, a 600 mm washer 12–15 mm +/// behind its front face, and a red label 0.3 mm thick on it. The washer stays hidden in both +/// depths; with reversed float depth the label is red all over (in 24 bits one depth step there +/// is about 0.6 mm, so the label speckled). +#[test] +fn at_ten_metres_a_thin_label_is_whole_and_a_washer_behind_stays_hidden() { + let base = [ + ("solid.box", json!({"length": 2000, "width": 10, "height": 2000, "corner": [0, 0, 0], "body_name": "plate"})), + ("solid.box", json!({"length": 1000, "width": 0.3, "height": 400, "corner": [500, -0.3, 1400], "body_name": "label"})), + ("appearance.assign", json!({"bodies": ["label"], "color": [204, 34, 34]})), + ]; + let washer = ("solid.cylinder", json!({"radius": 300, "height": 3, "base": [1000, 12, 1000], "axis": "Y", "body_name": "washer"})); + let without = session(&base); + let with = session(&[base[0].clone(), base[1].clone(), base[2].clone(), washer]); + let cam = + Camera { target: Vec3::new(1000.0, 5.0, 1000.0), yaw: 0.35, pitch: 0.25, roll: 0.0, distance: 10_000.0, fov: 0.6, ..Default::default() }; + let (w, h) = (800, 600); + for (mode, c) in capturers() { + let img = picture(c, &with, &cam, w, h); + let n = differing(&img, &picture(c, &without, &cam, w, h)); + assert_eq!(n, 0, "{mode} depth: the washer shows through the plate in {n} pixels"); + if !reversed(c) { + continue; + } + // The label's front face (y = -0.3), sampled inside its outline. + let radius = view::scene(&with, &cam).radius; + let mut grey = Vec::new(); + for i in 1..20 { + for j in 1..20 { + let p = Vec3::new(500.0 + 1000.0 * f64::from(i) / 20.0, -0.3, 1400.0 + 400.0 * f64::from(j) / 20.0); + let Some((x, y, _)) = cam.to_screen(p, f64::from(w), f64::from(h), radius) else { continue }; + let at = (y as usize * w as usize + x as usize) * 4; + if let Some(px) = img.get(at..at + 4).and_then(|p| <[u8; 4]>::try_from(p).ok()) + && !red(px) + { + grey.push((x as u32, y as u32, px)); + } + } + } + assert!(grey.is_empty(), "{mode} depth: the label speckles at {} of 361 points: {:?}", grey.len(), &grey[..grey.len().min(5)]); + } +} + +/// A 30 × 40 mm sheet at y = z·tan(`tilt`) (facing the front camera when `tilt` is 0). +fn sheet(tilt: f64) -> Mesh { + let t = tilt.tan(); + let c = [(-15.0, -20.0), (15.0, -20.0), (15.0, 20.0), (-15.0, 20.0)].map(|(x, z)| Vec3::new(x, z * t, z)); + Mesh { + positions: c.to_vec(), + normals: vec![Vec3::new(0.0, -tilt.cos(), tilt.sin()); 4], + triangles: vec![[0, 1, 2], [0, 2, 3]], + tri_face: vec![0, 0], + ..Default::default() + } +} + +/// A front view of `mesh` with `lines`, as part of a design of `radius`, 40 mm high on 200 px. +fn front(mesh: Mesh, lines: Vec, radius: f64, fov: f64) -> (Scene, Camera) { + let scene = Scene { + meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(160, 165, 175), face_colors: Vec::new() }], + lines, + radius, + ..Default::default() + }; + let mut cam = Camera { distance: if fov > 0.0 { 25.0 / (fov * 0.5).tan() } else { 62.5 }, fov, ..Default::default() }; + cam.set_view(StandardView::Front); + (scene, cam) +} + +fn red_line(a: Vec3, b: Vec3, on_top: bool) -> SceneLine { + SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), width: 1.2, on_top } +} + +fn red_pixels(c: &Capturer, scene: &Scene, cam: &Camera) -> usize { + pixels(&c.rgba(scene, cam, 200, 200).unwrap()).filter(|p| red(*p)).count() +} + +/// An edge lying on a face draws its whole width over it, on a face seen square and on one turned +/// to 80°, in orthographic and perspective, in a small design and a large one. +#[test] +fn an_edge_on_a_face_draws_whole_at_any_angle() { + for (mode, c) in capturers() { + for tilt in [0.0_f64, 60.0, 80.0] { + let t = tilt.to_radians().tan(); + // Across the sheet, at z = 5: y = 5·tan(tilt). + let (a, b) = (Vec3::new(-12.0, 5.0 * t, 5.0), Vec3::new(12.0, 5.0 * t, 5.0)); + for radius in [30.0, 17_000.0] { + for fov in [0.0, 0.6] { + let (on, cam) = front(sheet(tilt.to_radians()), vec![red_line(a, b, false)], radius, fov); + let (top, _) = front(sheet(tilt.to_radians()), vec![red_line(a, b, true)], radius, fov); + let (shown, all) = (red_pixels(c, &on, &cam), red_pixels(c, &top, &cam)); + assert!(all > 50, "{mode}: the line itself ({all} px)"); + assert!(shown * 10 >= all * 9, "{mode} depth, tilt {tilt}°, radius {radius}, fov {fov}: {shown} of {all} px drawn"); + } + } + } + } +} + +/// An edge 0.5 mm behind a face seen square is hidden, however large the design: lines were pulled +/// toward the eye by a share of the depth range, about 27 mm in a design 34 m across. +#[test] +fn an_edge_just_behind_a_face_stays_hidden_on_a_large_design() { + for (mode, c) in capturers() { + for radius in [30.0, 17_000.0] { + for fov in [0.0, 0.6] { + // The sheet is at y = 0; the camera looks along +y, so behind is +y. + let (a, b) = (Vec3::new(-12.0, 0.5, 5.0), Vec3::new(12.0, 0.5, 5.0)); + let (scene, cam) = front(sheet(0.0), vec![red_line(a, b, false)], radius, fov); + let n = red_pixels(c, &scene, &cam); + assert_eq!(n, 0, "{mode} depth, radius {radius}, fov {fov}: the edge behind shows in {n} px"); + } + } + } +} + +/// The application's device draws with reversed float depth where it can: every desktop backend +/// this runs on (D3D12, Vulkan, Metal) has `Depth32FloatStencil8`. +#[test] +fn the_device_draws_with_float_depth_where_it_has_it() { + let Some(c) = or_skip(capture::shared()) else { return }; + eprintln!("GPU: {}, depth {:?}", c.adapter_name(), c.depth_format()); + let float = c.depth_format() == wgpu::TextureFormat::Depth32FloatStencil8; + assert_eq!(float, reversed(c)); + if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_some() { + assert!(float, "a desktop GPU without Depth32FloatStencil8: {:?}", c.depth_format()); + } +} + +/// What lies on a section's cut face draws over the cap, which writes its own depth (so the GPU's +/// push does not apply to it; the shader pushes it the same way). +#[test] +fn an_edge_on_the_cut_face_draws_over_the_cap() { + let mut s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]); + // Cut away the front (y < 15), so the iso camera looks at the cut face at y = 15. + s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap(); + for (mode, c) in capturers() { + for fov in [0.0, 0.6] { + let mut cam = view::home_camera(&s); + cam.fov = fov; + let count = |on_top: bool| { + // The session's section (`scene_with_guides` takes its own). The line is 2 µm on + // the kept side: exactly on the plane, rounding cuts parts of it away with the rest. + let mut sc = view::scene(&s, &cam); + sc.lines.push(red_line(Vec3::new(5.0, 15.002, 10.0), Vec3::new(35.0, 15.002, 10.0), on_top)); + pixels(&c.rgba(&sc, &cam, 300, 220).unwrap()).filter(|p| red(*p)).count() + }; + let (shown, all) = (count(false), count(true)); + assert!(all > 50, "{mode}: the line itself ({all} px)"); + assert!(shown * 10 >= all * 9, "{mode} depth, fov {fov}: {shown} of {all} px drawn over the cap"); + } + } +} diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 9c072c7e..222938e4 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -142,6 +142,7 @@ pub(crate) fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Q let instance = wgpu::Instance::new(desc); let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_features: solidcraft_gpu::depth_features(&adapter), required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), ..Default::default() })) @@ -253,7 +254,7 @@ fn offscreen( let (adapter, device, queue) = offscreen_device()?; eprintln!("offscreen adapter: {:?}", adapter.get_info().backend); let format = if srgb { wgpu::TextureFormat::Rgba8UnormSrgb } else { wgpu::TextureFormat::Rgba8Unorm }; - let depth = wgpu::TextureFormat::Depth24PlusStencil8; + let depth = solidcraft_gpu::depth_format(&device, true); let compute = tess.and_then(|shader| solidcraft_gpu::view::tessellation(&adapter, &device, &queue, shader)); let target = GpuTarget { format, depth: Some(depth), samples: 1, tess: compute.is_some() }; let mut resources = egui_wgpu::CallbackResources::default(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 3fb11a41..99309b3f 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -3726,6 +3726,81 @@ mod tests { assert!(checked >= 3, "{checked} faces seen lit"); } + /// An origin plane lying on a body's face is tinted evenly over it (#1739: a box built at the + /// origin, seen from below in orthographic; the XY plane's square z-fought with the bottom + /// face in stripes). Faces are pushed back by their slope (`FACE_BIAS`), the planes are not. + /// Skipped without a GPU adapter. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn an_origin_plane_on_a_face_is_tinted_evenly() { + let mut s = solidcraft_engine::Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 10, "corner": [0, 0, 0]})).unwrap(); + let mut app = SolveApp::new(s, crate::Services::default()); + app.cam = Camera { + target: Vec3::new(16.904, 10.024, 3.661), + yaw: -0.32, + pitch: -0.9652, + roll: 0.0, + distance: 98.557, + fov: 0.0, + ..Default::default() + }; + app.ui.perspective = false; + const SIZE: u32 = 256; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); + let mut render = |origin: bool| { + app.ui.show_origin = origin; + app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); + let (scene, highlight) = (build_scene(&app), build_highlight(&app).merged()); + let proj = projection(&app, rect); + let e = proj.cam.eye().to_f32(); + let img = crate::gpu::render_offscreen( + scene, + Some(highlight), + proj.vp.to_f32(), + proj.cam.back().to_f32(), + [e[0], e[1], e[2], 0.0], + SIZE, + None, + [0.0; 8], + crate::gpu::analysis_colors(&Tokens::get().analysis), + ); + (img, proj) + }; + let (Some(with), proj) = render(true) else { + eprintln!("skipping: no GPU adapter"); + return; + }; + let (Some(without), _) = render(false) else { return }; + app.ui.show_origin = true; + let xy = origin_planes(&app).into_iter().find(|(n, _, _)| *n == "XY").map(|(_, _, q)| q).unwrap(); + let (w, h, r) = (f64::from(SIZE), f64::from(SIZE), scene_radius(&app)); + let (mut samples, mut plain) = (0, Vec::new()); + // Points inside the square (away from its outline), where it lies on the bottom face. + for i in 1..12 { + for j in 1..12 { + let (u, v) = (f64::from(i) / 12.0, f64::from(j) / 12.0); + let p = xy[0] + (xy[1] - xy[0]) * u + (xy[3] - xy[0]) * v; + if !(p.x > 0.5 && p.x < 39.5 && p.y > 0.5 && p.y < 29.5) { + continue; + } + let Some((x, y, _)) = proj.cam.to_screen(p, w, h, r) else { continue }; + let at = (y as usize * SIZE as usize + x as usize) * 4; + samples += 1; + if with.get(at..at + 4) == without.get(at..at + 4) { + plain.push((x as u32, y as u32)); + } + } + } + assert!(samples > 40, "{samples} points of the square on the face"); + assert!( + plain.is_empty(), + "{} of {samples} points of the XY plane's square do not show over the face: {:?}", + plain.len(), + &plain[..plain.len().min(6)] + ); + } + #[test] fn construction_drawing_and_hover_overlays_preserve_visible_gaps() { fn length(shape: &Shape) -> f32 {