refactor(pipeline): drop legacy per-hatch path (Phase 4-B, step 5)

The batched hatch pipeline has soaked through visibility + LOD; it's
correct on every file tested (Floor plan.dxf, UTM site plans, the
non-UTM regression case). Time to remove the parallel per-hatch
plumbing that's been sitting empty since step 3:

- `gpu_hatches: Vec<HatchGpu>` — never populated since
  `upload_hatches` switched to the batched builder; dropped.
- `hatch_pixel_scissors`, `hatch_skip_flags` — same story; dropped.
- `compute_hatch_scissors` — no longer has a consumer; dropped, with
  the render-time call removed from `Primitive::prepare`.
- Hatch render pass — the dead `else if` branch over `gpu_hatches`
  is gone; only the single batched `pass.draw(0..vertex_count, 0..1)`
  remains.
- `compute_hatch_lod` — no fallback path; if `gpu_hatch_batched` is
  `None` (empty hatch list) it just returns early.

`HatchGpu` itself stays — the wipeout pipeline still uses it. So do
`hatch_pipeline` / `hatch_bgl1`, kept around for that path.

`#[allow(dead_code)]` on the four storage-buffer fields in
`HatchBatchedGpu` — they exist only to keep the bind group's
referenced resources alive; nothing reads them directly.

Known gap left as a follow-up: per-hatch viewport scissor for paper-
space MSPACE viewports isn't ported to the batched path yet. Hatches
inside a paper-space viewport can render past the viewport border
until that's added. Hasn't surfaced as a visible regression in the
test files so far.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-17 20:18:06 +03:00
commit 798a391207
3 changed files with 32 additions and 95 deletions

View file

