From a08cf477c190b46b730ee9480241b3c3a16e2a8b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 4 Jul 2026 16:27:59 +0300 Subject: [PATCH] fix(render): draw transparent solids without depth writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once solids carried a real sub-1 alpha (after the ENC transparency fix), the single depth-writing mesh pass made a transparent solid occlude the geometry behind it: it wrote depth at its near surface, so anything farther failed the LessEqual test and was never drawn — the transparent fill looked like it erased what was behind it. The batched mesh builder now routes each solid's triangles into an opaque or a transparent index stream by its baked colour alpha (< 0.999). The opaque fills draw first and write depth; the transparent fills draw last through a new depth-write-disabled pipeline, so they blend over the geometry behind them instead of culling it. The hidden-line depth prepass still rasterises both streams so solid surfaces keep occluding hidden edges. Co-Authored-By: Claude Opus 4.8 --- src/scene/pipeline/mesh_gpu.rs | 61 ++++++++++++----- src/scene/pipeline/mod.rs | 119 ++++++++++++++++++++++++++++----- 2 files changed, 145 insertions(+), 35 deletions(-) diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index cf21f22b6..f9fbf0d1f 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -112,8 +112,14 @@ impl Highlight { pub struct MeshBatchChunk { pub vertex_buffer: wgpu::Buffer, + /// Opaque triangle indices (mesh colour alpha ≈ 1). Drawn with depth write. pub index_buffer: wgpu::Buffer, pub index_count: u32, + /// Transparent triangle indices (mesh colour alpha < 1). Drawn after the + /// opaque fills with depth write disabled so they blend over — rather than + /// erase — the geometry behind them. + pub transp_index_buffer: wgpu::Buffer, + pub transp_index_count: u32, pub wire_index_buffer: wgpu::Buffer, pub wire_index_count: u32, } @@ -122,28 +128,34 @@ fn make_chunk( device: &wgpu::Device, verts: &[MeshVertex], indices: &[u32], + transp_indices: &[u32], wire_indices: &[u32], ) -> MeshBatchChunk { + // `create_buffer_init` with an empty slice yields a zero-sized buffer that + // some backends reject for INDEX usage; a chunk can legitimately hold only + // opaque or only transparent tris, so fall back to a 1-index stub (count + // stays 0, so the draw loop skips it). + let mk_index = |data: &[u32], label: &'static str| { + let stub = [0u32]; + let src = if data.is_empty() { &stub[..] } else { data }; + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents: bytemuck::cast_slice(src), + usage: wgpu::BufferUsages::INDEX, + }) + }; let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mesh.batch.vbuf"), contents: bytemuck::cast_slice(verts), usage: wgpu::BufferUsages::VERTEX, }); - let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("mesh.batch.ibuf"), - contents: bytemuck::cast_slice(indices), - usage: wgpu::BufferUsages::INDEX, - }); - let wire_index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("mesh.batch.wire_ibuf"), - contents: bytemuck::cast_slice(wire_indices), - usage: wgpu::BufferUsages::INDEX, - }); MeshBatchChunk { vertex_buffer, - index_buffer, + index_buffer: mk_index(indices, "mesh.batch.ibuf"), index_count: indices.len() as u32, - wire_index_buffer, + transp_index_buffer: mk_index(transp_indices, "mesh.batch.transp_ibuf"), + transp_index_count: transp_indices.len() as u32, + wire_index_buffer: mk_index(wire_indices, "mesh.batch.wire_ibuf"), wire_index_count: wire_indices.len() as u32, } } @@ -168,6 +180,7 @@ pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec = Vec::new(); let mut indices: Vec = Vec::new(); + let mut transp_indices: Vec = Vec::new(); let mut wire_indices: Vec = Vec::new(); let mut total_tris = 0u64; for set in sets { @@ -181,6 +194,9 @@ pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec (Vec max_verts || mesh_tris > max_tris { if !verts.is_empty() { - chunks.push(make_chunk(device, &verts, &indices, &wire_indices)); + chunks.push(make_chunk(device, &verts, &indices, &transp_indices, &wire_indices)); verts.clear(); indices.clear(); + transp_indices.clear(); wire_indices.clear(); } let tris_per = (max_verts / 3).min(max_tris).max(1); @@ -207,7 +224,13 @@ pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec (Vec max_verts || wire_indices.len() / 6 + mesh_tris > max_tris) { - chunks.push(make_chunk(device, &verts, &indices, &wire_indices)); + chunks.push(make_chunk(device, &verts, &indices, &transp_indices, &wire_indices)); verts.clear(); indices.clear(); + transp_indices.clear(); wire_indices.clear(); } let base = verts.len() as u32; for i in 0..mesh.verts.len() { verts.push(vtx(i)); } + let fill = if is_transp { &mut transp_indices } else { &mut indices }; for &idx in &mesh.indices { - indices.push(base + idx); + fill.push(base + idx); } for tri in mesh.indices.chunks_exact(3) { let (a, b, c) = (base + tri[0], base + tri[1], base + tri[2]); wire_indices.extend_from_slice(&[a, b, b, c, c, a]); } } - if !indices.is_empty() { - chunks.push(make_chunk(device, &verts, &indices, &wire_indices)); + if !indices.is_empty() || !transp_indices.is_empty() { + chunks.push(make_chunk(device, &verts, &indices, &transp_indices, &wire_indices)); } (chunks, total_tris) } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 5f664147b..49c46dc3e 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -45,6 +45,8 @@ pub struct Pipeline { hatch_batched_pipeline: Option, image_pipeline: wgpu::RenderPipeline, mesh_pipeline: wgpu::RenderPipeline, + /// Depth-write-disabled variant of `mesh_pipeline` for non-opaque solids. + mesh_transparent_pipeline: wgpu::RenderPipeline, mesh_highlight_pipeline: wgpu::RenderPipeline, /// Wireframe variant of the mesh pipeline (LineList topology, same /// vertex layout / shader). Used when the active render mode is @@ -532,6 +534,55 @@ impl Pipeline { cache: None, }); + // Transparent variant — identical to `mesh_pipeline` but with depth + // writes disabled. Non-opaque solids are drawn after the opaque fills + // with this pipeline so they blend over the geometry behind them (which + // has already written depth) instead of erasing it. + let mesh_transparent_pipeline = + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh.transparent.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: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: false, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState { + constant: 1, + slope_scale: 1.0, + clamp: 0.0, + }, + }), + 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: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + // Highlight variant — same shader / vertex layout, but `depth_compare = // Always` and no depth write, so a hovered / selected solid's tint is // drawn on top of everything (it shows through occluding geometry) and @@ -1003,6 +1054,7 @@ impl Pipeline { hatch_batched_pipeline, image_pipeline, mesh_pipeline, + mesh_transparent_pipeline, mesh_highlight_pipeline, mesh_wireframe_pipeline, mesh_depth_pipeline, @@ -1568,14 +1620,25 @@ impl Pipeline { // per-solid LOD / frustum cull in the batched path (the batch is // resident in full); that is reintroduced separately if needed. if hidden_line { + // Depth-only prepass: every solid surface occludes hidden edges, + // so both the opaque and the transparent tris write depth here. pass.set_pipeline(&self.mesh_depth_pipeline); for c in &self.gpu_mesh_batch { - if c.index_count == 0 { - continue; - } pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); - pass.set_index_buffer(c.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..c.index_count, 0, 0..1); + if c.index_count != 0 { + pass.set_index_buffer( + c.index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(0..c.index_count, 0, 0..1); + } + if c.transp_index_count != 0 { + pass.set_index_buffer( + c.transp_index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(0..c.transp_index_count, 0, 0..1); + } } pass.set_pipeline(&self.mesh_wireframe_pipeline); for c in &self.gpu_mesh_batch { @@ -1592,21 +1655,43 @@ impl Pipeline { } else { if mesh_wireframe { pass.set_pipeline(&self.mesh_wireframe_pipeline); + for c in &self.gpu_mesh_batch { + if c.wire_index_count == 0 { + continue; + } + pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); + pass.set_index_buffer( + c.wire_index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(0..c.wire_index_count, 0, 0..1); + } } else { + // Opaque fills first (they write depth). pass.set_pipeline(&self.mesh_pipeline); - } - for c in &self.gpu_mesh_batch { - let (ibuf, icount) = if mesh_wireframe { - (&c.wire_index_buffer, c.wire_index_count) - } else { - (&c.index_buffer, c.index_count) - }; - if icount == 0 { - continue; + for c in &self.gpu_mesh_batch { + if c.index_count == 0 { + continue; + } + pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); + pass.set_index_buffer(c.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..c.index_count, 0, 0..1); + } + // Transparent fills last, with depth writes disabled, so they + // blend over the opaque geometry behind them instead of + // culling it via the depth buffer. + pass.set_pipeline(&self.mesh_transparent_pipeline); + for c in &self.gpu_mesh_batch { + if c.transp_index_count == 0 { + continue; + } + pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); + pass.set_index_buffer( + c.transp_index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + pass.draw_indexed(0..c.transp_index_count, 0, 0..1); } - pass.set_vertex_buffer(0, c.vertex_buffer.slice(..)); - pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..icount, 0, 0..1); } // Selection / hover highlight: tinted copies of the picked // solids, drawn last with the Always-depth highlight pipeline so