From 0e2d8efd5cae4c6c93227d118ffe4ecc428d1092 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Fri, 9 Oct 2026 22:45:53 +0000 Subject: [PATCH] See-through bodies and faces are drawn back to front: the glass triangles are re-sorted along the view direction when the view turns (more than about 2 degrees), so overlapping glass blends in order. Large triangles that cut through each other can still show a seam (sorted by centre) Fixes #49 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/ui-egui/src/gpu.rs | 66 +++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 9863d691..04eb8382 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -173,6 +173,9 @@ struct Batches { tris: Option, trans: Option, glass: Option, + /// The glass triangles as built, and the view direction they were last sorted for. + glass_cpu: Vec, + glass_sorted_for: Option<[f32; 3]>, lines: Option, hidden: Option, overlays: Option, @@ -189,7 +192,9 @@ impl Batches { if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) { self.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE); self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE); - self.glass = upload(device, "sc_glass", &sc.glass, TRI_SIZE); + self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST); + self.glass_cpu = sc.glass; + self.glass_sorted_for = None; self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE); self.hidden = upload(device, "sc_hidden", &sc.hidden, LINE_SIZE); self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); @@ -788,19 +793,44 @@ impl Resources { } fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option { + upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX) +} + +fn upload_with(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage: wgpu::BufferUsages) -> Option { if bytes.len() < stride { return None; } Some(Batch { - buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some(label), - contents: bytes, - usage: wgpu::BufferUsages::VERTEX, - }), + buffer: device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: bytes, usage }), count: u32::try_from(bytes.len() / stride).unwrap_or(0), }) } +/// Triangles (3 vertices of `TRI_SIZE` bytes each, position first) ordered far to near along +/// the view direction `back` (toward the eye), so blended glass draws back to front. +pub fn sort_back_to_front(bytes: &[u8], back: [f32; 3]) -> Vec { + let tri = TRI_SIZE * 3; + let depth = |t: &[u8]| -> f32 { + let f = |o: usize| t.get(o..o + 4).and_then(|b| b.try_into().ok()).map_or(0.0, f32::from_le_bytes); + (0..3).map(|v| (0..3).map(|k| f(v * TRI_SIZE + k * 4) * back[k]).sum::()).sum::() + }; + let mut tris: Vec<(f32, &[u8])> = bytes.chunks_exact(tri).map(|t| (depth(t), t)).collect(); + tris.sort_by(|a, b| a.0.total_cmp(&b.0)); + tris.into_iter().flat_map(|(_, t)| t.iter().copied()).collect() +} + +impl Batches { + /// Re-sort the glass for a new view direction (when it turned more than about 2 degrees). + fn sort_glass(&mut self, queue: &wgpu::Queue, back: [f32; 3]) { + let (Some(g), false) = (&self.glass, self.glass_cpu.is_empty()) else { return }; + let turned = self.glass_sorted_for.is_none_or(|s| s[0] * back[0] + s[1] * back[1] + s[2] * back[2] < 0.9994); + if turned { + queue.write_buffer(&g.buffer, 0, &sort_back_to_front(&self.glass_cpu, back)); + self.glass_sorted_for = Some(back); + } + } +} + /// Create the pipelines. Call once with the app's render state and its depth/MSAA settings. pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) -> GpuTarget { let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples }; @@ -822,6 +852,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { res.model.take(device, self.key, &self.slot); res.highlight.take(device, self.hl_key, &self.hl_slot); res.preview.take(device, self.pv_key, &self.pv_slot); + res.model.sort_glass(queue, self.back); queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); if let Some(a) = &self.access && a.version != res.access_version @@ -1042,3 +1073,26 @@ mod tests { } } } + +#[cfg(test)] +mod glass_tests { + use super::*; + + #[test] + fn glass_is_drawn_far_to_near() { + // Two triangles at z = 5 and z = -5, given near first; looking down from +Z. + let mut sc = GpuScene::default(); + for z in [5.0f32, -5.0] { + for p in [[0.0, 0.0, z], [1.0, 0.0, z], [0.0, 1.0, z]] { + sc.glass_tri(p, [0.0, 0.0, 1.0], [255, 0, 0, 100]); + } + } + let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, 1.0]); + let z0 = f32::from_le_bytes(sorted[8..12].try_into().unwrap()); + assert_eq!(z0, -5.0, "the far triangle first"); + assert_eq!(sorted.len(), sc.glass.len()); + // Seen from below, the order flips. + let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, -1.0]); + assert_eq!(f32::from_le_bytes(sorted[8..12].try_into().unwrap()), 5.0); + } +}