diff --git a/apps/solvecraft-web/src/main.rs b/apps/solvecraft-web/src/main.rs index ad31f8a2..a62b041b 100644 --- a/apps/solvecraft-web/src/main.rs +++ b/apps/solvecraft-web/src/main.rs @@ -376,7 +376,8 @@ mod web { app.load_prefs(&p); } if let Some(rs) = &cc.wgpu_render_state { - app.set_wgpu(rs, DEPTH_BITS, 1); + // eframe's web painter has no stencil buffer. + app.set_wgpu(rs, DEPTH_BITS, 0, 1); } if query().contains("sample") { if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) { diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 21132f54..4dcdcc4d 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -94,6 +94,8 @@ fn msaa() -> u16 { const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); const DEPTH_BITS: u8 = 24; +/// The section cap marks where it shows in the stencil. +const STENCIL_BITS: u8 = 8; const MSAA: u16 = 4; fn main() -> eframe::Result { @@ -129,6 +131,7 @@ fn main() -> eframe::Result { .with_title_shown(false) .with_app_id("ai.storyteller.solvecraft"), depth_buffer: DEPTH_BITS, + stencil_buffer: STENCIL_BITS, multisampling: samples, ..Default::default() }; @@ -155,7 +158,7 @@ fn main() -> eframe::Result { app.load_prefs(&p); } if let Some(rs) = &cc.wgpu_render_state { - app.set_wgpu(rs, DEPTH_BITS, u32::from(samples)); + app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples)); } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index beef00be..13acae14 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -11,7 +11,7 @@ use egui_wgpu::wgpu::util::DeviceExt; pub const TRI_SIZE: usize = 28; /// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32). pub const LINE_SIZE: usize = 32; -const UNIFORM_SIZE: u64 = 192; +const UNIFORM_SIZE: u64 = 208; /// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the /// highlight scene (hover, selection, the origin widget) changes often and is small. @@ -146,6 +146,8 @@ pub struct ViewportCallback { pub access: Option, /// Draw silhouette outlines around the model (visual styles with edges). pub outline: bool, + /// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth. + pub eye: [f32; 4], } /// Heights of the model seen from a direction (an orthographic depth map): a point is reachable @@ -208,6 +210,9 @@ impl Batches { struct Resources { tri: wgpu::RenderPipeline, + /// While a section is shown (when the depth buffer has a stencil): `tri` marking where the + /// cap shows, then the cap again at the section plane's depth, so it hides the inside. + tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, /// Highlight triangles over the model (depth test ≤, no depth write). tri_hl: wgpu::RenderPipeline, trans: wgpu::RenderPipeline, @@ -281,6 +286,7 @@ struct U { pull: vec4, // draft pull direction acc_u: vec4, // accessibility map axes (xyz, offset) acc_v: vec4, + eye: vec4, // eye position, w = 1 for perspective (0: orthographic, looking along -back) }; @group(1) @binding(2) var acc_t: texture_2d; @@ -321,6 +327,41 @@ fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: /// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour. @fragment fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 { + let c = solid(i, front); + return c; +} + +struct CutOut { + @location(0) c: vec4, + @builtin(frag_depth) depth: f32, +}; + +/// The section cap (the inside seen through the cut, drawn on the far walls' back faces where +/// they are the visible surface) again, at the depth of the section plane rather than of the +/// far wall, so the cut face hides what lies inside the body instead of showing it like a +/// pane of glass. +@fragment +fn fs_solid_cut(i: TOut, @builtin(front_facing) front: bool) -> CutOut { + var o: CutOut; + o.c = solid(i, front); + o.depth = cap_depth(i.wp, i.pos.z); + return o; +} + +/// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut), +/// never behind the wall itself (`z`). +fn cap_depth(wp: vec3, z: f32) -> f32 { + var dir = -u.back.xyz; + if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; } + let dn = dot(u.clip.xyz, dir); + if (abs(dn) < 1e-9) { return z; } + let p = wp + dir * ((u.clip.w - dot(u.clip.xyz, wp)) / dn); + let c = u.vp * vec4(p, 1.0); + if (c.w <= 0.0) { return z; } + return clamp(c.z / c.w, 0.0, z); +} + +fn solid(i: TOut, front: bool) -> vec4 { // Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after // a branch that returns; the whole shader module then fails and nothing is drawn). let k = curvature(i); @@ -699,27 +740,29 @@ impl Resources { bind_group_layouts: &[Some(&bgl), Some(&access_bgl)], immediate_size: 0, }); - let solid = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("sc_tris"), - layout: Some(&solid_layout), - vertex: wgpu::VertexState { - module: &module, - entry_point: Some("vs_tri"), - buffers: &[Some(tri_layout())], - compilation_options: Default::default(), - }, - primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, - depth_stencil: depth(wgpu::CompareFunction::Less, true), - 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_solid"), - targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }); + let solid = |label: &str, fs: &str, cull: Option, ds: Option| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&solid_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_tri"), + buffers: &[Some(tri_layout())], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: cull, ..Default::default() }, + depth_stencil: ds, + 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), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; let access_bind = access_texture(device, None, &access_bgl, 1, &[]); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("sc_image"), @@ -733,7 +776,29 @@ impl Resources { sampler, textures: Default::default(), quads: Vec::new(), - tri: solid, + tri: solid("sc_tris", "fs_solid", None, depth(wgpu::CompareFunction::Less, true)), + tri_cut: t.depth.filter(|f| f.has_stencil_aspect()).map(|format| { + // The visible surface marks the stencil: 1 where it is a back face (the cap). + use wgpu::StencilOperation::{Keep, Replace, Zero}; + let face = |pass_op| wgpu::StencilFaceState { compare: wgpu::CompareFunction::Always, fail_op: Keep, depth_fail_op: Keep, pass_op }; + let mark = wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff }, + bias: Default::default(), + }; + // Then the cap's back faces again, only there, at the section plane's depth. + let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) }; + let cap = wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState { front: only, back: only, read_mask: 0xff, write_mask: 0 }, + bias: Default::default(), + }; + (solid("sc_tris_mark", "fs_solid", None, Some(mark)), solid("sc_tris_cap", "fs_solid_cut", Some(wgpu::Face::Front), Some(cap))) + }), access_bgl, access_bind, access_version: 0, @@ -795,9 +860,10 @@ impl Batches { } } -/// 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 }; +/// Create the pipelines. Call once with the app's render state and its depth/stencil/MSAA +/// settings. Without a stencil the section cap does not hide the inside of cut bodies. +pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) -> GpuTarget { + let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits), samples }; let res = Resources::new(&rs.device, t); rs.renderer.write().callback_resources.insert(res); t @@ -901,8 +967,20 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { let (m, h, pv) = (&res.model, &res.highlight, &res.preview); lines(pass, &res.overlay, &m.under); pass.set_bind_group(1, &res.access_bind, &[]); - tris(pass, &res.tri, &m.tris); - tris(pass, &res.tri, &pv.tris); + let cut = res.tri_cut.as_ref().filter(|_| self.clip[..3].iter().map(|c| c * c).sum::() > 0.25); + match cut { + Some((mark, cap)) => { + pass.set_stencil_reference(1); + tris(pass, mark, &m.tris); + tris(pass, mark, &pv.tris); + tris(pass, cap, &m.tris); + tris(pass, cap, &pv.tris); + } + None => { + tris(pass, &res.tri, &m.tris); + tris(pass, &res.tri, &pv.tris); + } + } tris(pass, &res.tri_hl, &h.tris); lines(pass, &res.line, &m.lines); lines(pass, &res.line_hidden, &m.hidden); @@ -943,6 +1021,7 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec } None => v.extend([0.0; 8]), } + v.extend(cb.eye); v.iter().flat_map(|x| x.to_le_bytes()).collect() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dad3b43e..8b422eb0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -294,10 +294,10 @@ impl SolveApp { } } - /// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth buffer - /// bits and MSAA sample count). Without it the viewport renders on the CPU. - pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) { - self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples)); + /// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth and + /// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU. + pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) { + self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples)); let info = rs.adapter.get_info(); self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)); } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e8e375b0..c70fefff 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1478,6 +1478,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { analysis: analysis_uniform(app), access: crate::access::map(app), outline: matches!(app.ui.visual_style, 0 | 3), + eye: { + let e = proj.cam.eye().to_f32(); + [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }] + }, }; painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } else {