feat(render): HiddenLine mode via depth-only prepass

3D yüzeyleri (Solid3D mesh, 3DFACE quad, PolyfaceMesh/PolygonMesh face
tris) HiddenLine modunda renk yazmadan z-buffer'a yazıyor; ardından
kenarlar wireframe pipeline ile çizilince LessEqual depth testi
arkadaki segmentleri eliyor. 2D fill'ler (text greek, MultiLeader
arka planı) ayrı bir vertex buffer'a ayrıldı ve her modda renkli
çizilmeye devam ediyor.

- Face3DGpu: vertex_buffer artık 3D / 2D olarak ikiye ayrıldı
- mesh_depth_pipeline + face3d_depth_pipeline (write_mask=empty)
- RenderModeFlags.hidden_line + Primitive.hidden_line plumbing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-27 05:40:39 +03:00
commit 56834dfba6
3 changed files with 312 additions and 100 deletions

View file

@ -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<u32>,
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);
}
}
}