diff --git a/src/scene/pipeline/face3d_gpu.rs b/src/scene/pipeline/face3d_gpu.rs index f91cdcd00..13f657237 100644 --- a/src/scene/pipeline/face3d_gpu.rs +++ b/src/scene/pipeline/face3d_gpu.rs @@ -9,6 +9,14 @@ // color [f32; 4] offset 12 16 B // ------ // 28 B / vertex +// +// 3D vs 2D split: `vertex_buffer_3d` holds 3DFACE quads + PolyfaceMesh / +// PolygonMesh face triangles (the "3D" geometry that participates in +// hidden-surface removal). `vertex_buffer_2d` holds the residual fills +// — text-LOD greek dim, MultiLeader background — whose source +// WireModels have an empty `points` list. Splitting them lets the +// render pass send the 3D side through a depth-only pipeline for +// HiddenLine while keeping the 2D side fully visible. use crate::scene::wire_model::WireModel; use iced::wgpu; @@ -47,43 +55,60 @@ impl Face3DVertex { // ── GPU handle ───────────────────────────────────────────────────────────── pub struct Face3DGpu { - pub vertex_buffer: wgpu::Buffer, - pub vertex_count: u32, + /// 3DFACE quads + PolyfaceMesh / PolygonMesh face triangles. + /// HiddenLine routes this through the depth-only pipeline so the + /// fragments occlude wires behind them without drawing visible + /// pixels. + pub vertex_buffer_3d: wgpu::Buffer, + pub vertex_count_3d: u32, + /// Text-LOD greek dim, MultiLeader background, etc. — fills whose + /// source wire has an empty `points` list. Always rendered with the + /// normal face3d pipeline (visible in every mode). + pub vertex_buffer_2d: wgpu::Buffer, + pub vertex_count_2d: u32, } impl Face3DGpu { /// Build a batched GPU buffer from Face3D wire models and mesh fill_tris. /// /// - `face3d_wires`: Face3D entities — `key_vertices` holds 4 quad corners; - /// emits 2 triangles per face. + /// emits 2 triangles per face into the 3D buffer. /// - `all_wires`: all entity wires — `fill_tris` holds pre-triangulated - /// fill data for PolyfaceMesh / PolygonMesh. + /// fill data. Wires with non-empty `points` (PolyfaceMesh / PolygonMesh + /// face data) feed the 3D buffer; wires with empty `points` (2D fills) + /// feed the 2D buffer. + /// - `keep_3d_mesh_fills`: when false (wireframe modes), the 3D side + /// is left empty; the 2D side is always populated. pub fn from_wires( device: &wgpu::Device, face3d_wires: &[WireModel], all_wires: &[WireModel], keep_3d_mesh_fills: bool, ) -> Self { - let mut vertices: Vec = Vec::with_capacity(face3d_wires.len() * 6); + let mut verts_3d: Vec = Vec::with_capacity(face3d_wires.len() * 6); + let mut verts_2d: Vec = Vec::new(); - // Face3D quads (4 key_vertices → 2 triangles). - for wire in face3d_wires { - if wire.key_vertices.len() < 4 { - continue; + // Face3D quads (4 key_vertices → 2 triangles) — only when 3D + // fills are wanted. + if keep_3d_mesh_fills { + for wire in face3d_wires { + if wire.key_vertices.len() < 4 { + continue; + } + let [r, g, b, a] = wire.color; + let fill_color = [r * 0.45, g * 0.45, b * 0.45, a]; + let p = &wire.key_vertices; + let v = |i: usize| Face3DVertex { + position: p[i], + color: fill_color, + }; + verts_3d.push(v(0)); + verts_3d.push(v(1)); + verts_3d.push(v(2)); + verts_3d.push(v(0)); + verts_3d.push(v(2)); + verts_3d.push(v(3)); } - let [r, g, b, a] = wire.color; - let fill_color = [r * 0.45, g * 0.45, b * 0.45, a]; - let p = &wire.key_vertices; - let v = |i: usize| Face3DVertex { - position: p[i], - color: fill_color, - }; - vertices.push(v(0)); - vertices.push(v(1)); - vertices.push(v(2)); - vertices.push(v(0)); - vertices.push(v(2)); - vertices.push(v(3)); } // PolyfaceMesh / PolygonMesh / unlit fills (text greek, MultiLeader @@ -93,42 +118,53 @@ impl Face3DGpu { // with both fill_tris and points (mesh edges + faces) keep the dim // so PolyfaceMesh / PolygonMesh still look 3-D-shaded. // - // When `keep_3d_mesh_fills` is false (wireframe render modes), - // skip wires that carry 3-D mesh face data (`points` + `fill_tris`) - // while leaving 2-D fills (text greek, MultiLeader background) - // alone — they're not "3D objects" the wireframe toggle should - // hide. + // 2-D fills always go to `verts_2d` (visible in every mode). + // 3-D mesh face data goes to `verts_3d` only when + // `keep_3d_mesh_fills` is true. for wire in all_wires { if wire.fill_tris.is_empty() { continue; } let is_3d_mesh_face = !wire.points.is_empty(); - if is_3d_mesh_face && !keep_3d_mesh_fills { - continue; - } let [r, g, b, a] = wire.color; - let fill_color = if wire.points.is_empty() { - [r, g, b, a] + if is_3d_mesh_face { + if !keep_3d_mesh_fills { + continue; + } + let fill_color = [r * 0.45, g * 0.45, b * 0.45, a]; + for &position in &wire.fill_tris { + verts_3d.push(Face3DVertex { + position, + color: fill_color, + }); + } } else { - [r * 0.45, g * 0.45, b * 0.45, a] - }; - for &position in &wire.fill_tris { - vertices.push(Face3DVertex { - position, - color: fill_color, - }); + let fill_color = [r, g, b, a]; + for &position in &wire.fill_tris { + verts_2d.push(Face3DVertex { + position, + color: fill_color, + }); + } } } - let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("face3d.vbuf"), - contents: bytemuck::cast_slice(&vertices), + let vertex_buffer_3d = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("face3d.vbuf.3d"), + contents: bytemuck::cast_slice(&verts_3d), + usage: wgpu::BufferUsages::VERTEX, + }); + let vertex_buffer_2d = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("face3d.vbuf.2d"), + contents: bytemuck::cast_slice(&verts_2d), usage: wgpu::BufferUsages::VERTEX, }); Self { - vertex_buffer, - vertex_count: vertices.len() as u32, + vertex_buffer_3d, + vertex_count_3d: verts_3d.len() as u32, + vertex_buffer_2d, + vertex_count_2d: verts_2d.len() as u32, } } } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 4afefa4d1..3cca07583 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -43,7 +43,14 @@ pub struct Pipeline { /// Wireframe 2D or Wireframe 3D so 3D solids draw as their /// triangle edges instead of filled faces. mesh_wireframe_pipeline: wgpu::RenderPipeline, + /// Depth-only variant of the mesh pipeline (TriangleList, no color + /// writes, writes depth). Used in HiddenLine mode so 3D solids + /// occlude wires behind them without painting visible pixels. + mesh_depth_pipeline: wgpu::RenderPipeline, face3d_pipeline: wgpu::RenderPipeline, + /// Depth-only variant of the face3d pipeline (no color writes, + /// writes depth). Paired with `mesh_depth_pipeline` for HiddenLine. + face3d_depth_pipeline: wgpu::RenderPipeline, uniform_buffer: wgpu::Buffer, uniform_bind_group: wgpu::BindGroup, hatch_bgl1: wgpu::BindGroupLayout, @@ -471,6 +478,50 @@ impl Pipeline { cache: None, }); + // Depth-only variant — TriangleList, back-face culling stays on + // (we only want front-facing fragments to write depth so wires + // on the far side of the mesh stay hidden), `write_mask` zero + // so no fragment ever reaches the colour buffer. + let mesh_depth_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh.depth.pipeline"), + layout: Some(&mesh_layout), + vertex: wgpu::VertexState { + module: &mesh_shader, + entry_point: Some("vs_main"), + buffers: &[mesh_gpu::MeshVertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: MSAA_SAMPLES, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &mesh_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::empty(), + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + // ── Face3D pipeline ──────────────────────────────────────────────── let face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("face3d.shader"), @@ -525,6 +576,49 @@ impl Pipeline { cache: None, }); + // Depth-only variant — write_mask zero, no blend. The face3d + // shader still runs but its colour output is discarded, so we + // get a pure depth prepass for HiddenLine. + let face3d_depth_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("face3d.depth.pipeline"), + layout: Some(&face3d_layout), + vertex: wgpu::VertexState { + module: &face3d_shader, + entry_point: Some("vs_main"), + buffers: &[face3d_gpu::Face3DVertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: MSAA_SAMPLES, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &face3d_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::empty(), + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + // ── Image pipeline ───────────────────────────────────────────────── let image_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("image.bgl1"), @@ -717,7 +811,9 @@ impl Pipeline { image_pipeline, mesh_pipeline, mesh_wireframe_pipeline, + mesh_depth_pipeline, face3d_pipeline, + face3d_depth_pipeline, uniform_buffer, uniform_bind_group, hatch_bgl1, @@ -840,30 +936,25 @@ impl Pipeline { // Edge buffer is always built from `face3d_wires`, so 3DFACE // outlines stay on the screen regardless of mode. self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires); - // Fill buffer: in wireframe mode we drop the 3DFACE quads - // (those come from `face3d_wires.key_vertices`) *and* the - // PolyfaceMesh / PolygonMesh face triangles routed through - // `all_wires.fill_tris` (Face3DGpu::from_wires recognises them - // by the wire having both `points` and `fill_tris`). 2-D - // fill_tris (text-LOD greek, MultiLeader background — `points` - // empty) survive in either mode. + // Fill buffer split: 3D quads + PolyfaceMesh / PolygonMesh face + // tris go to `vertex_buffer_3d` (gated by `keep_3d_mesh_fills`); + // 2D fills (text-LOD greek, MultiLeader background) go to + // `vertex_buffer_2d` and are visible in every mode. let keep_3d_mesh_fills = !wireframe_only; - let face3d_for_fill: &[WireModel] = if wireframe_only { &[] } else { face3d_wires }; let has_any_2d_fill = all_wires .iter() .any(|w| !w.fill_tris.is_empty() && w.points.is_empty()); - let has_any_3d_fill = all_wires - .iter() - .any(|w| !w.fill_tris.is_empty() && !w.points.is_empty()); - let has_fills = !face3d_for_fill.is_empty() - || has_any_2d_fill - || (keep_3d_mesh_fills && has_any_3d_fill); + let has_any_3d_fill = !face3d_wires.is_empty() + || all_wires + .iter() + .any(|w| !w.fill_tris.is_empty() && !w.points.is_empty()); + let has_fills = has_any_2d_fill || (keep_3d_mesh_fills && has_any_3d_fill); if !has_fills { self.gpu_face3d_fill = None; } else { self.gpu_face3d_fill = Some(Face3DGpu::from_wires( device, - face3d_for_fill, + face3d_wires, all_wires, keep_3d_mesh_fills, )); @@ -936,6 +1027,7 @@ impl Pipeline { clip_bounds: Rectangle, bg_color: [f32; 4], mesh_wireframe: bool, + hidden_line: bool, ) { let vp = clip_bounds; let msaa = &self.msaa_view; @@ -1063,47 +1155,102 @@ impl Pipeline { occlusion_query_set: None, }); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); - // Wireframe mode swaps in the LineList pipeline + the - // per-mesh `wire_index_buffer` (triangle indices expanded - // into edge segments at upload time); everything else stays - // identical so the same uniforms / vertex buffers feed both - // paths. - if mesh_wireframe { - pass.set_pipeline(&self.mesh_wireframe_pipeline); - } else { - pass.set_pipeline(&self.mesh_pipeline); - } pass.set_bind_group(0, &self.uniform_bind_group, &[]); - for (i, set) in self.gpu_meshes.iter().enumerate() { - if !self.mesh_visible.get(i).copied().unwrap_or(true) { - continue; + // Three draw modes share this pass: + // - Solid: `mesh_pipeline` + the triangle index buffer. + // - Wireframe: `mesh_wireframe_pipeline` + the pre-built + // `wire_index_buffer` (each triangle edge expanded to a + // line segment at upload). + // - HiddenLine: depth prepass with `mesh_depth_pipeline` + // (writes Z, no colour), then wire-edge draw on top. + // The wire draw's LessEqual depth test naturally hides + // edges occluded by the prepass. + if hidden_line { + pass.set_pipeline(&self.mesh_depth_pipeline); + for (i, set) in self.gpu_meshes.iter().enumerate() { + if !self.mesh_visible.get(i).copied().unwrap_or(true) { + continue; + } + let level = self + .mesh_lod_levels + .get(i) + .copied() + .unwrap_or(0) + .min(set.lods.len().saturating_sub(1)); + let Some(mesh) = set.lods.get(level) else { + continue; + }; + if mesh.index_count == 0 { + continue; + } + pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + pass.set_index_buffer(mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..mesh.index_count, 0, 0..1); } - let level = self - .mesh_lod_levels - .get(i) - .copied() - .unwrap_or(0) - .min(set.lods.len().saturating_sub(1)); - let Some(mesh) = set.lods.get(level) else { - continue; - }; - let (ibuf, icount) = if mesh_wireframe { - (&mesh.wire_index_buffer, mesh.wire_index_count) + pass.set_pipeline(&self.mesh_wireframe_pipeline); + for (i, set) in self.gpu_meshes.iter().enumerate() { + if !self.mesh_visible.get(i).copied().unwrap_or(true) { + continue; + } + let level = self + .mesh_lod_levels + .get(i) + .copied() + .unwrap_or(0) + .min(set.lods.len().saturating_sub(1)); + let Some(mesh) = set.lods.get(level) else { + continue; + }; + if mesh.wire_index_count == 0 { + continue; + } + pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + pass.set_index_buffer( + mesh.wire_index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(0..mesh.wire_index_count, 0, 0..1); + } + } else { + if mesh_wireframe { + pass.set_pipeline(&self.mesh_wireframe_pipeline); } else { - (&mesh.index_buffer, mesh.index_count) - }; - if icount == 0 { - continue; + pass.set_pipeline(&self.mesh_pipeline); + } + for (i, set) in self.gpu_meshes.iter().enumerate() { + if !self.mesh_visible.get(i).copied().unwrap_or(true) { + continue; + } + let level = self + .mesh_lod_levels + .get(i) + .copied() + .unwrap_or(0) + .min(set.lods.len().saturating_sub(1)); + let Some(mesh) = set.lods.get(level) else { + continue; + }; + let (ibuf, icount) = if mesh_wireframe { + (&mesh.wire_index_buffer, mesh.wire_index_count) + } else { + (&mesh.index_buffer, mesh.index_count) + }; + if icount == 0 { + continue; + } + pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); + pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..icount, 0, 0..1); } - pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..)); - pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..icount, 0, 0..1); } } - // ── Pass 5a: 3DFACE fills ───────────────────────────────────────── + // ── Pass 5a: 3DFACE fills (3D + 2D split) ───────────────────────── + // 3D quads + PolyfaceMesh face tris go through the depth-only + // pipeline in HiddenLine so wires hidden behind them disappear. + // 2D fills (text greek, MultiLeader bg) always draw with colour. if let Some(ref fill) = self.gpu_face3d_fill { - if fill.vertex_count > 0 { + if fill.vertex_count_3d > 0 || fill.vertex_count_2d > 0 { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("face3d.render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -1127,10 +1274,21 @@ impl Pipeline { occlusion_query_set: None, }); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); - pass.set_pipeline(&self.face3d_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); - pass.set_vertex_buffer(0, fill.vertex_buffer.slice(..)); - pass.draw(0..fill.vertex_count, 0..1); + if fill.vertex_count_3d > 0 { + if hidden_line { + pass.set_pipeline(&self.face3d_depth_pipeline); + } else { + pass.set_pipeline(&self.face3d_pipeline); + } + pass.set_vertex_buffer(0, fill.vertex_buffer_3d.slice(..)); + pass.draw(0..fill.vertex_count_3d, 0..1); + } + if fill.vertex_count_2d > 0 { + pass.set_pipeline(&self.face3d_pipeline); + pass.set_vertex_buffer(0, fill.vertex_buffer_2d.slice(..)); + pass.draw(0..fill.vertex_count_2d, 0..1); + } } } diff --git a/src/scene/render.rs b/src/scene/render.rs index d2c2290f4..d410e070e 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -114,6 +114,10 @@ pub struct Primitive { /// `*WithEdges` variants and the pure wireframes leave it on. #[allow(dead_code)] pub(super) show_3d_edges: bool, + /// HiddenLine routes 3D fills through a depth-only prepass so edges + /// occluded by closer geometry are culled by the LessEqual depth + /// test on the wire passes that follow. + pub(super) hidden_line: bool, pub(super) geometry_epoch: u64, /// Camera generation captured when this Primitive was assembled. Paired /// with `geometry_epoch` so the wire buffers re-upload when the view @@ -124,15 +128,16 @@ pub struct Primitive { /// Flags the render pipeline consumes, derived from /// [`acadrust::entities::ViewportRenderMode`]. Each shaded variant fills /// 3D faces and meshes; the pure wireframes drop the fill and keep only -/// edges. `*WithEdges` variants render both. `HiddenLine` is currently -/// rendered as plain solid (until Phase 2 wires up the depth-only -/// prepass); `FlatShaded` vs `GouraudShaded` differ in shader uniform -/// only and produce identical fill flags here. +/// edges. `*WithEdges` variants render both. HiddenLine uses a depth +/// prepass: face/mesh fills are uploaded but routed through depth-only +/// pipelines so hidden edges drop out. `FlatShaded` vs `GouraudShaded` +/// differ in shader uniform only and produce identical fill flags here. #[derive(Clone, Copy, Debug)] pub struct RenderModeFlags { pub face3d_fill: bool, pub mesh_fill: bool, pub show_3d_edges: bool, + pub hidden_line: bool, } pub fn render_mode_flags( @@ -144,21 +149,25 @@ pub fn render_mode_flags( face3d_fill: false, mesh_fill: false, show_3d_edges: true, + hidden_line: false, }, M::HiddenLine => RenderModeFlags { face3d_fill: true, mesh_fill: true, show_3d_edges: true, + hidden_line: true, }, M::FlatShaded | M::GouraudShaded => RenderModeFlags { face3d_fill: true, mesh_fill: true, show_3d_edges: false, + hidden_line: false, }, M::FlatShadedWithEdges | M::GouraudShadedWithEdges => RenderModeFlags { face3d_fill: true, mesh_fill: true, show_3d_edges: true, + hidden_line: false, }, } } @@ -251,7 +260,14 @@ impl shader::Primitive for Primitive { // the draw path so meshes use the wireframe pipeline + the // pre-built triangle-edge index buffer. let mesh_wireframe = !self.mesh_fill; - pipeline.render(encoder, target, *clip, self.bg_color, mesh_wireframe); + pipeline.render( + encoder, + target, + *clip, + self.bg_color, + mesh_wireframe, + self.hidden_line, + ); if self.show_viewcube { pipeline.viewcube.render(encoder, target, *clip); } @@ -455,6 +471,7 @@ impl Scene { view_wireframe, mesh_fill: flags.mesh_fill, show_3d_edges: flags.show_3d_edges, + hidden_line: flags.hidden_line, geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, } @@ -505,6 +522,7 @@ impl Scene { view_wireframe, mesh_fill: flags.mesh_fill, show_3d_edges: flags.show_3d_edges, + hidden_line: flags.hidden_line, geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, }