From e7675be0bc59fc4b839d83eb524793771a8ea19e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 15 May 2026 23:18:21 +0300 Subject: [PATCH] =?UTF-8?q?perf(scene):=20hatch=20LOD=20=E2=80=94=20solid-?= =?UTF-8?q?fill=20substitute=20+=20sub-pixel=20skip=20(Phase=203.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hatch fills drew the full per-family pattern loop every frame regardless of zoom. Two LOD tiers, neither of which existed: * **Shader-side solid substitute.** When the densest line family's spacing × scale projects to less than 2 px, individual lines blur into a solid color and the per-fragment family loop is wasted ALU (and produces moiré). Pass `world_per_pixel` through Uniforms; the hatch fragment shader now returns `h.color` directly in that case. * **CPU-side sub-pixel skip.** When the entire hatch AABB projects to less than 2 px, drop the draw call. Adds `world_aabb` to HatchGpu plus a per-frame `compute_hatch_lod` pass (mirrors the existing scissor pass) and `hatch_skip_flags`. The render pass skips flagged hatches. Factored a shared `aabb_below_pixel` helper for future LOD callers. The Uniforms layout is unchanged in size (96 B) — the trailing `_pad: vec2` becomes `world_per_pixel: f32, _pad: f32`, so wgsl modules that don't read the new field keep working with their old `_pad: vec2` declarations. Co-Authored-By: Claude Opus 4.7 --- src/scene/pipeline/hatch_gpu.rs | 5 +++ src/scene/pipeline/mod.rs | 60 +++++++++++++++++++++++++++++++++ src/scene/pipeline/uniforms.rs | 15 +++++++-- src/scene/render.rs | 1 + src/shaders/hatch.wgsl | 28 ++++++++++++--- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index 1f2e045da..e9bc8cb56 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -106,6 +106,10 @@ pub struct HatchGpu { /// frame's `compute_hatch_scissors` can translate the world rect into /// a pixel scissor. pub vp_scissor: Option<[f32; 4]>, + /// World-space XY bounding rect [min_x, min_y, max_x, max_y] of the + /// boundary polygon. Used by the per-frame LOD pass to skip hatches + /// whose entire footprint projects to less than ~2 px (Phase 3.3). + pub world_aabb: [f32; 4], _uniform_buf: wgpu::Buffer, _boundary_buf: wgpu::Buffer, _family_buf: wgpu::Buffer, @@ -273,6 +277,7 @@ impl HatchGpu { vertex_buffer, bind_group, vp_scissor: model.vp_scissor, + world_aabb: [min_x, min_y, max_x, max_y], _uniform_buf, _boundary_buf, _family_buf, diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 295a6214d..0a332439a 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -59,6 +59,10 @@ pub struct Pipeline { gpu_hatches: Vec, /// 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 + /// current zoom. Render pass skips the draw call. Recomputed each + /// frame alongside the scissor pass (Phase 3.3 hatch LOD). + hatch_skip_flags: Vec, /// Wipeout fills — rendered after wires in a separate pass. gpu_wipeouts: Vec, /// Pixel scissor rects [x, y, w, h] for viewport-clipped wipeouts. Recomputed each frame. @@ -597,6 +601,7 @@ impl Pipeline { gpu_selected_wires: vec![], gpu_hatches: vec![], hatch_pixel_scissors: vec![], + hatch_skip_flags: vec![], gpu_wipeouts: vec![], wipeout_pixel_scissors: vec![], gpu_images: vec![], @@ -640,6 +645,20 @@ impl Pipeline { .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(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) { + self.hatch_skip_flags = self + .gpu_hatches + .iter() + .map(|h| aabb_below_pixel(h.world_aabb, view_proj, clip_w, clip_h, 2.0)) + .collect(); + } + /// Recompute pixel scissor rects for viewport-clipped wipeouts. pub fn compute_wipeout_scissors(&mut self, view_proj: glam::Mat4, clip_w: u32, clip_h: u32) { self.wipeout_pixel_scissors = self @@ -767,6 +786,10 @@ impl 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; + } match self.hatch_pixel_scissors.get(i) { Some(Some([x, y, w, h])) => { pass.set_scissor_rect(*x, *y, *w, *h); @@ -1161,6 +1184,43 @@ impl Pipeline { } } +/// Return `true` when the world-XY AABB's screen-space size is below the +/// given pixel threshold. Used by LOD passes (hatch skip, etc.) to drop +/// draw calls that wouldn't contribute a visible pixel. +fn aabb_below_pixel( + aabb: [f32; 4], + view_proj: glam::Mat4, + clip_w: u32, + clip_h: u32, + threshold_px: f32, +) -> bool { + let [x0, y0, x1, y1] = aabb; + if !x0.is_finite() || !y0.is_finite() || !x1.is_finite() || !y1.is_finite() { + return false; + } + let w = clip_w as f32; + let h = clip_h as f32; + let corners = [ + view_proj.project_point3(glam::Vec3::new(x0, y0, 0.0)), + view_proj.project_point3(glam::Vec3::new(x1, y0, 0.0)), + view_proj.project_point3(glam::Vec3::new(x0, y1, 0.0)), + view_proj.project_point3(glam::Vec3::new(x1, y1, 0.0)), + ]; + let mut min_px = f32::INFINITY; + let mut max_px = f32::NEG_INFINITY; + let mut min_py = f32::INFINITY; + let mut max_py = f32::NEG_INFINITY; + for c in &corners { + let px = (c.x + 1.0) * 0.5 * w; + let py = (1.0 - c.y) * 0.5 * h; + if px < min_px { min_px = px; } + if px > max_px { max_px = px; } + if py < min_py { min_py = py; } + if py > max_py { max_py = py; } + } + (max_px - min_px).max(max_py - min_py) < threshold_px +} + /// Project a world-space XY scissor rect through `view_proj` into the four /// pixel-space corners and return the smallest aligned-rect that bounds them, /// clamped to the clip viewport. Returns `None` when the rect is missing or diff --git a/src/scene/pipeline/uniforms.rs b/src/scene/pipeline/uniforms.rs index 1700c7baf..5b4a0bb69 100644 --- a/src/scene/pipeline/uniforms.rs +++ b/src/scene/pipeline/uniforms.rs @@ -7,16 +7,27 @@ pub struct Uniforms { pub view_proj: glam::Mat4, pub camera_pos: glam::Vec4, pub viewport_size: [f32; 2], - pub _pad: [f32; 2], + /// World units per screen pixel at the current zoom. Used by the + /// hatch shader to substitute solid fill when pattern line spacing + /// falls below ~2 px (Phase 3.3 LOD). + pub world_per_pixel: f32, + pub _pad: f32, } impl Uniforms { pub fn new(camera: &Camera, bounds: Rectangle) -> Self { + let half_h = camera.ortho_size(); + let world_per_pixel = if bounds.height > 0.0 { + (2.0 * half_h) / bounds.height + } else { + 0.0 + }; Self { view_proj: camera.view_proj(bounds), camera_pos: camera.position_vec4(), viewport_size: [bounds.width, bounds.height], - _pad: [0.0; 2], + world_per_pixel, + _pad: 0.0, } } } diff --git a/src/scene/render.rs b/src/scene/render.rs index 86cc10613..ed6e6a45c 100644 --- a/src/scene/render.rs +++ b/src/scene/render.rs @@ -147,6 +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); if self.show_viewcube { pipeline.viewcube.upload( queue, diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index c6d21bf6e..8c2fdd4f8 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -13,10 +13,11 @@ // ── Group 0: frame uniforms (shared) ────────────────────────────────────── struct Uniforms { - view_proj: mat4x4, - camera_pos: vec4, - viewport_size: vec2, - _pad: vec2, + view_proj: mat4x4, + camera_pos: vec4, + viewport_size: vec2, + world_per_pixel: f32, + _pad: f32, } @group(0) @binding(0) var u: Uniforms; @@ -195,7 +196,24 @@ fn check_family( return mix(h.color, h.color2, t); } - // 3. Pattern: evaluate each line family. + // 3. Pattern: LOD substitution — when the densest family's spacing + // projects to less than 2 px, individual lines blur into a solid + // fill and the per-family loop just wastes ALU. Return solid color + // instead. (Phase 3.3 hatch LOD.) + if u.world_per_pixel > 0.0 && f.n_families > 0u { + var min_spacing_world: f32 = 1.0e30; + for (var i = 0u; i < f.n_families; i++) { + let s = abs(f.families[i].perp_step) * h.scale; + if s > 0.0 && s < min_spacing_world { + min_spacing_world = s; + } + } + if min_spacing_world / u.world_per_pixel < 2.0 { + return h.color; + } + } + + // 4. Pattern: evaluate each line family. let cos_off = cos(h.angle_offset); let sin_off = sin(h.angle_offset); for (var i = 0u; i < f.n_families; i++) {