diff --git a/src/scene/pipeline/hatch_batched_gpu.rs b/src/scene/pipeline/hatch_batched_gpu.rs index 5899e2a57..028d1e365 100644 --- a/src/scene/pipeline/hatch_batched_gpu.rs +++ b/src/scene/pipeline/hatch_batched_gpu.rs @@ -136,14 +136,25 @@ pub struct HatchBatchedGpu { pub boundary_buffer: wgpu::Buffer, pub family_buffer: wgpu::Buffer, pub dash_buffer: wgpu::Buffer, + /// Per-instance visibility flag (1=draw, 0=skip). Stored in its + /// own small storage buffer so per-frame updates don't have to + /// touch the large `instance_buffer`. Vertex shader reads + /// `visibility[instance_index]` — when 0 it emits an out-of-NDC + /// clip position so the GPU clips the primitive before the + /// fragment stage runs. + pub visibility_buffer: wgpu::Buffer, pub bind_group: wgpu::BindGroup, pub instance_count: u32, - /// CPU-side mirror of `HatchInstance.visible` — `compute_hatch_lod` - /// writes 0/1 here, then `upload_visibility` pushes the changed - /// `visible` field through to the GPU `instance_buffer`. Storing - /// just the flag (not the whole HatchInstance) keeps the per-frame - /// upload small. + /// CPU mirror — `update_visibility` re-uploads this whole slice + /// 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 + /// data. pub visibility: Vec, + /// CPU-side mirror of each instance's local-space AABB (world- + /// offset-subtracted, world_origin already added back). Used by + /// `compute_hatch_lod` to evaluate the sub-pixel + frustum cull + /// without reading back from the GPU. + pub instance_aabbs: Vec<[f32; 4]>, } impl HatchBatchedGpu { @@ -350,6 +361,13 @@ impl HatchBatchedGpu { usage: wgpu::BufferUsages::STORAGE, }); + let visibility: Vec = instances.iter().map(|i| i.visible).collect(); + let visibility_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch_batched.visibility"), + contents: bytemuck::cast_slice(&visibility), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("hatch_batched.bg"), layout: bgl, @@ -370,10 +388,30 @@ impl HatchBatchedGpu { binding: 3, resource: dash_buffer.as_entire_binding(), }, + wgpu::BindGroupEntry { + binding: 4, + resource: visibility_buffer.as_entire_binding(), + }, ], }); - let visibility = instances.iter().map(|i| i.visible).collect(); + // CPU AABB mirror — local-space rect with world_origin added + // back, ready for `aabb_offscreen` / `aabb_below_pixel` to + // compare against the camera view_proj. + let instance_aabbs: Vec<[f32; 4]> = instances + .iter() + .map(|i| { + let ox = i.world_origin[0]; + let oy = i.world_origin[1]; + [ + i.aabb[0] + ox, + i.aabb[1] + oy, + i.aabb[2] + ox, + i.aabb[3] + oy, + ] + }) + .collect(); + Some(Self { vertex_buffer, vertex_count: verts.len() as u32, @@ -381,12 +419,24 @@ impl HatchBatchedGpu { boundary_buffer, family_buffer, dash_buffer, + visibility_buffer, bind_group, instance_count: instances.len() as u32, visibility, + instance_aabbs, }) } + /// Push the CPU `visibility` slice to GPU. Call when any + /// element changes (typically per-frame from compute_hatch_lod). + pub fn upload_visibility(&self, queue: &wgpu::Queue) { + queue.write_buffer( + &self.visibility_buffer, + 0, + bytemuck::cast_slice(&self.visibility), + ); + } + /// Group-1 bind group layout — shared by the pipeline so it can be /// constructed once at startup. All four bindings are read-only /// storage and visible to both VS (AABB+visibility lookup) and FS @@ -404,7 +454,7 @@ impl HatchBatchedGpu { }; device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("hatch_batched.bgl"), - entries: &[entry(0), entry(1), entry(2), entry(3)], + entries: &[entry(0), entry(1), entry(2), entry(3), entry(4)], }) } } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 974e214e0..cc8b761de 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -745,7 +745,32 @@ impl Pipeline { /// 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(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) { + pub fn compute_hatch_lod( + &mut self, + queue: &wgpu::Queue, + view_proj: glam::Mat4, + clip_w: u32, + clip_h: u32, + ) { + // Phase 4-B batched path — write the same skip rule (sub-pixel + // 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; + } + + // Per-hatch (legacy) fallback path. self.hatch_skip_flags = self .gpu_hatches .iter() diff --git a/src/scene/render.rs b/src/scene/render.rs index 48fb97fcd..413764431 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -147,7 +147,7 @@ impl shader::Primitive for Primitive { 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_image_scissors(self.uniforms.view_proj, clip_size.width, clip_size.height); - pipeline.compute_hatch_lod(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_wipeout_lod(self.uniforms.view_proj, clip_size.width, clip_size.height); pipeline.compute_mesh_lod(self.uniforms.view_proj, clip_size.width, clip_size.height); if self.show_viewcube { diff --git a/src/shaders/hatch_batched.wgsl b/src/shaders/hatch_batched.wgsl index d914ee930..00ecc3b5c 100644 --- a/src/shaders/hatch_batched.wgsl +++ b/src/shaders/hatch_batched.wgsl @@ -63,10 +63,15 @@ struct LineFamily { dash_offset: u32, } -@group(1) @binding(0) var instances: array; -@group(1) @binding(1) var boundary: array>; -@group(1) @binding(2) var families: array; -@group(1) @binding(3) var dashes: array; +@group(1) @binding(0) var instances: array; +@group(1) @binding(1) var boundary: array>; +@group(1) @binding(2) var families: array; +@group(1) @binding(3) var dashes: array; +// Per-instance visibility (Phase 4-B sub-pixel + frustum skip). +// CPU writes `1` to draw / `0` to skip every frame; vertex shader +// emits an out-of-NDC clip position for 0-instances so the GPU +// rasterizer culls the primitive before any fragment runs. +@group(1) @binding(4) var visibility: array; // ── Vertex shader ──────────────────────────────────────────────────────── @@ -100,12 +105,12 @@ fn corner_xy(c: u32, aabb: vec4) -> vec2 { var o: VOut; let inst = instances[v.instance_index]; - // CPU-driven visibility flag (Phase 4-B frustum skip). Emit a clip - // position whose x/y exceed |w|, so the GPU frustum-culls the - // primitive and no fragment runs for it. (WGSL forbids literal NaN - // so we can't go that route; pushing the vertex out of the unit - // cube is the equivalent degenerate-triangle trick.) - if inst.visible == 0u { + // Per-frame visibility (CPU-driven sub-pixel + frustum skip). + // 0 → emit a clip position whose x/y exceed |w| so the GPU + // frustum-culls the primitive and no fragment runs. (WGSL + // forbids literal NaN so this out-of-NDC trick replaces the + // usual NaN-degenerate-triangle.) + if visibility[v.instance_index] == 0u { o.clip = vec4(2.0, 2.0, 2.0, 1.0); o.xz = vec2(0.0, 0.0); o.instance_index = v.instance_index;