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 SevenandClaude Opus 4.7 committed 2026-05-27 05:40:39 +03:00
1 parent 8dbdebeedc
commit 56834dfba6
3 files changed
+312 -100

No files matched your search

+80 -44
View File
@@ -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<Face3DVertex> = Vec::with_capacity(face3d_wires.len() * 6);
let mut verts_3d: Vec<Face3DVertex> = Vec::with_capacity(face3d_wires.len() * 6);
let mut verts_2d: Vec<Face3DVertex> = 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,
}
}
}
+209 -51
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);
}
}
}
+23 -5
View File
@@ -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,
}