@ -132,10 +132,14 @@ impl HatchBatchedVertex {
pub struct HatchBatchedGpu { pub struct HatchBatchedGpu {
pub vertex_buffer: wgpu::Buffer, pub vertex_buffer: wgpu::Buffer,
pub vertex_count: u32, pub vertex_count: u32,
pub instance_buffer: wgpu::Buffer, // The four storage buffers below are referenced via `bind_group` —
pub boundary_buffer: wgpu::Buffer, // dropping them would invalidate it, but the bind group is the
pub family_buffer: wgpu::Buffer, // only direct consumer. Keep them as fields to keep ownership in
pub dash_buffer: wgpu::Buffer, // one place; `#[allow(dead_code)]` silences the read-never warning.
#[allow(dead_code)] pub instance_buffer: wgpu::Buffer,
#[allow(dead_code)] pub boundary_buffer: wgpu::Buffer,
#[allow(dead_code)] pub family_buffer: wgpu::Buffer,
#[allow(dead_code)] pub dash_buffer: wgpu::Buffer,
/// Per-instance visibility flag (1=draw, 0=skip). Stored in its /// Per-instance visibility flag (1=draw, 0=skip). Stored in its
/// own small storage buffer so per-frame updates don't have to /// own small storage buffer so per-frame updates don't have to
/// touch the large `instance_buffer`. Vertex shader reads /// touch the large `instance_buffer`. Vertex shader reads
@ -144,7 +148,7 @@ pub struct HatchBatchedGpu {
/// fragment stage runs. /// fragment stage runs.
pub visibility_buffer: wgpu::Buffer, pub visibility_buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup, pub bind_group: wgpu::BindGroup,
pub instance_count: u32, #[allow(dead_code)] pub instance_count: u32,
/// CPU mirror — `update_visibility` re-uploads this whole slice /// CPU mirror — `update_visibility` re-uploads this whole slice
/// when any flag changes. ~4 B per hatch, so 40 KB / 10 k hatches /// when any flag changes. ~4 B per hatch, so 40 KB / 10 k hatches
/// per pan tick. Far cheaper than touching the 112 B-per-instance /// per pan tick. Far cheaper than touching the 112 B-per-instance

View file

@ -64,17 +64,12 @@ pub struct Pipeline {
wire_pixel_scissors: Vec<Option<[u32; 4]>>, wire_pixel_scissors: Vec<Option<[u32; 4]>>,
/// Ghost copies (25% alpha) of selected wires for the X-ray depth pass. /// Ghost copies (25% alpha) of selected wires for the X-ray depth pass.
gpu_selected_wires: Vec<WireGpu>, gpu_selected_wires: Vec<WireGpu>,
gpu_hatches: Vec<HatchGpu>, /// Phase 4-B — single batched-hatch GPU resource. Drawn in one
/// Phase 4-B — single batched-hatch GPU resource. When `Some`, the /// indexed call with per-instance visibility masking the rest.
/// batched pipeline draws every hatch in one indexed call and the /// Legacy per-hatch `Vec<HatchGpu>` + scissor / skip-flag plumbing
/// per-hatch `gpu_hatches` path is skipped. /// removed in step 5; the `hatch_pipeline` itself stays around to
/// serve the wipeout path which still uses `HatchGpu`.
gpu_hatch_batched: Option<hatch_batched_gpu::HatchBatchedGpu>, gpu_hatch_batched: Option<hatch_batched_gpu::HatchBatchedGpu>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped hatches. Recomputed each frame.
hatch_pixel_scissors: Vec<Option<[u32; 4]>>,
/// `true` for hatches whose AABB projects to less than ~2 px at the
/// current zoom. Render pass skips the draw call. Recomputed each
/// frame alongside the scissor pass (Phase 3.3 hatch LOD).
hatch_skip_flags: Vec<bool>,
/// Wipeout fills — rendered after wires in a separate pass. /// Wipeout fills — rendered after wires in a separate pass.
gpu_wipeouts: Vec<HatchGpu>, gpu_wipeouts: Vec<HatchGpu>,
/// Per-wipeout draw-time skip flag (Phase 2.3 frustum cull). `true` /// Per-wipeout draw-time skip flag (Phase 2.3 frustum cull). `true`
@ -689,10 +684,7 @@ impl Pipeline {
gpu_wires: vec![], gpu_wires: vec![],
wire_pixel_scissors: vec![], wire_pixel_scissors: vec![],
gpu_selected_wires: vec![], gpu_selected_wires: vec![],
gpu_hatches: vec![],
gpu_hatch_batched: None, gpu_hatch_batched: None,
hatch_pixel_scissors: vec![],
hatch_skip_flags: vec![],
gpu_wipeouts: vec![], gpu_wipeouts: vec![],
wipeout_skip_flags: vec![], wipeout_skip_flags: vec![],
wipeout_pixel_scissors: vec![], wipeout_pixel_scissors: vec![],
@ -730,21 +722,12 @@ impl Pipeline {
.collect(); .collect();
} }
/// Recompute pixel scissor rects for viewport-clipped hatches. /// Per-frame visibility refresh for the batched hatch path.
pub fn compute_hatch_scissors(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) { /// Combines Phase 3.3 sub-pixel LOD skip with Phase 2.3 frustum
self.hatch_pixel_scissors = self /// cull and pushes the resulting 0/1 mask through to the GPU
.gpu_hatches /// `visibility_buffer`. Vertex shader maps 0 → out-of-NDC clip,
.iter() /// so the rasterizer culls the primitive before any fragment
.map(|h| project_scissor(h.vp_scissor, view_proj, clip_w, clip_h)) /// runs.
.collect();
}
/// Recompute per-hatch LOD skip flags. A hatch whose entire AABB
/// projects to less than ~2 px is invisible at this zoom — skip the
/// draw call entirely instead of running the polygon test for every
/// fragment in the bounding quad. Pairs with the shader-side
/// solid-fill substitution for hatches between 2 px and the dense
/// pattern threshold.
pub fn compute_hatch_lod( pub fn compute_hatch_lod(
&mut self, &mut self,
queue: &wgpu::Queue, queue: &wgpu::Queue,
@ -752,34 +735,15 @@ impl Pipeline {
clip_w: u32, clip_w: u32,
clip_h: u32, clip_h: u32,
) { ) {
// Phase 4-B batched path — write the same skip rule (sub-pixel let Some(batch) = &mut self.gpu_hatch_batched else {
// LOD OR offscreen) into the GPU visibility buffer. The vertex
// shader maps 0 → out-of-NDC clip → primitive culled before
// fragment stage; equivalent to the per-hatch `skip_flags`
// gate but at GPU-side cost.
if let Some(batch) = &mut self.gpu_hatch_batched {
// Same skip rule as the legacy per-hatch path: sub-pixel
// LOD (`aabb_below_pixel`) OR offscreen (`aabb_offscreen`).
// Walks the per-instance AABB mirror — no GPU readback.
for (i, aabb) in batch.instance_aabbs.iter().enumerate() {
let skip = aabb_below_pixel(*aabb, view_proj, clip_w, clip_h, 2.0)
|| aabb_offscreen(*aabb, view_proj, clip_w, clip_h);
batch.visibility[i] = if skip { 0 } else { 1 };
}
batch.upload_visibility(queue);
return; return;
};
for (i, aabb) in batch.instance_aabbs.iter().enumerate() {
let skip = aabb_below_pixel(*aabb, view_proj, clip_w, clip_h, 2.0)
|| aabb_offscreen(*aabb, view_proj, clip_w, clip_h);
batch.visibility[i] = if skip { 0 } else { 1 };
} }
batch.upload_visibility(queue);
// Per-hatch (legacy) fallback path.
self.hatch_skip_flags = self
.gpu_hatches
.iter()
.map(|h| {
// Phase 3.3 sub-pixel LOD skip OR Phase 2.3 frustum skip.
aabb_below_pixel(h.world_aabb, view_proj, clip_w, clip_h, 2.0)
|| aabb_offscreen(h.world_aabb, view_proj, clip_w, clip_h)
})
.collect();
} }
/// Recompute pixel scissor rects for viewport-clipped wipeouts. /// Recompute pixel scissor rects for viewport-clipped wipeouts.
@ -861,12 +825,8 @@ impl Pipeline {
} }
pub fn upload_hatches(&mut self, device: &wgpu::Device, hatches: &[HatchModel]) { pub fn upload_hatches(&mut self, device: &wgpu::Device, hatches: &[HatchModel]) {
// 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<HatchModel> = let renderable: Vec<HatchModel> =
hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect();
self.gpu_hatches = vec![];
self.gpu_hatch_batched = self.gpu_hatch_batched =
hatch_batched_gpu::HatchBatchedGpu::build(device, &self.hatch_batched_bgl1, &renderable); hatch_batched_gpu::HatchBatchedGpu::build(device, &self.hatch_batched_bgl1, &renderable);
} }
@ -940,43 +900,17 @@ impl Pipeline {
// MSAA texture is clip-bounds-sized, so viewport starts at (0, 0). // 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); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
// Phase 4-B — single batched draw covers every hatch. // Phase 4-B — single batched draw covers every hatch.
// The vertex shader culls per-instance via the `visible` // Vertex shader culls per-instance via the `visibility`
// flag set by `compute_hatch_lod` (sub-pixel + offscreen). // buffer (sub-pixel LOD + frustum cull written each frame
// Viewport-clip scissors haven't been ported to the batched // by `compute_hatch_lod`). Per-hatch viewport scissor
// path yet; paper-space MSPACE rendering still uses the // (paper-space MSPACE) isn't ported to the batched path
// per-hatch fallback below when no batched resource exists. // yet — follow-up if it shows up as a visual issue.
if let Some(batch) = &self.gpu_hatch_batched { if let Some(batch) = &self.gpu_hatch_batched {
pass.set_pipeline(&self.hatch_batched_pipeline); pass.set_pipeline(&self.hatch_batched_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_bind_group(0, &self.uniform_bind_group, &[]);
pass.set_bind_group(1, &batch.bind_group, &[]); pass.set_bind_group(1, &batch.bind_group, &[]);
pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..)); pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
pass.draw(0..batch.vertex_count, 0..1); 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() {
if self.hatch_skip_flags.get(i).copied().unwrap_or(false) {
continue;
}
match self.hatch_pixel_scissors.get(i) {
Some(Some([x, y, w, h])) => {
pass.set_scissor_rect(*x, *y, *w, *h);
scissor_active = true;
}
_ if scissor_active => {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
scissor_active = false;
}
_ => {}
}
pass.set_bind_group(1, &hatch.bind_group, &[]);
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
if scissor_active {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
}
} }
} }

View file

@ -144,7 +144,6 @@ impl shader::Primitive for Primitive {
pipeline.cached_epoch = cur_key; pipeline.cached_epoch = cur_key;
} }
pipeline.compute_wire_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_wire_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_hatch_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_wipeout_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_wipeout_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_image_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_image_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height);
pipeline.compute_hatch_lod(queue, self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_hatch_lod(queue, self.uniforms.view_proj, clip_size.width, clip_size.height);