From bbf2dba092c72aebf73eca5afdb9c82612f17e61 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:42:31 -0400 Subject: [PATCH] render: translucent faces in both renderers; datum planes get a light fill (#302) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/render/src/glyphs.rs | 28 ++++++++--------- crates/render/src/lib.rs | 4 +-- crates/render/src/raster.rs | 55 +++++++++++++++++++++++++++++++-- crates/ui-egui/src/gpu.rs | 61 ++++++++++++++++++++++++++++--------- 4 files changed, 115 insertions(+), 33 deletions(-) diff --git a/crates/render/src/glyphs.rs b/crates/render/src/glyphs.rs index e95c773..73b622c 100644 --- a/crates/render/src/glyphs.rs +++ b/crates/render/src/glyphs.rs @@ -51,7 +51,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use fieldcraft_model::{BoundaryCondition, ContactSurfaces, ElementFace, Load, Model, Procedure, ResolvedSet, Target}; use crate::display::{centre, polygon_normal}; -use crate::{Aabb3, Rgb, Scene, SceneLabel, SceneLine, ScenePoint, Vec3}; +use crate::{Aabb3, Rgb, Scene, SceneFill, SceneLabel, SceneLine, ScenePoint, SceneTri, Vec3}; /// The symbol unit drawn when the caller gives none: a fraction of the model's bounding box /// diagonal. @@ -894,8 +894,8 @@ impl Glyphs { const DATUM_MARGIN: f64 = 0.05; /// A datum point's cross: each arm's length, as a fraction of the bounding box diagonal. const DATUM_POINT: f64 = 0.02; -/// Hatch lines across a datum plane's rectangle (its light fill). -const DATUM_HATCH: usize = 4; +/// A datum plane's fill: how opaque its translucent rectangle is. +pub const DATUM_FILL_ALPHA: f64 = 0.12; /// Corner `k` (0..8, one bit per axis) of box `b`. fn box_corner(b: &Aabb3, k: usize) -> Vec3 { @@ -958,7 +958,7 @@ fn axis_ends(bounds: &Aabb3, p: Vec3, a: Vec3) -> (Vec3, Vec3) { (p + a * (t0 - margin), p + a * (t1 + margin)) } -/// Draw the model's datum planes (an outlined, lightly hatched rectangle: the model's extent +/// Draw the model's datum planes (an outlined rectangle with a light translucent fill: the model's extent /// within the plane, `bounds`, plus a margin), axes (a line along the model's extent) and points /// (a small cross), each with its name; returns the box around them. Only the datums `shown` /// takes are drawn, and `highlight` (the one selected in the browser) whether shown or not, in @@ -976,7 +976,7 @@ pub fn datums( for (name, datum) in model.datums().iter().filter(|(n, _)| shown(n) || highlight == Some(n.as_str())) { let picked = highlight == Some(name.as_str()); let color = if picked { colors.highlight } else { colors.datum }; - let (outline, thin) = if picked { (HIGHLIGHT_WIDTH, OUTLINE_WIDTH) } else { (OUTLINE_WIDTH, COUPLING_WIDTH) }; + let outline = if picked { HIGHLIGHT_WIDTH } else { OUTLINE_WIDTH }; // The one selected in the browser shows through the part. (d.color, d.on_top) = (color, picked); match *datum { @@ -991,11 +991,9 @@ pub fn datums( let b = ring.get((k + 1) % 4).copied().unwrap_or(*a); d.line(*a, b); } - d.width = thin; - for k in 1..DATUM_HATCH { - let t = -1.0 + 2.0 * k as f64 / DATUM_HATCH as f64; - d.line(corner(t, -1.0), corner(t, 1.0)); - d.line(corner(-1.0, t), corner(1.0, t)); + let [a, b, c2, e] = ring; + for p in [[a, b, c2], [a, c2, e]] { + d.scene.fills.push(SceneFill { tri: SceneTri { p, normal: n, color }, alpha: DATUM_FILL_ALPHA }); } d.scene.labels.push(SceneLabel { p: corner(1.0, 1.0), text: name.clone(), color }); } @@ -1712,9 +1710,11 @@ mod tests { let bounds = cube(0.0, 10.0); let mut scene = Scene::default(); let drawn = datums(&m, &bounds, &|_| true, None, &COLORS, &mut scene); - // A rectangle with its hatch (4 + 2 × 3 lines), the axis line and the point's three arms, - // each crossing at the point. - assert_eq!(scene.lines.len(), 14); + // A rectangle (4 lines, filled by two translucent triangles), the axis line and the + // point's three arms, each crossing at the point. + assert_eq!(scene.lines.len(), 8); + assert_eq!(scene.fills.len(), 2); + assert!(scene.fills.iter().all(|f| f.alpha == DATUM_FILL_ALPHA && f.tri.normal == Vec3::X)); let p = Vec3::new(1.0, 2.0, 3.0); assert!(scene.lines.iter().rev().take(3).all(|l| ((l.a + l.b) * 0.5).dist(p) < 1e-12)); let mut names: Vec<&str> = scene.labels.iter().map(|l| l.text.as_str()).collect(); @@ -1734,7 +1734,7 @@ mod tests { assert_eq!((only.lines.len(), only.labels.len()), (1, 1)); let mut picked = Scene::default(); datums(&m, &bounds, &|_| false, Some("Mid"), &COLORS, &mut picked); - assert_eq!((picked.lines.len(), picked.on_top.len()), (0, 10)); + assert_eq!((picked.lines.len(), picked.on_top.len(), picked.fills.len()), (0, 4, 2)); assert!(picked.on_top.iter().all(|l| l.color == COLORS.highlight)); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 62a6213..dd4680f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -18,7 +18,7 @@ mod vec; pub use camera::{Camera, FIT_MARGIN, FULL_VIEW, Mat4, Orientation, StandardView}; pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceCurve, SurfaceTri}; pub use raster::{ - Canvas, LABEL_OFFSET, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLabel, SceneLine, ScenePoint, SceneTri, fit_size, png, - render, render_layers, render_layers_png, render_png, shade, + Canvas, LABEL_OFFSET, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneFill, SceneLabel, SceneLine, ScenePoint, SceneTri, + fit_size, png, render, render_layers, render_layers_png, render_png, shade, }; pub use vec::{Aabb3, Vec3}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index d6a5aa3..5c6b9a5 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -26,6 +26,14 @@ pub struct SceneTri { pub color: Rgb, } +/// A translucent triangle (a datum plane's fill): drawn after the opaque faces, depth-tested +/// against them without hiding what is behind, blended at `alpha` (0–1). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SceneFill { + pub tri: SceneTri, + pub alpha: f64, +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct SceneLine { pub a: Vec3, @@ -55,6 +63,8 @@ pub struct SceneLabel { #[derive(Clone, Debug, Default, PartialEq)] pub struct Scene { pub tris: Vec, + /// Translucent triangles ([`SceneFill`]). + pub fills: Vec, pub lines: Vec, pub points: Vec, pub labels: Vec, @@ -71,6 +81,7 @@ impl Scene { /// Add `other`'s primitives (not its background or radius) to this scene. pub fn append(&mut self, other: Scene) { self.tris.extend(other.tris); + self.fills.extend(other.fills); self.lines.extend(other.lines); self.points.extend(other.points); self.labels.extend(other.labels); @@ -221,7 +232,7 @@ const DEPTH_BIAS: f64 = 2e-4; /// [`render`] with overlays drawn over the scene (the setup's symbols, a selection highlight): /// their faces over the scene's faces they coincide with (a highlight repeats the faces it -/// marks), then every layer's lines and points depth-tested, then every layer's +/// marks), then every layer's translucent faces blended over them, then every layer's lines and points depth-tested, then every layer's /// [`Scene::on_top`] lines and labels over everything. The overlays' background and radius are /// not used. pub fn render_layers(scene: &Scene, overlays: &[&Scene], cam: &Camera, w: usize, h: usize) -> Result { @@ -229,7 +240,13 @@ pub fn render_layers(scene: &Scene, overlays: &[&Scene], cam: &Camera, w: usize, return Err(RenderError::Size { w, h }); } let count = |s: &Scene| { - s.tris.len().saturating_add(s.lines.len()).saturating_add(s.points.len()).saturating_add(s.labels.len()).saturating_add(s.on_top.len()) + s.tris + .len() + .saturating_add(s.fills.len()) + .saturating_add(s.lines.len()) + .saturating_add(s.points.len()) + .saturating_add(s.labels.len()) + .saturating_add(s.on_top.len()) }; let primitives = overlays.iter().fold(count(scene), |n, o| n.saturating_add(count(o))); if primitives > MAX_PRIMITIVES { @@ -277,6 +294,18 @@ pub fn render_layers(scene: &Scene, overlays: &[&Scene], cam: &Camera, w: usize, }); } let layers = || std::iter::once(scene).chain(overlays.iter().copied()); + // Translucent faces over the opaque ones, without writing depth: what is behind shows. + for f in layers().flat_map(|s| &s.fills) { + let t = &f.tri; + let (Some(a), Some(b), Some(cc)) = (project(t.p[0]), project(t.p[1]), project(t.p[2])) else { continue }; + let alpha = if f.alpha.is_finite() { f.alpha.clamp(0.0, 1.0) } else { 0.0 }; + let col = shade(t.normal, view_dir, t.color.f()); + raster_tri(a, b, cc, sw, sh, |x, y, z| { + if c.depth.get(y * sw + x).is_some_and(|d| z < *d) { + c.put(x, y, col, alpha); + } + }); + } let line = |c: &mut Canvas, l: &SceneLine, depth_test: bool| { let (Some(a), Some(b)) = (biased(l.a, 1.0), biased(l.b, 1.0)) else { return }; let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); @@ -486,6 +515,28 @@ mod tests { assert!(p[2] > 200 && p[0] < 60, "{p:?}"); } + /// A translucent face tints what is behind it and hides nothing; one behind an opaque face + /// does not show (#302). + #[test] + fn translucent_faces_blend_over_what_is_in_front_only() { + let mut cam = Camera { distance: 50.0, ..Default::default() }; + cam.set_view(StandardView::Front); + let px = |c: &Canvas| c.rgba[(32 * c.w + 32) * 4..(32 * c.w + 32) * 4 + 3].to_vec(); + let red = red_triangle(); + let plain = px(&render(&red, &cam, 64, 64).unwrap()); + let fill = |y: f64| SceneFill { + tri: SceneTri { p: red.tris[0].p.map(|p| Vec3::new(p.x, y, p.z)), color: Rgb(0, 0, 255), ..red.tris[0] }, + alpha: 0.25, + }; + let mut front = red.clone(); + front.fills.push(fill(-1.0)); + let tinted = px(&render(&front, &cam, 64, 64).unwrap()); + assert!(tinted[2] > plain[2] + 20 && tinted[0] > 120, "red still shows, tinted blue: {tinted:?} over {plain:?}"); + let mut back = red.clone(); + back.fills.push(fill(1.0)); + assert_eq!(px(&render(&back, &cam, 64, 64).unwrap()), plain); + } + /// In perspective, close to the part (the near plane at its smallest), a line a little /// behind a face stays hidden and one on it shows (#282). #[test] diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 9053e08..329d159 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -5,7 +5,8 @@ //! (which resizes them) never uploads the mesh again. The selection highlight is a third, small //! scene (the overlay) drawn over the first: its faces pass the depth test over the faces they //! repeat, so a hover change uploads only the overlay. Every layer's `on_top` lines are drawn -//! last, with no depth test. +//! last, with no depth test. Translucent faces (a datum plane's fill) are drawn after every +//! opaque face, blended, depth-tested without writing depth, as the CPU renderer draws them. //! //! A large mesh does not fit one vertex buffer (wgpu's default `max_buffer_size`, which eframe //! requests, is 256 MiB): geometry is split into batches of at most [`MAX_BATCH_BYTES`] and the @@ -39,6 +40,8 @@ pub type GpuErrors = Arc>>; #[derive(Default, Clone, Debug, PartialEq)] pub struct GpuScene { pub tris: Vec, + /// Translucent triangles ([`Scene::fills`]), in the triangle format with their alpha. + pub fills: Vec, pub lines: Vec, /// Lines drawn over everything ([`Scene::on_top`]). pub top_lines: Vec, @@ -48,10 +51,16 @@ impl GpuScene { /// The scene's triangles, lines and points; widths and sizes scaled by `pixels_per_point` /// (the scene gives them in points). pub fn from_scene(scene: &Scene, pixels_per_point: f32) -> GpuScene { - let mut g = GpuScene { tris: Vec::with_capacity(scene.tris.len() * 3 * TRI_SIZE), lines: Vec::new(), top_lines: Vec::new() }; + let mut g = GpuScene { tris: Vec::with_capacity(scene.tris.len() * 3 * TRI_SIZE), ..GpuScene::default() }; for t in &scene.tris { for p in t.p { - g.vertex(p.to_f32(), t.normal.to_f32(), t.color); + push_vertex(&mut g.tris, p.to_f32(), t.normal.to_f32(), t.color, 255); + } + } + for f in &scene.fills { + let alpha = if f.alpha.is_finite() { (f.alpha.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + for p in f.tri.p { + push_vertex(&mut g.fills, p.to_f32(), f.tri.normal.to_f32(), f.tri.color, alpha); } } for l in &scene.lines { @@ -65,17 +74,19 @@ impl GpuScene { } g } - fn vertex(&mut self, p: [f32; 3], n: [f32; 3], c: Rgb) { - for v in p.iter().chain(&n) { - self.tris.extend_from_slice(&v.to_le_bytes()); - } - self.tris.extend_from_slice(&[c.0, c.1, c.2, 255]); - } fn line(&mut self, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) { push_line(&mut self.lines, a, b, c, width); } } +/// A triangle vertex into `buf`. +fn push_vertex(buf: &mut Vec, p: [f32; 3], n: [f32; 3], c: Rgb, alpha: u8) { + for v in p.iter().chain(&n) { + buf.extend_from_slice(&v.to_le_bytes()); + } + buf.extend_from_slice(&[c.0, c.1, c.2, alpha]); +} + /// A line instance into `buf`. fn push_line(buf: &mut Vec, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) { for v in a.iter().chain(&b) { @@ -119,6 +130,7 @@ struct Batch { struct Layer { key: Option, tris: Vec, + fills: Vec, lines: Vec, top_lines: Vec, } @@ -131,10 +143,12 @@ impl Layer { if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) { // The old buffers go first, so the new ones don't have to fit beside them. self.tris.clear(); + self.fills.clear(); self.lines.clear(); self.top_lines.clear(); let max = device.limits().max_buffer_size; self.tris = upload(device, queue, "fc_tris", &sc.tris, TRI_SIZE, 3, max); + self.fills = upload(device, queue, "fc_fills", &sc.fills, TRI_SIZE, 3, max); self.lines = upload(device, queue, "fc_lines", &sc.lines, LINE_SIZE, 1, max); self.top_lines = upload(device, queue, "fc_top_lines", &sc.top_lines, LINE_SIZE, 1, max); self.key = Some(key); @@ -142,12 +156,16 @@ impl Layer { } fn paint_tris(&self, pass: &mut wgpu::RenderPass<'static>, tri: &wgpu::RenderPipeline) { - if !self.tris.is_empty() { - pass.set_pipeline(tri); - for b in &self.tris { - pass.set_vertex_buffer(0, b.buffer.slice(..)); - pass.draw(0..b.count, 0..1); - } + paint_tri_batches(&self.tris, pass, tri); + } +} + +fn paint_tri_batches(batches: &[Batch], pass: &mut wgpu::RenderPass<'static>, tri: &wgpu::RenderPipeline) { + if !batches.is_empty() { + pass.set_pipeline(tri); + for b in batches { + pass.set_vertex_buffer(0, b.buffer.slice(..)); + pass.draw(0..b.count, 0..1); } } } @@ -166,6 +184,8 @@ struct Resources { tri: wgpu::RenderPipeline, /// The overlay's faces: pulled toward the eye and depth-tested without writing depth. overlay_tri: wgpu::RenderPipeline, + /// Translucent faces: blended, depth-tested without writing depth. + fill: wgpu::RenderPipeline, line: wgpu::RenderPipeline, /// Lines over everything: no depth test. top_line: wgpu::RenderPipeline, @@ -380,6 +400,14 @@ impl Resources { Resources { tri: pipeline("fc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None), overlay_tri: pipeline("fc_overlay_tris", "vs_overlay_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), None), + fill: pipeline( + "fc_fills", + "vs_tri", + "fs_tri", + tri_layout(), + depth(wgpu::CompareFunction::Less, false), + Some(wgpu::BlendState::ALPHA_BLENDING), + ), line: pipeline( "fc_lines", "vs_line", @@ -489,6 +517,9 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { res.scene.paint_tris(pass, &res.tri); res.symbols.paint_tris(pass, &res.tri); res.overlay.paint_tris(pass, &res.overlay_tri); + for layer in [&res.scene, &res.symbols, &res.overlay] { + paint_tri_batches(&layer.fills, pass, &res.fill); + } for layer in [&res.scene, &res.symbols, &res.overlay] { paint_line_batches(&layer.lines, pass, &res.line); }