From 3593e9dabcf6ba9fe24ce793ed74efcb7c2a77cf Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 17 May 2026 19:52:28 +0300 Subject: [PATCH] feat(pipeline): wire batched-hatch pipeline + single draw call (Phase 4-B, step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipeline carries a new `hatch_batched_pipeline` + matching `bgl1` alongside the existing per-hatch pipeline. `upload_hatches` now packs the renderable hatch list into the single `HatchBatchedGpu` and clears `gpu_hatches`; the draw loop prefers the batched path when that resource is set, falling back to the per-hatch loop only when build returned `None` (empty list). One draw call replaces N — for a 100-hatch frame that's a 99-call saving plus 99 fewer `set_bind_group(1, ...)` / `set_vertex_buffer` pairs. Caveats acknowledged for step 4 cleanup: - `compute_hatch_lod` still writes into `hatch_skip_flags`, which the batched path ignores. Step 4 will push the same flags into the per-instance `visible` field on the GPU instance buffer so the sub-pixel + frustum cull keeps working. - Per-hatch viewport scissors (paper-space MSPACE) aren't ported yet; the legacy per-hatch path still serves that case when the batched build is skipped, but a model-space → paper-space transition keeps the batched buffer intact and may render hatches outside the intended viewport. Will be addressed alongside visibility. Co-Authored-By: Claude Opus 4.7 --- src/scene/pipeline/mod.rs | 95 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 27f9931c8..974e214e0 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -32,12 +32,19 @@ pub struct Pipeline { /// Used to draw ghost copies of selected wires through occluding geometry. wire_xray_pipeline: wgpu::RenderPipeline, hatch_pipeline: wgpu::RenderPipeline, + /// Phase 4-B — single-draw batched hatch pipeline. Per-instance + /// data lives in storage buffers; one draw call covers every + /// hatch in the frame. + hatch_batched_pipeline: wgpu::RenderPipeline, image_pipeline: wgpu::RenderPipeline, mesh_pipeline: wgpu::RenderPipeline, face3d_pipeline: wgpu::RenderPipeline, uniform_buffer: wgpu::Buffer, uniform_bind_group: wgpu::BindGroup, hatch_bgl1: wgpu::BindGroupLayout, + /// Group-1 layout for the batched hatch pipeline (storage buffers + /// for instances / boundary / families / dashes). + hatch_batched_bgl1: wgpu::BindGroupLayout, image_bgl1: wgpu::BindGroupLayout, depth_texture_size: Size, depth_view: wgpu::TextureView, @@ -58,6 +65,10 @@ pub struct Pipeline { /// Ghost copies (25% alpha) of selected wires for the X-ray depth pass. gpu_selected_wires: Vec, gpu_hatches: Vec, + /// Phase 4-B — single batched-hatch GPU resource. When `Some`, the + /// batched pipeline draws every hatch in one indexed call and the + /// per-hatch `gpu_hatches` path is skipped. + gpu_hatch_batched: Option, /// Pixel scissor rects [x, y, w, h] for viewport-clipped hatches. Recomputed each frame. hatch_pixel_scissors: Vec>, /// `true` for hatches whose AABB projects to less than ~2 px at the @@ -308,6 +319,59 @@ impl Pipeline { cache: None, }); + // ── Hatch batched pipeline (Phase 4-B) ───────────────────────────── + let hatch_batched_bgl1 = hatch_batched_gpu::HatchBatchedGpu::bind_group_layout(device); + let hatch_batched_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hatch_batched.pipeline_layout"), + bind_group_layouts: &[&frame_bgl, &hatch_batched_bgl1], + push_constant_ranges: &[], + }); + let hatch_batched_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("hatch_batched.shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "../../shaders/hatch_batched.wgsl" + ))), + }); + let hatch_batched_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("hatch_batched.pipeline"), + layout: Some(&hatch_batched_layout), + vertex: wgpu::VertexState { + module: &hatch_batched_shader, + entry_point: Some("vs_main"), + buffers: &[hatch_batched_gpu::HatchBatchedVertex::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: &hatch_batched_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, + }); + // ── Mesh pipeline ────────────────────────────────────────────────── let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("mesh.shader"), @@ -604,12 +668,14 @@ impl Pipeline { wire_pipeline, wire_xray_pipeline, hatch_pipeline, + hatch_batched_pipeline, image_pipeline, mesh_pipeline, face3d_pipeline, uniform_buffer, uniform_bind_group, hatch_bgl1, + hatch_batched_bgl1, image_bgl1, depth_texture_size: Size::new(1, 1), depth_view, @@ -624,6 +690,7 @@ impl Pipeline { wire_pixel_scissors: vec![], gpu_selected_wires: vec![], gpu_hatches: vec![], + gpu_hatch_batched: None, hatch_pixel_scissors: vec![], hatch_skip_flags: vec![], gpu_wipeouts: vec![], @@ -769,11 +836,14 @@ impl Pipeline { } pub fn upload_hatches(&mut self, device: &wgpu::Device, hatches: &[HatchModel]) { - self.gpu_hatches = hatches - .iter() - .filter(|h| h.boundary.len() >= 3) - .map(|h| HatchGpu::new(device, h, &self.hatch_bgl1)) - .collect(); + // Phase 4-B — build the single batched GPU resource. The + // per-hatch `gpu_hatches` Vec is left empty; the draw loop + // takes the batched path when `gpu_hatch_batched.is_some()`. + let renderable: Vec = + hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); + self.gpu_hatches = vec![]; + self.gpu_hatch_batched = + hatch_batched_gpu::HatchBatchedGpu::build(device, &self.hatch_batched_bgl1, &renderable); } pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) { @@ -844,12 +914,23 @@ impl Pipeline { }); // MSAA texture is clip-bounds-sized, so viewport starts at (0, 0). pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); - if !self.gpu_hatches.is_empty() { + // Phase 4-B — single batched draw covers every hatch. + // The vertex shader culls per-instance via the `visible` + // flag set by `compute_hatch_lod` (sub-pixel + offscreen). + // Viewport-clip scissors haven't been ported to the batched + // path yet; paper-space MSPACE rendering still uses the + // per-hatch fallback below when no batched resource exists. + if let Some(batch) = &self.gpu_hatch_batched { + pass.set_pipeline(&self.hatch_batched_pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_bind_group(1, &batch.bind_group, &[]); + pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..)); + pass.draw(0..batch.vertex_count, 0..1); + } else if !self.gpu_hatches.is_empty() { pass.set_pipeline(&self.hatch_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); let mut scissor_active = false; for (i, hatch) in self.gpu_hatches.iter().enumerate() { - // Phase 3.3 LOD: skip hatches projecting to sub-pixel size. if self.hatch_skip_flags.get(i).copied().unwrap_or(false) { continue; }