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);