From e6a961be6c407078137f6036d8bfa3acf46625c6 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 4 Jul 2026 18:28:27 +0300 Subject: [PATCH] feat(render): draw 3D mesh outline edges black in filled modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the solid feature-edge behaviour to mesh entities (PolyfaceMesh / PolygonMesh): their triangulation outline edges now render black when a filled-with-edges mode is active, and keep the entity colour in the lines-only modes (wireframe / hidden-line). A mesh entity emits its face fill and its outline edges as separate WireModels sharing the entity handle: the fill carries fill_tris plus a non-empty fill_tris_low (real 3D depth), the edge carries points. The old is_3d_mesh_edge flag keyed on `!fill_tris.is_empty()`, which tagged the points-less fill wire rather than the edge wire, so the edges were never recognised. upload_wires now collects the handles of real 3D fill wires and flags any points-wire sharing one as the mesh edge (text greek, whose fill has an empty fill_tris_low, is excluded — matching the face3d test). The wire draw loop then routes flagged edges through a new black wire pipeline (fs_black) in filled-with-edges modes. fs_black lives in wire_indexed.wgsl (the native shader) and wire.wgsl (the wasm shader). Co-Authored-By: Claude Opus 4.8 --- src/scene/pipeline/mod.rs | 78 ++++++++++++++++++++++++++++++++++- src/shaders/wire.wgsl | 15 +++++++ src/shaders/wire_indexed.wgsl | 15 +++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 14054d19b..8f878824c 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -29,6 +29,9 @@ const MSAA_SAMPLES: u32 = 4; pub struct Pipeline { wire_pipeline: wgpu::RenderPipeline, + /// Black-fragment variant of `wire_pipeline` for 3D mesh outline edges in + /// filled render modes. + wire_black_pipeline: wgpu::RenderPipeline, /// Same shader as wire_pipeline but depth_compare=Greater, depth_write_enabled=false. /// Used to draw ghost copies of selected wires through occluding geometry. wire_xray_pipeline: wgpu::RenderPipeline, @@ -283,6 +286,48 @@ impl Pipeline { cache: None, }); + // Black variant of `wire_pipeline` — same geometry/depth, black fragment + // — for 3D mesh outline edges in filled modes (see the wire draw loop). + let wire_black_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("wire.black.pipeline"), + layout: Some(&wire_layout), + vertex: wgpu::VertexState { + module: &wire_shader, + entry_point: Some("vs_main"), + buffers: &[wire_gpu::WireInstance::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: &wire_shader, + entry_point: Some("fs_black"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + // Selection overlay variant: renders selected wires on top of everything // (depth_compare=Always), without writing depth. Ensures selected entities // are always fully visible regardless of occluding geometry. @@ -1053,6 +1098,7 @@ impl Pipeline { Self { wire_pipeline, + wire_black_pipeline, wire_xray_pipeline, #[cfg(not(target_arch = "wasm32"))] wire_const_bgl: Some(wire_const_bgl), @@ -1128,15 +1174,28 @@ impl Pipeline { // and alpha blending both depend on it. Scissor and mesh-edge stay // grouping keys because the draw loop sets one scissor per batch and // skips whole mesh-edge batches in shaded modes. + // A 3D mesh entity (PolyfaceMesh / PolygonMesh) emits its face fill and + // its outline edges as *separate* WireModels sharing the entity handle + // (`name`): the fill carries `fill_tris` + a non-empty `fill_tris_low` + // (real 3D depth, same test face3d uses); the edge carries `points`. + // Flag the edge wire as `is_3d_mesh_edge` so the draw loop can hide it in + // clean-shaded modes and draw it black in filled-with-edges modes. + let mesh_names: rustc_hash::FxHashSet<&str> = wires + .iter() + .filter(|w| !w.fill_tris.is_empty() && !w.fill_tris_low.is_empty()) + .map(|w| w.name.as_str()) + .collect(); + let is_mesh_edge = + |w: &WireModel| !w.points.is_empty() && mesh_names.contains(w.name.as_str()); let mut batches: Vec = Vec::new(); let mut i = 0; while i < wires.len() { let scissor = wires[i].vp_scissor; - let mesh_edge = !wires[i].fill_tris.is_empty(); + let mesh_edge = is_mesh_edge(&wires[i]); let mut j = i + 1; while j < wires.len() && wires[j].vp_scissor == scissor - && (!wires[j].fill_tris.is_empty()) == mesh_edge + && is_mesh_edge(&wires[j]) == mesh_edge { j += 1; } @@ -1861,7 +1920,13 @@ impl Pipeline { pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); pass.set_pipeline(&self.wire_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); + // In a filled-with-edges mode the mesh outline edges frame the shaded + // fill and should read black; wireframe / hidden-line keep the entity + // colour. `wire_black_pipeline` shares the wire layout, so the switch + // needs no bind-group rebind. + let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges; let mut scissor_active = false; + let mut black_active = false; for (i, wire) in self.gpu_wires.iter().enumerate() { if wire.instance_count == 0 { continue; @@ -1875,6 +1940,15 @@ impl Pipeline { if !show_3d_edges && wire.is_3d_mesh_edge { continue; } + let use_black = want_solid_with_edges && wire.is_3d_mesh_edge; + if use_black != black_active { + pass.set_pipeline(if use_black { + &self.wire_black_pipeline + } else { + &self.wire_pipeline + }); + black_active = use_black; + } match self.wire_pixel_scissors.get(i) { Some(Some([x, y, w, h])) => { pass.set_scissor_rect(*x, *y, *w, *h); diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index 9e95e54e3..81fcf2d30 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -206,3 +206,18 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); return vec4(in.color.rgb, alpha); } + +// Black variant: used for 3D mesh outline edges in filled render modes so the +// mesh reads as a shaded surface framed by black edges. Keeps the dash/LOD +// logic identical to `fs_main`; only the RGB is forced to black. +@fragment fn fs_black(in: VertexOut) -> @location(0) vec4 { + if in.pattern_length > 0.0 { + if in.min_elem >= u.world_per_pixel { + if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1) { + discard; + } + } + } + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); + return vec4(0.0, 0.0, 0.0, alpha); +} diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 2e47d84ab..c702ffeec 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -149,3 +149,18 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); return vec4(in.color.rgb, alpha); } + +// Black variant: used for 3D mesh outline edges in filled render modes so the +// mesh reads as a shaded surface framed by black edges. Keeps the dash/LOD +// logic identical to `fs_main`; only the RGB is forced to black. +@fragment fn fs_black(in: VertexOut) -> @location(0) vec4 { + if in.pattern_length > 0.0 { + if in.min_elem >= u.world_per_pixel { + if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1) { + discard; + } + } + } + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); + return vec4(0.0, 0.0, 0.0, alpha); +}