fix(renderer): keep draw-order offsets within clipping planes

This commit is contained in:
Christopher Overbeck committed 2026-09-15 12:36:05 -05:00
1 parent 0e42e3bf28
commit e52e565b1f
26 files changed
+525 -90

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+38
View File
@@ -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.
+2 -2
View File
@@ -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(),
+370
View File
@@ -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<u64, [f32; 2]>,
) -> Vec<u8> {
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"
);
}
}
+2 -2
View File
@@ -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(),
+2 -2
View File
@@ -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 {
+29 -12
View File
@@ -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"
))),
});
+2 -2
View File
@@ -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 {
+7 -7
View File
@@ -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<String> = 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(),
+40 -15
View File
@@ -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.)
+1 -4
View File
@@ -28,16 +28,13 @@ struct VertexOut {
@location(0) color: vec4<f32>,
}
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<f32>(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;
}
+1 -4
View File
@@ -32,16 +32,13 @@ struct VertOut {
@location(1) color: vec4<f32>,
}
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<f32>(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;
+2 -3
View File
@@ -44,7 +44,6 @@ struct VertexIn {
@location(8) depth: vec2<f32>,
}
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<f32>(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<f32>(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)
+2 -3
View File
@@ -68,7 +68,6 @@ struct VertexIn {
@location(8) depth: vec2<f32>,
}
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<f32>(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<f32>(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)
+1 -2
View File
@@ -26,7 +26,6 @@ struct Uniforms {
}
@group(0) @binding(0) var<uniform> 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<f32>(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;
+13
View File
@@ -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<f32>, order: f32) -> vec4<f32> {
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<f32>(clip.xy, clip.z - order * scale, clip.w);
}
+1 -2
View File
@@ -21,7 +21,6 @@ struct Uniforms {
}
@group(0) @binding(0) var<uniform> 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<f32>(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;
+2 -3
View File
@@ -34,17 +34,16 @@ struct VertexOut {
@location(0) color: vec4<f32>,
};
// 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<f32>(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;
}
+1 -5
View File
@@ -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<f32>(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;
+1 -1
View File
@@ -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<f32>(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<f32>(v.pos.x, v.pos.y);
return o;
}
+1 -5
View File
@@ -33,9 +33,6 @@ struct ImageParams {
};
@group(1) @binding(2) var<uniform> 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<f32>,
@@ -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<f32>(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;
}
+1 -4
View File
@@ -26,9 +26,6 @@ struct Uniforms {
@group(1) @binding(0) var atlas_tex: texture_2d<f32>;
@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<f32>,
@@ -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<f32>(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;
+2 -2
View File
@@ -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<f32>(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<f32>(v.pos.x, v.pos.y);
return o;
}
+2 -7
View File
@@ -67,11 +67,6 @@ struct InstanceIn {
@location(12) marker_normal_scale: vec4<f32>,
}
// 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<f32>, position_low: vec3<f32>, 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<f32>(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<f32>, position_low: vec3<f32>, 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<f32>(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;
+2 -3
View File
@@ -53,7 +53,6 @@ struct InstanceIn {
@location(7) taper_ratio: vec2<f32>,
}
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<f32>, position_low: vec3<f32>, 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<f32>(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<f32>, position_low: vec3<f32>, 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<f32>(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;