From d1cdc4d0616ced652aef946c9b7e6f65cd85ce53 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 03:52:14 +0000 Subject: [PATCH] Silhouette edges on curved surfaces: drawn in the surface shader where it turns away from the eye (about 1.5 px, only on the visible surface, so nothing shows through thin walls), in the viewport and live previews; snapshots draw them too. The back-face outline pass is gone Fixes #48 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/engine/src/view.rs | 11 ++++++ crates/render/src/raster.rs | 13 ++++++- crates/ui-egui/src/gpu.rs | 67 +++++++------------------------------ 3 files changed, 35 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index b857f58d..3018960a 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -282,4 +282,15 @@ mod tests { let img = solvecraft_render::render(&sc, &cam, 200, 140); assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels"); } + + /// Curved surfaces get a dark silhouette line in snapshots where they turn away. + #[test] + fn snapshots_draw_silhouettes_on_curved_surfaces() { + let mut s = Session::default(); + s.execute("solid.sphere", &json!({"diameter": 20})).unwrap(); + let cam = home_camera(&s); + let img = solvecraft_render::render(&scene(&s, &cam), &cam, 400, 400); + let dark = img.rgba.chunks(4).filter(|p| p[0] < 70 && p[1] < 70 && p[2] < 75).count(); + assert!(dark > 40, "silhouette pixels: {dark}"); + } } diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 82cc6b64..6d433dde 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -113,6 +113,9 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { continue; }; let ns: Vec = t.iter().map(|k| m.normals.get(*k as usize).copied().unwrap_or(Vec3::Z)).collect(); + // A curved surface (its normals turn across the triangle) gets a silhouette line + // where it turns away from the eye. + let curved = ns.iter().any(|n| n.dist(ns[0]) > 1e-6); let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0)); let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0)); if !(x0 <= x1 && y0 <= y1) { @@ -138,7 +141,15 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { *d = z; } let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z); - c.put(x, y, shade(n, view_dir, base), 1.0); + let mut col = shade(n, view_dir, base); + let ndv = n.dot(view_dir).abs(); + if curved && ndv < 0.14 { + let a = 1.0 - ndv / 0.14; + for (k, e) in col.iter_mut().zip([0.07, 0.08, 0.10]) { + *k = *k * (1.0 - a) + e * a; + } + } + c.put(x, y, col, 1.0); } } } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 04eb8382..fa2b1da2 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -219,8 +219,6 @@ struct Resources { line: wgpu::RenderPipeline, /// Lines behind the model (depth test >). line_hidden: wgpu::RenderPipeline, - /// Silhouettes: back faces pushed out a little on screen, drawn dark behind the model. - outline: wgpu::RenderPipeline, overlay: wgpu::RenderPipeline, uniform: wgpu::Buffer, bind: wgpu::BindGroup, @@ -276,7 +274,7 @@ const SHADER: &str = r#" struct U { vp: mat4x4, back: vec4, // xyz = toward the eye, w = 1 for linear output - screen: vec4, // viewport width, height (px), depth bias + screen: vec4, // viewport width, height (px), depth bias, silhouettes on (1) clip: vec4, // section plane: normal, d (zero normal: no section) cap: vec4, // colour of the inside of cut bodies ana: vec4, // surface analysis: mode (1 zebra, 2 draft, 3 curvature), parameter @@ -326,6 +324,8 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 0.5) { return out_color(u.cap); @@ -333,7 +333,14 @@ fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 0.5 && length(i.n) > 0.5) { return analysis(i, k); } - return shade(i); + let c = shade(i); + // Silhouettes (styles with edges): where a curved surface turns away from the eye, a line + // about 1.5 px wide, on the visible surface only (depth-correct). + if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) { + let a = 1.0 - smoothstep(fw * 0.8, fw * 2.0, abs(ndv)); + return vec4(mix(c.rgb, out_color(vec4(0.07, 0.08, 0.10, 1.0)).rgb, a), c.a); + } + return c; } /// How fast the normal turns across the pixel (per mm). @@ -479,28 +486,6 @@ fn fs_line(i: LOut) -> @location(0) vec4 { return out_color(i.c); } -/// Silhouettes: back faces pushed out along their screen-space normal by about 1.5 px. -@vertex -fn vs_outline(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { - var o: TOut; - o.pos = u.vp * vec4(p, 1.0); - let nc = (u.vp * vec4(n, 0.0)).xy * u.screen.xy; - if (length(nc) > 1e-6) { - let d = normalize(nc) * 1.5 * 2.0 / u.screen.xy; - o.pos = vec4(o.pos.xy + d * o.pos.w, o.pos.zw); - } - o.n = vec3(0.0); - o.c = c; - o.wp = p; - return o; -} - -@fragment -fn fs_outline(i: TOut) -> @location(0) vec4 { - if (clipped(i.wp)) { discard; } - return out_color(vec4(0.07, 0.08, 0.10, 1.0)); -} - struct IOut { @builtin(position) pos: vec4, @location(0) uv: vec2, @@ -756,31 +741,6 @@ impl Resources { xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha), line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha), - outline: device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("sc_outline"), - layout: Some(&layout), - vertex: wgpu::VertexState { - module: &module, - entry_point: Some("vs_outline"), - buffers: &[Some(tri_layout())], - compilation_options: Default::default(), - }, - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - cull_mode: Some(wgpu::Face::Front), - ..Default::default() - }, - depth_stencil: depth(wgpu::CompareFunction::Less, false), - multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { - module: &module, - entry_point: Some("fs_outline"), - targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }), overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha), uniform, bind, @@ -940,9 +900,6 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { tris(pass, &res.tri, &m.tris); tris(pass, &res.tri, &pv.tris); tris(pass, &res.tri_hl, &h.tris); - if self.outline { - tris(pass, &res.outline, &m.tris); - } lines(pass, &res.line, &m.lines); lines(pass, &res.line_hidden, &m.hidden); lines(pass, &res.line_hidden, &h.hidden); @@ -971,7 +928,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec { let mut v: Vec = cb.view_proj.iter().flatten().copied().collect(); v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]); - v.extend([w.max(1.0), h.max(1.0), 2e-4, 0.0]); + v.extend([w.max(1.0), h.max(1.0), 2e-4, if cb.outline { 1.0 } else { 0.0 }]); v.extend(cb.clip); v.extend(cb.cap); v.extend(cb.analysis);