diff --git a/docs/images/perspective-depth-after.png b/docs/images/perspective-depth-after.png new file mode 100644 index 000000000..95d2a3ff2 Binary files /dev/null and b/docs/images/perspective-depth-after.png differ diff --git a/docs/images/perspective-depth-before.png b/docs/images/perspective-depth-before.png new file mode 100644 index 000000000..6e5a628be Binary files /dev/null and b/docs/images/perspective-depth-before.png differ diff --git a/docs/perspective-depth.md b/docs/perspective-depth.md new file mode 100644 index 000000000..7ebaa9301 --- /dev/null +++ b/docs/perspective-depth.md @@ -0,0 +1,38 @@ +# Perspective draw-order clipping regression + +Switching a flat model view from orthographic to perspective could hide otherwise +visible lines. The camera applied an OpenGL-to-WebGPU depth conversion to a +projection that already used WebGPU's depth range. The shaders then applied a +fixed draw-order offset that could push geometry past the far clipping boundary. + +The camera now uses the DirectX/WebGPU projection directly. The shared +`draw_order.wgsl` function keeps the existing offset away from clipping planes +and reduces it near either plane. Its adjustment is monotonic in draw order, +leaves neutral geometric depth unchanged, and does not move clipped vertices +back into range. It is shared by the native and compatibility shader paths. + +## GPU reproduction + +These are offscreen renders from the application's wire pipelines. All 17 lines +are on the same plane; only their draw-order values differ. The first two lines +(red when visible) were clipped in perspective. Both projections now show all +17 lines. This synthetic drawing contains no user CAD geometry. + +| Before | After | +| --- | --- | +| ![Two leftmost lines missing](images/perspective-depth-before.png) | ![All 17 lines visible](images/perspective-depth-after.png) | + +## Validation + +```sh +cargo test --locked --lib +cargo test --locked --lib scene::pipeline::depth_tests -- --ignored --nocapture +``` + +The GPU tests exercise storage and packed wire pipelines, near/far clipping, +coplanar draw order in both submission orders, and wire occlusion by a triangle +mesh. Normal tests check camera depth endpoints, picking, and validation of all +14 composed draw-order shaders. + +Set `OCS_DEPTH_TEST_IMAGES` to an output directory to save the orthographic and +perspective reproduction images while running the GPU tests. diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index f5e898cd5..00fc794c5 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -113,7 +113,7 @@ pub fn create_pipelines( ) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("circle.wgsl"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/circle.wgsl").into()), + source: wgpu::ShaderSource::Wgsl(draw_order_shader!("circle.wgsl").into()), }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("circle.pipeline.layout"), @@ -349,7 +349,7 @@ mod tests { #[test] fn circle_shader_validates_with_naga() { - let source = include_str!("../../shaders/circle.wgsl"); + let source = draw_order_shader!("circle.wgsl"); let module = naga::front::wgsl::parse_str(source).expect("circle.wgsl parses cleanly"); let mut validator = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), diff --git a/src/scene/pipeline/depth_tests.rs b/src/scene/pipeline/depth_tests.rs new file mode 100644 index 000000000..1a0e9d2b1 --- /dev/null +++ b/src/scene/pipeline/depth_tests.rs @@ -0,0 +1,370 @@ +//! Pixel regressions for projection and draw-order clipping, using the actual +//! wire pipelines in both storage and WebGL-compatible packed modes. +use super::*; +use crate::scene::view::camera::{Camera, Projection}; +use iced::futures::executor::block_on; + +const SIZE: u32 = 512; + +fn pixels( + device: &wgpu::Device, + queue: &wgpu::Queue, + pipeline: &mut Pipeline, + uniforms: &Uniforms, + wires: &[WireModel], + depths: &rustc_hash::FxHashMap, +) -> Vec { + let size = wgpu::Extent3d { + width: SIZE, + height: SIZE, + depth_or_array_layers: 1, + }; + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("draw-order regression"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("draw-order readback"), + size: (SIZE * SIZE * 4) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + pipeline.ensure_depth_texture(device, Size::new(SIZE, SIZE)); + pipeline.upload_uniforms(device, queue, uniforms); + pipeline.gpu_wires = std::sync::Arc::new(WireGpu::from_batch( + device, + queue, + wires, + depths, + pipeline.wire_const_bgl.as_ref(), + )); + let mut encoder = device.create_command_encoder(&Default::default()); + let rect = Rectangle { + x: 0, + y: 0, + width: SIZE, + height: SIZE, + }; + pipeline.render( + &mut encoder, + &view, + Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32)), + rect, + rect, + [0., 0., 0., 1.], + false, + false, + false, + ); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(SIZE * 4), + rows_per_image: Some(SIZE), + }, + }, + size, + ); + queue.submit([encoder.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + readback.slice(..).map_async(wgpu::MapMode::Read, move |r| { + tx.send(r).unwrap(); + }); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + rx.recv().unwrap().unwrap(); + let result = readback.slice(..).get_mapped_range().to_vec(); + readback.unmap(); + result +} + +fn color_at(bytes: &[u8], ndc_x: f32) -> [u32; 3] { + let x = ((ndc_x * 0.5 + 0.5) * SIZE as f32) as usize; + let mut color = [0; 3]; + for col in x - 2..=x + 2 { + let offset = (SIZE as usize / 2 * SIZE as usize + col) * 4; + for c in 0..3 { + color[c] += bytes[offset + c] as u32; + } + } + color +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn perspective_draw_order_keeps_flat_lines_visible() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&Default::default())).expect("GPU adapter"); + let bounds = Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32)); + let mut failures = Vec::new(); + for packed in [false, true] { + let mut limits = adapter.limits(); + if packed { + limits.max_storage_buffers_per_shader_stage = 0; + } + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: limits, + ..Default::default() + })) + .expect("GPU device"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm); + for projection in [Projection::Orthographic, Projection::Perspective] { + let camera = Camera { + distance: 102.03651, + fov_y: 0.47108996, + rotation: glam::Quat::IDENTITY, + target: glam::DVec3::ZERO, + projection, + ..Camera::default() + }; + // Same 17-entity draw-order labels as the reported drawing. Its + // missing line (45EB) occupies index 1 with depth -0.5794896. + let depths = (0..17) + .map(|i| { + ( + i + 1, + [ + (-800_000_000_i64 + 88_888_888 * (i + 1) as i64) as f32 + / 1_073_741_824., + 0., + ], + ) + }) + .collect(); + let wires: Vec<_> = (0..17) + .map(|i| { + let x = (-0.8 + i as f32 * 0.1) * camera.ortho_size(); + let h = camera.ortho_size() * 0.65; + WireModel::solid( + (i + 1).to_string(), + vec![[x, -h, 0.], [x, h, 0.]], + if i < 2 { [1., 0.3, 0.2, 1.] } else { [1.; 4] }, + false, + ) + }) + .collect(); + let bytes = pixels( + &device, + &queue, + &mut pipeline, + &Uniforms::new(&camera, bounds, false), + &wires, + &depths, + ); + if let Ok(path) = std::env::var("OCS_DEPTH_TEST_IMAGES") { + let path = std::path::Path::new(&path); + std::fs::create_dir_all(path).unwrap(); + image::save_buffer( + path.join(format!( + "{projection:?}-{}.png", + if packed { "packed" } else { "storage" } + )), + &bytes, + SIZE, + SIZE, + image::ColorType::Rgba8, + ) + .unwrap(); + } + for i in 0..17 { + if color_at(&bytes, -0.8 + i as f32 * 0.1) == [0; 3] { + failures.push(format!("{projection:?}, packed={packed}, line {i}")); + } + } + } + assert!( + block_on(validation.pop()).is_none(), + "GPU validation failed" + ); + } + assert!( + failures.is_empty(), + "visible lines were clipped: {failures:?}" + ); +} + +#[test] +fn all_draw_order_shaders_validate() { + for source in [ + draw_order_shader!("wire.wgsl"), + draw_order_shader!("wire_indexed.wgsl"), + draw_order_shader!("block_wire.wgsl"), + draw_order_shader!("block_wire_storage.wgsl"), + draw_order_shader!("circle.wgsl"), + draw_order_shader!("ellipse.wgsl"), + draw_order_shader!("text.wgsl"), + draw_order_shader!("block_text.wgsl"), + draw_order_shader!("face3d.wgsl"), + draw_order_shader!("block_face3d.wgsl"), + draw_order_shader!("hatch.wgsl"), + draw_order_shader!("hatch_texture.wgsl"), + draw_order_shader!("image.wgsl"), + draw_order_shader!("wipeout.wgsl"), + ] { + let module = naga::front::wgsl::parse_str(source).expect("composed shader parses"); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .expect("composed shader validates"); + } +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn draw_order_preserves_clip_boundaries_sorting_and_mesh_occlusion() { + use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&Default::default())).expect("GPU adapter"); + for packed in [false, true] { + let mut limits = adapter.limits(); + if packed { + limits.max_storage_buffers_per_shader_stage = 0; + } + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: limits, + ..Default::default() + })) + .expect("GPU device"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm); + let bounds = Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32)); + let mut uniforms = Uniforms::new(&Camera::default(), bounds, false); + uniforms.view_rot = glam::Mat4::IDENTITY; + uniforms.eye_high = [0.; 3]; + uniforms.eye_low = [0.; 3]; + let line = |id: u64, x: f32, z: f32, color: [f32; 4]| { + WireModel::solid( + id.to_string(), + vec![[x, -0.65, z], [x, 0.65, z]], + color, + false, + ) + }; + // Both bias signs, the paper's -2 order, and already-clipped geometry. + let cases = [ + (0.00001, 2., true), + (0.00001, -2., true), + (0.99999, 2., true), + (0.99999, -2., true), + (-0.00001, -2., false), + (1.00001, 2., false), + (0., 2., true), + (1., -2., true), + (0.5, 0., true), + ]; + let wires: Vec<_> = cases + .iter() + .enumerate() + .map(|(i, (z, _, _))| line(i as u64 + 1, -0.8 + i as f32 * 0.2, *z, [1.; 4])) + .collect(); + let depths = cases + .iter() + .enumerate() + .map(|(i, (_, order, _))| (i as u64 + 1, [*order, 0.])) + .collect(); + let bytes = pixels(&device, &queue, &mut pipeline, &uniforms, &wires, &depths); + for (i, (z, order, visible)) in cases.iter().enumerate() { + assert_eq!( + color_at(&bytes, -0.8 + i as f32 * 0.2) != [0; 3], + *visible, + "packed={packed}, depth={z}, order={order}" + ); + } + + // Coplanar draw order must work close to both planes, for negative + // ranks too, regardless of the order in which the GPU sees the wires. + for z in [0.0001, 0.5, 0.9999] { + for (back, front) in [(-0.7, 0.7), (-2., -0.9), (0.3, 0.8)] { + let depths = rustc_hash::FxHashMap::from_iter([(1, [back, 0.]), (2, [front, 0.])]); + let mut wires = [ + line(1, 0., z, [1., 0., 0., 1.]), + line(2, 0., z, [0., 1., 0., 1.]), + ]; + for _ in 0..2 { + let bytes = pixels(&device, &queue, &mut pipeline, &uniforms, &wires, &depths); + let [red, green, _] = color_at(&bytes, 0.); + assert!( + green > 0 && red == 0, + "draw order reversed at z={z}, packed={packed}" + ); + wires.reverse(); + } + } + } + + // A real triangle mesh still occludes a wire behind it. A wire in front + // stays visible: neutral draw order must retain geometric depth. + pipeline.upload_mesh_batch( + &device, + &queue, + &[MeshLodSet::from_single(MeshModel { + name: "100".into(), + verts: vec![ + [-0.6, -0.8, 0.5], + [0.6, -0.8, 0.5], + [0.6, 0.8, 0.5], + [-0.6, 0.8, 0.5], + ], + verts_low: Vec::new(), + normals: vec![[0., 0., 1.]; 4], + indices: vec![0, 1, 2, 0, 2, 3], + triangle_material_handles: Vec::new(), + triangle_colors: Vec::new(), + color: [0., 1., 0., 1.], + selected: false, + })], + ); + let mesh_only = pixels( + &device, + &queue, + &mut pipeline, + &uniforms, + &[], + &Default::default(), + ); + assert_ne!( + color_at(&mesh_only, -0.3), + [0; 3], + "mesh must produce pixels" + ); + let bytes = pixels( + &device, + &queue, + &mut pipeline, + &uniforms, + &[ + line(1, -0.3, 0.6, [1., 0., 0., 1.]), + line(2, 0.3, 0.4, [0., 0., 1., 1.]), + ], + &Default::default(), + ); + assert_eq!( + color_at(&bytes, -0.3), + color_at(&mesh_only, -0.3), + "mesh failed to occlude the rear wire, packed={packed}" + ); + let [red, _, blue] = color_at(&bytes, 0.3); + assert!(blue > red, "mesh hid the front wire, packed={packed}"); + assert!( + block_on(validation.pop()).is_none(), + "GPU validation failed" + ); + } +} diff --git a/src/scene/pipeline/ellipse_gpu.rs b/src/scene/pipeline/ellipse_gpu.rs index 0ed65407e..1be6e909b 100644 --- a/src/scene/pipeline/ellipse_gpu.rs +++ b/src/scene/pipeline/ellipse_gpu.rs @@ -113,7 +113,7 @@ pub fn create_pipelines( ) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("ellipse.wgsl"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/ellipse.wgsl").into()), + source: wgpu::ShaderSource::Wgsl(draw_order_shader!("ellipse.wgsl").into()), }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("ellipse.pipeline.layout"), @@ -305,7 +305,7 @@ mod tests { #[test] fn ellipse_shader_validates_with_naga() { - let source = include_str!("../../shaders/ellipse.wgsl"); + let source = draw_order_shader!("ellipse.wgsl"); let module = naga::front::wgsl::parse_str(source).expect("ellipse.wgsl parses cleanly"); let mut validator = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), diff --git a/src/scene/pipeline/hatch_gpu/mod.rs b/src/scene/pipeline/hatch_gpu/mod.rs index ea5db5789..39ece95f8 100644 --- a/src/scene/pipeline/hatch_gpu/mod.rs +++ b/src/scene/pipeline/hatch_gpu/mod.rs @@ -78,9 +78,9 @@ impl HatchGpu { "hatch.texture.shader" }), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(if uses_storage { - include_str!("../../../shaders/hatch.wgsl") + draw_order_shader!("hatch.wgsl") } else { - include_str!("../../../shaders/hatch_texture.wgsl") + draw_order_shader!("hatch_texture.wgsl") })), }); let vertex_layouts = if uses_storage { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 49172946a..a56c929f4 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,5 +1,22 @@ +// WGSL has no includes. Compose one shared draw-order function into each 2D +// shader at compile time, including the sources used by shader validation tests. +macro_rules! draw_order_shader { + ($file:literal) => { + concat!( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/shaders/draw_order.wgsl" + )), + "\n", + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/", $file)) + ) + }; +} + #[cfg(test)] mod gpu_tests; +#[cfg(test)] +mod depth_tests; mod device_capabilities; pub mod circle_gpu; pub mod ellipse_gpu; @@ -614,18 +631,18 @@ impl Pipeline { label: Some("wire.shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(match wire_mode { wire_gpu::WirePipelineMode::IndexedStorage => { - include_str!("../../shaders/wire_indexed.wgsl") + draw_order_shader!("wire_indexed.wgsl") } - wire_gpu::WirePipelineMode::Packed => include_str!("../../shaders/wire.wgsl"), + wire_gpu::WirePipelineMode::Packed => draw_order_shader!("wire.wgsl"), })), }); let block_wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("block_wire.shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed( if wire_mode.uses_storage() { - include_str!("../../shaders/block_wire_storage.wgsl") + draw_order_shader!("block_wire_storage.wgsl") } else { - include_str!("../../shaders/block_wire.wgsl") + draw_order_shader!("block_wire.wgsl") }, )), }); @@ -1011,8 +1028,8 @@ impl Pipeline { let wipeout_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("wipeout.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/wipeout.wgsl" + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!( + "wipeout.wgsl" ))), }); @@ -1826,14 +1843,14 @@ impl Pipeline { // ── Face3D pipeline ──────────────────────────────────────────────── let face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("face3d.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/face3d.wgsl" + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!( + "face3d.wgsl" ))), }); let block_face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("block_face3d.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/block_face3d.wgsl" + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!( + "block_face3d.wgsl" ))), }); @@ -2038,8 +2055,8 @@ impl Pipeline { let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("image.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/image.wgsl" + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!( + "image.wgsl" ))), }); diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 136e0945b..bf1bb1b60 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -341,7 +341,7 @@ pub fn create_pipelines( ) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("text.wgsl"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/text.wgsl").into()), + source: wgpu::ShaderSource::Wgsl(draw_order_shader!("text.wgsl").into()), }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("text.pipeline.layout"), @@ -390,7 +390,7 @@ pub fn create_pipelines( }; let block_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("block_text.wgsl"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/block_text.wgsl").into()), + source: wgpu::ShaderSource::Wgsl(draw_order_shader!("block_text.wgsl").into()), }); let create_block = |label, depth_write_enabled, depth_compare| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index e58ebd8fb..292db3963 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -1460,7 +1460,7 @@ mod block_wire_storage_tests { /// real device, so the stride is checked against the Rust record instead. #[test] fn segment_struct_matches_the_rust_record() { - let module = module(include_str!("../../shaders/block_wire_storage.wgsl")); + let module = module(draw_order_shader!("block_wire_storage.wgsl")); validate(&module); let mut layouter = naga::proc::Layouter::default(); layouter @@ -1484,8 +1484,8 @@ mod block_wire_storage_tests { /// fragment behaviour. Only the geometry source differs. #[test] fn both_block_wire_shaders_expose_the_same_entry_points() { - let packed = module(include_str!("../../shaders/block_wire.wgsl")); - let storage = module(include_str!("../../shaders/block_wire_storage.wgsl")); + let packed = module(draw_order_shader!("block_wire.wgsl")); + let storage = module(draw_order_shader!("block_wire_storage.wgsl")); validate(&packed); let names = |m: &naga::Module| { let mut n: Vec = m.entry_points.iter().map(|e| e.name.clone()).collect(); @@ -1498,8 +1498,8 @@ mod block_wire_storage_tests { #[test] fn both_block_wire_shaders_honor_fixed_screen_width() { for source in [ - include_str!("../../shaders/block_wire.wgsl"), - include_str!("../../shaders/block_wire_storage.wgsl"), + draw_order_shader!("block_wire.wgsl"), + draw_order_shader!("block_wire_storage.wgsl"), ] { assert!(source.contains("if world_hw < 0.0")); assert!(source.contains( @@ -1510,7 +1510,7 @@ mod block_wire_storage_tests { #[test] fn wire_shader_validates_with_naga() { - let source = include_str!("../../shaders/wire.wgsl"); + let source = draw_order_shader!("wire.wgsl"); let module = naga::front::wgsl::parse_str(source).expect("wire.wgsl parses cleanly"); let mut validator = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), @@ -1523,7 +1523,7 @@ mod block_wire_storage_tests { #[test] fn wire_indexed_shader_validates_with_naga() { - let source = include_str!("../../shaders/wire_indexed.wgsl"); + let source = draw_order_shader!("wire_indexed.wgsl"); let module = naga::front::wgsl::parse_str(source).expect("wire_indexed.wgsl parses cleanly"); let mut validator = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 7b87a2a8d..de94dd117 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -84,13 +84,6 @@ impl Default for Camera { } } -pub const OPENGL_TO_WGPU: Mat4 = glam::mat4( - glam::vec4(1.0, 0.0, 0.0, 0.0), - glam::vec4(0.0, 1.0, 0.0, 0.0), - glam::vec4(0.0, 0.0, 0.5, 0.0), - glam::vec4(0.0, 0.0, 0.5, 1.0), -); - impl Camera { // ── Eye position ─────────────────────────────────────────────────────── @@ -238,13 +231,9 @@ impl Camera { /// Orthographic near/far that CENTRE the target plane at ndc-z ≈ 0.5. /// - /// The draw-order depth bias shifts clip-z by ±`DRAW_ORDER_BIAS` (0.001). - /// The old range (`near = distance*0.001`, `far = distance*1000`) parked the - /// geometry at ndc-z ≈ 0.001 — right on the near plane — so a front-biased - /// entity landed exactly at z = 0 and got clipped the moment f32 rounding - /// (worse at high zoom) tipped it past the plane, making the drawing vanish. - /// A symmetric range gives the bias half the depth buffer of headroom on - /// each side; ortho permits a negative near. + /// A symmetric range gives draw-order offsets headroom on each side and + /// keeps the target away from both clipping planes. The shared shader + /// bounds those offsets near either plane; ortho permits a negative near. fn ortho_depth_range(&self) -> (f32, f32) { // Generous headroom based on the current screen size so that rotating any // geometry visible on screen in 3D never penetrates the near/far planes. @@ -296,7 +285,9 @@ impl Camera { orthographic(-w, w, -h, h, near, far) } }; - OPENGL_TO_WGPU * proj * view + // The DirectX projection functions already produce WebGPU's [0, 1] + // depth range. An OpenGL remap here would compress it to [0.5, 1]. + proj * view } /// Project a world point to screen pixels with full f64 precision: the @@ -798,6 +789,40 @@ pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: f32) -> Quat { mod tests { use super::*; + #[test] + fn projection_uses_the_webgpu_depth_range_once() { + let bounds = Rectangle::with_size(iced::Size::new(800., 600.)); + for projection in [Projection::Orthographic, Projection::Perspective] { + let camera = Camera { + projection, + rotation: Quat::IDENTITY, + ..Camera::default() + }; + let (near, far) = match projection { + Projection::Perspective => (camera.distance * 0.001, camera.distance * 1000.), + Projection::Orthographic => camera.ortho_depth_range(), + }; + let matrix = camera.view_proj_rte(bounds); + let depth = |distance: f32| { + let clip = matrix * glam::vec4(0., 0., -distance, 1.); + clip.z / clip.w + }; + assert!(depth(near).abs() < 1e-5, "{projection:?}: near must map to 0"); + assert!( + (depth(far) - 1.).abs() < 1e-5, + "{projection:?}: far must map to 1" + ); + assert!( + depth(near - (far - near) * 1e-7) < 0., + "near clipping boundary moved" + ); + assert!(depth(far * 2.) > 1., "far clipping boundary moved"); + if projection == Projection::Orthographic { + assert!((depth(camera.distance) - 0.5).abs() < 1e-5); + } + } + } + /// A drawing 140 units wide carrying one entity 800 km below its plane must /// still zoom to the 140 units — the outlier belongs to the depth range, not /// to the zoom. (Sizing both from the 3-D diagonal made the drawing a dot.) diff --git a/src/shaders/block_face3d.wgsl b/src/shaders/block_face3d.wgsl index e3520039d..4d2b59bd5 100644 --- a/src/shaders/block_face3d.wgsl +++ b/src/shaders/block_face3d.wgsl @@ -28,16 +28,13 @@ struct VertexOut { @location(0) color: vec4, } -const DRAW_ORDER_BIAS: f32 = 0.001; - @vertex fn vs_main(v: VertexIn) -> VertexOut { var out: VertexOut; let rel = (v.position + v.translation - u.eye_high) + (v.position_low + v.translation_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - - (v.source_depth + v.instance_depth) * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, v.source_depth + v.instance_depth); out.color = v.color; return out; } diff --git a/src/shaders/block_text.wgsl b/src/shaders/block_text.wgsl index 72e489ecf..e8e854598 100644 --- a/src/shaders/block_text.wgsl +++ b/src/shaders/block_text.wgsl @@ -32,16 +32,13 @@ struct VertOut { @location(1) color: vec4, } -const DRAW_ORDER_BIAS: f32 = 0.001; - @vertex fn vs_main(v: VertIn) -> VertOut { var out: VertOut; let rel = (v.pos + v.translation - u.eye_high) + (v.pos_low + v.translation_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - - (v.source_depth + v.instance_depth) * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, v.source_depth + v.instance_depth); out.uv = v.uv; out.color = v.color; return out; diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index 55963405f..31683b58e 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -44,7 +44,6 @@ struct VertexIn { @location(8) depth: vec2, } -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -165,7 +164,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); - clip_pos.z = clip_pos.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, in.depth.x); final_clip = clip_pos; out_dist = mix(in.distances.x, in.distances.y, which_end); @@ -192,7 +191,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut + direction * half_width * extension; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_position + vec4(ndc_offset * clip_position.w, 0.0, 0.0); - clip_pos_out.z = clip_pos_out.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos_out.w; + clip_pos_out = apply_draw_order(clip_pos_out, in.depth.x); final_clip = clip_pos_out; out_dist = mix(in.distances.x, in.distances.y, which_end) diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl index f168504e6..33ee2d636 100644 --- a/src/shaders/block_wire_storage.wgsl +++ b/src/shaders/block_wire_storage.wgsl @@ -68,7 +68,6 @@ struct VertexIn { @location(8) depth: vec2, } -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -196,7 +195,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); - clip_pos.z = clip_pos.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, in.depth.x); final_clip = clip_pos; out_dist = mix(seg_distances.x, seg_distances.y, which_end); @@ -223,7 +222,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut + direction * half_width * extension; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_position + vec4(ndc_offset * clip_position.w, 0.0, 0.0); - clip_pos_out.z = clip_pos_out.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos_out.w; + clip_pos_out = apply_draw_order(clip_pos_out, in.depth.x); final_clip = clip_pos_out; out_dist = mix(seg_distances.x, seg_distances.y, which_end) diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl index 43d157286..e178340bf 100644 --- a/src/shaders/circle.wgsl +++ b/src/shaders/circle.wgsl @@ -26,7 +26,6 @@ struct Uniforms { } @group(0) @binding(0) var u: Uniforms; -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; const TAU: f32 = 6.283185307179586; @@ -116,7 +115,7 @@ fn resolve_px_hw(px_hw: f32) -> f32 { let world_pos_rel = center_rel + (u_val * axis_x + v_val * axis_y) * ext; var clip_pos = u.view_rot * vec4(world_pos_rel, 1.0); - clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, draw_depth); var out: VertexOut; out.clip_pos = clip_pos; diff --git a/src/shaders/draw_order.wgsl b/src/shaders/draw_order.wgsl new file mode 100644 index 000000000..d0d243d85 --- /dev/null +++ b/src/shaders/draw_order.wgsl @@ -0,0 +1,13 @@ +// Positive order draws in front; zero retains geometric depth. Keep the usual +// 0.001 NDC offset away from the clipping planes, but reduce it near either +// plane: perspective depth can be much closer to 1 than this fixed offset. +// +// Using order / (1 + abs(order)) for the limited region keeps ordering monotonic +// (including the paper background's -2). Less than half the remaining depth is +// consumed, so valid vertices stay valid. Out-of-range/behind-eye vertices get +// no offset and remain subject to the real near/far clipping planes. +fn apply_draw_order(clip: vec4, order: f32) -> vec4 { + let room = 0.5 * max(0.0, min(clip.z, clip.w - clip.z)); + let scale = min(0.001 * max(clip.w, 0.0), room / (1.0 + abs(order))); + return vec4(clip.xy, clip.z - order * scale, clip.w); +} diff --git a/src/shaders/ellipse.wgsl b/src/shaders/ellipse.wgsl index b6c35ba62..5fac0447c 100644 --- a/src/shaders/ellipse.wgsl +++ b/src/shaders/ellipse.wgsl @@ -21,7 +21,6 @@ struct Uniforms { } @group(0) @binding(0) var u: Uniforms; -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; const TAU: f32 = 6.283185307179586; @@ -98,7 +97,7 @@ fn resolve_hw(px_hw: f32) -> f32 { let world_pos_rel = center_rel + (u_val * ext_u) * u_axis + (v_val * ext_v) * v_axis; var clip_pos = u.view_rot * vec4(world_pos_rel, 1.0); - clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, draw_depth); var out: VertexOut; out.clip_pos = clip_pos; diff --git a/src/shaders/face3d.wgsl b/src/shaders/face3d.wgsl index ec1e3a36a..e2c2639b3 100644 --- a/src/shaders/face3d.wgsl +++ b/src/shaders/face3d.wgsl @@ -34,17 +34,16 @@ struct VertexOut { @location(0) color: vec4, }; -// Draw-order depth bias (see wire.wgsl). Signed draw_depth; 0.0 (all 3D +// Draw-order depth bias (see draw_order.wgsl). Signed draw_depth; 0.0 (all 3D // surface faces — 3DFACE, PolyfaceMesh, PolygonMesh) leaves real depth // untouched so they occlude against solids; only 2D fills order by rank. -const DRAW_ORDER_BIAS: f32 = 0.001; @vertex fn vs_main(v: VertexIn) -> VertexOut { var out: VertexOut; let rel = (v.position - u.eye_high) + (v.position_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - v.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, v.draw_depth); out.color = v.color; return out; } diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index bf34140bd..b8800c41e 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -47,9 +47,6 @@ struct HatchInstance { grad_kind: u32, // shape (0=linear,1=cyl,2=sph,3=hemi,4=curved), bit4=invert } -// Draw-order depth bias (see wire.wgsl). Higher draw_depth → smaller z → -// drawn on top, ordering this fill against other entity types. -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -122,8 +119,7 @@ struct VOut { local.y + inst.world_origin_low.y + v.translation_low.y - u.eye_low.y, -u.eye_low.z); o.clip = u.view_rot * vec4(hi + lo, 1.0); - o.clip.z = o.clip.z - - (inst.draw_depth + v.draw_depth) * DRAW_ORDER_BIAS * o.clip.w; + o.clip = apply_draw_order(o.clip, inst.draw_depth + v.draw_depth); o.xz = local; o.instance_index = v.instance_index; return o; diff --git a/src/shaders/hatch_texture.wgsl b/src/shaders/hatch_texture.wgsl index 237ad8341..a46457d39 100644 --- a/src/shaders/hatch_texture.wgsl +++ b/src/shaders/hatch_texture.wgsl @@ -110,7 +110,7 @@ struct VOut { v.pos.y + h.origin_low.y + v.translation_low.y - u.eye_low.y, v.pos.z - u.eye_low.z); o.clip = u.view_rot * vec4(hi + lo, 1.0); - o.clip.z = o.clip.z - v.draw_depth * 0.001 * o.clip.w; + o.clip = apply_draw_order(o.clip, v.draw_depth); o.xz = vec2(v.pos.x, v.pos.y); return o; } diff --git a/src/shaders/image.wgsl b/src/shaders/image.wgsl index 9e795a5a5..ca7860aa7 100644 --- a/src/shaders/image.wgsl +++ b/src/shaders/image.wgsl @@ -33,9 +33,6 @@ struct ImageParams { }; @group(1) @binding(2) var img_params: ImageParams; -// Draw-order depth bias (see wire.wgsl). -const DRAW_ORDER_BIAS: f32 = 0.001; - // ── Vertex stage ────────────────────────────────────────────────────────────── struct VertIn { @location(0) pos: vec3, @@ -57,8 +54,7 @@ fn vs_main(in: VertIn) -> VertOut { let rel = (in.pos + in.translation - u.eye_high) + (in.pos_low + in.translation_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - - (img_params.draw_depth + in.draw_depth) * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, img_params.draw_depth + in.draw_depth); out.uv = in.uv; return out; } diff --git a/src/shaders/text.wgsl b/src/shaders/text.wgsl index a3ff27167..9062f8320 100644 --- a/src/shaders/text.wgsl +++ b/src/shaders/text.wgsl @@ -26,9 +26,6 @@ struct Uniforms { @group(1) @binding(0) var atlas_tex: texture_2d; @group(1) @binding(1) var atlas_samp: sampler; -// Draw-order depth bias (see wire.wgsl / image.wgsl). -const DRAW_ORDER_BIAS: f32 = 0.001; - // ── Vertex stage ────────────────────────────────────────────────────────────── struct VertIn { @location(0) pos: vec3, @@ -50,7 +47,7 @@ fn vs_main(in: VertIn) -> VertOut { // Double-single relative-to-eye, then rotation-only projection. let rel = (in.pos - u.eye_high) + (in.pos_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - in.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, in.draw_depth); out.uv = in.uv; out.color = in.color; return out; diff --git a/src/shaders/wipeout.wgsl b/src/shaders/wipeout.wgsl index 2558441ba..ae0d937ed 100644 --- a/src/shaders/wipeout.wgsl +++ b/src/shaders/wipeout.wgsl @@ -106,10 +106,10 @@ struct VOut { let hi = h.plane_origin_high.xyz + v.translation - u.eye_high; let lo = local_world + h.plane_origin_low.xyz + v.translation_low - u.eye_low; o.clip = u.view_rot * vec4(hi + lo, 1.0); - // Draw-order bias (see wire.wgsl): the mask erases what draws BELOW its + // Draw-order bias (see draw_order.wgsl): the mask erases what draws BELOW its // entity and loses to what draws above — including its own frame wire, // which rides a +half-rank override. - o.clip.z = o.clip.z - (h.draw_depth + v.draw_depth) * 0.001 * o.clip.w; + o.clip = apply_draw_order(o.clip, h.draw_depth + v.draw_depth); o.xz = vec2(v.pos.x, v.pos.y); return o; } diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index 6a458b1dd..69eefa06b 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -67,11 +67,6 @@ struct InstanceIn { @location(12) marker_normal_scale: vec4, } -// Draw-order depth bias: shifts clip-space z so 2D entities of different -// types order against each other through the shared LessEqual depth test. -// draw_depth is signed (-1,1): front → positive → smaller z → drawn on top; -// 0.0 = neutral (real depth). Depth32Float gives ample precision. -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -199,7 +194,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); - clip_pos.z = clip_pos.z - in.misc.x * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, in.misc.x); final_clip = clip_pos; out_dist = mix(in.dists.x, in.dists.y, which_end); @@ -233,7 +228,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: let offset_px = perp * hw * side + dir * hw * ext; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); - clip_pos_out.z = clip_pos_out.z - in.misc.x * DRAW_ORDER_BIAS * clip_pos_out.w; + clip_pos_out = apply_draw_order(clip_pos_out, in.misc.x); final_clip = clip_pos_out; out_dist = mix(in.dists.x, in.dists.y, which_end) + ext * hw * u.world_per_pixel; diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 8a3d099e3..9344b199a 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -53,7 +53,6 @@ struct InstanceIn { @location(7) taper_ratio: vec2, } -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -172,7 +171,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); - clip_pos.z = clip_pos.z - c.draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, c.draw_depth); final_clip = clip_pos; out_dist = mix(in.distance_a, in.distance_b, which_end); @@ -206,7 +205,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon let offset_px = perp * hw * side + dir * hw * ext; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); - clip_pos_out.z = clip_pos_out.z - c.draw_depth * DRAW_ORDER_BIAS * clip_pos_out.w; + clip_pos_out = apply_draw_order(clip_pos_out, c.draw_depth); final_clip = clip_pos_out; out_dist = mix(in.distance_a, in.distance_b, which_end) + ext * hw * u.world_per_pixel;