From b4152ae1fe39695d893a99fabf337ed613588336 Mon Sep 17 00:00:00 2001 From: sLuCHa <142535358+sLuCHaa@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:23:54 +0200 Subject: [PATCH] perf(hatch): tessellate fills so navigation no longer flickers (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched hatch pass drew a screen-covering AABB quad per hatch and, for every fragment, ran a point-in-polygon ray-cast over the whole boundary plus the pattern test, with a discard that disabled early-Z. Zoomed into a hatch the quad covers the screen, so the shader re-ran per pixel every frame — the cost the PR #273 interaction-LOD worked around by suppressing hatches during navigation, which made fills flicker on and off while zooming. Triangulate each boundary once (lyon, even-odd — same coverage as the old in_polygon, islands/holes included) and rasterize those triangles instead: no screen-filling overdraw, no per-fragment boundary test. The fragment shader keeps the boundary test only on a per-instance fallback path (poly_test == 1) for boundaries that fail to tessellate. Tessellation is cached with the batch build (geometry_epoch) in local space, so the double-single relative-to-eye precision is preserved. With the fast path the interaction-LOD is no longer needed; it is gated behind OCS_HATCH_LOD (default off) as a safety net for now. --- src/scene/mod.rs | 10 ++ src/scene/pipeline/hatch_batched_gpu.rs | 83 ++++++++----- src/scene/pipeline/hatch_tess.rs | 150 ++++++++++++++++++++++++ src/scene/pipeline/mod.rs | 1 + src/scene/view/render.rs | 2 +- src/shaders/hatch_batched.wgsl | 48 ++++---- 6 files changed, 236 insertions(+), 58 deletions(-) create mode 100644 src/scene/pipeline/hatch_tess.rs diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e69ccd290..9ea92529b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1198,6 +1198,16 @@ impl Scene { .map_or(false, |t| t.elapsed().as_millis() < Self::NAV_SETTLE_MS) } + /// Whether the interaction-LOD hatch suppression is enabled (env + /// `OCS_HATCH_LOD`), read once. Default OFF: the tessellated hatch pass is + /// cheap enough that suppression — and its zoom flicker (#258) — is not + /// needed. Kept behind the flag as a safety net for pathological drawings. + pub fn hatch_lod_enabled(&self) -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("OCS_HATCH_LOD").is_some()) + } + /// True for a short window around navigation — used by the subscription to /// keep requesting frames just past the settle point so the one full-quality /// (hatched) frame actually renders after the cursor stops, even when no diff --git a/src/scene/pipeline/hatch_batched_gpu.rs b/src/scene/pipeline/hatch_batched_gpu.rs index f87be2323..69588827e 100644 --- a/src/scene/pipeline/hatch_batched_gpu.rs +++ b/src/scene/pipeline/hatch_batched_gpu.rs @@ -5,7 +5,7 @@ // // Data layout — three storage buffers fed from `HatchModel`s: // -// InstanceBuffer (binding 0) : HatchInstance[] (~96 B each) +// InstanceBuffer (binding 0) : HatchInstance[] (128 B each) // color, color2, mode, gradient, // pattern angle/scale, world_origin, // boundary_offset, boundary_count, @@ -22,13 +22,17 @@ // DashBuffer (binding 3) : f32[] (all dash // lengths concatenated) // -// The vertex buffer holds 6 corner indices repeated per-instance and a -// per-vertex `instance_index` (u32 attribute) — instance_index lets us -// avoid relying on `@builtin(instance_index)` for portability. The -// vertex shader reads the per-instance AABB from the InstanceBuffer -// and emits the quad corner for that instance. When the visibility -// flag is 0, it returns a degenerate position and the fragment shader -// runs zero times for that instance. +// The vertex buffer holds each hatch's tessellated boundary triangles as +// per-vertex local-space positions (`local_xy`) plus a per-vertex +// `instance_index` (u32 attribute) — instance_index lets us avoid relying +// on `@builtin(instance_index)` for portability. An instance whose +// boundary failed to tessellate instead gets an AABB quad here, with +// `poly_test == 1` on its `HatchInstance` so the fragment shader still +// runs the `in_polygon` test to clip the quad to the real shape; on the +// tessellated fast path (`poly_test == 0`) the triangles already bound +// the fill and the fragment shader skips that test. When the visibility +// flag is 0, the vertex shader returns a degenerate position and the +// fragment shader runs zero times for that instance. // // Two storage usages — vertex shader reads InstanceBuffer + Boundary // for the AABB / boundary range; fragment shader reads @@ -68,7 +72,10 @@ pub struct HatchInstance { /// Signed draw-order depth (-1,1); 0.0 = neutral. Applied as a clip-z /// bias in the vertex shader so this fill orders against other types. pub draw_depth: f32, // 112 - pub _pad0: u32, // 116 (pad to 128 = 16-byte stride) + /// 1 = run the per-fragment `in_polygon` boundary test (fallback path for + /// an instance whose boundary failed to tessellate); 0 = the rasterized + /// triangles already bound the fill, skip the test. + pub poly_test: u32, // 116 pub _pad1: u32, // 120 pub _pad2: u32, // 124 } @@ -107,18 +114,18 @@ pub struct LineFamilyGpu { const _: () = assert!(std::mem::size_of::() == 48); -/// Per-vertex data — 6 verts per instance. Instance_index here so we -/// can match WebGL2 backends that don't expose builtin(instance_index) -/// uniformly. +/// Per-vertex data — tessellated boundary triangles in local (world_origin- +/// relative) space, each carrying its instance index (avoids relying on +/// `@builtin(instance_index)` across backends). Instances whose boundary +/// failed to tessellate emit an AABB quad here instead (see `build`). #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct HatchBatchedVertex { - pub corner: u32, // 0..6 — which AABB corner - pub instance_index: u32, // index into InstanceBuffer + pub local_xy: [f32; 2], // 0 — local-space position + pub instance_index: u32, // 8 — index into InstanceBuffer } impl HatchBatchedVertex { - #[allow(dead_code)] pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, @@ -127,10 +134,10 @@ impl HatchBatchedVertex { wgpu::VertexAttribute { offset: 0, shader_location: 0, - format: wgpu::VertexFormat::Uint32, + format: wgpu::VertexFormat::Float32x2, }, wgpu::VertexAttribute { - offset: 4, + offset: 8, shader_location: 1, format: wgpu::VertexFormat::Uint32, }, @@ -167,7 +174,7 @@ pub struct HatchBatchedGpu { #[allow(dead_code)] pub instance_count: u32, /// 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 + /// per pan tick. Far cheaper than touching the 128 B-per-instance /// data. pub visibility: Vec, /// CPU-side mirror of each instance's local-space AABB (world- @@ -192,6 +199,7 @@ impl HatchBatchedGpu { } let mut instances: Vec = Vec::with_capacity(hatches.len()); + let mut instance_tris: Vec> = Vec::with_capacity(hatches.len()); let mut boundary: Vec<[f32; 4]> = Vec::new(); let mut families: Vec = Vec::new(); let mut dashes: Vec = Vec::new(); @@ -203,6 +211,12 @@ impl HatchBatchedGpu { } let boundary_count = boundary.len() as u32 - boundary_offset; + // Triangulate this boundary once (cached with the whole build, which + // only re-runs when geometry_epoch advances). Empty → fall back to the + // AABB-quad + in_polygon path for this instance. + let tris = crate::scene::pipeline::hatch_tess::tessellate_boundary(&h.boundary); + instance_tris.push(tris); + let family_offset = families.len() as u32; let mut family_count = 0u32; @@ -293,7 +307,7 @@ impl HatchBatchedGpu { family_offset, family_count, draw_depth: h.draw_depth, - _pad0: 0, + poly_test: 1, _pad1: 0, _pad2: 0, }); @@ -336,7 +350,7 @@ impl HatchBatchedGpu { family_offset, family_count, draw_depth: h.draw_depth, - _pad0: 0, + poly_test: if instance_tris.last().map_or(true, |t| t.is_empty()) { 1 } else { 0 }, _pad1: 0, _pad2: 0, }); @@ -353,15 +367,26 @@ impl HatchBatchedGpu { dashes.push(0.0); } - // Vertex buffer — 6 verts per instance (two triangles for the - // AABB quad), indexed by (corner, instance_index). - let mut verts: Vec = Vec::with_capacity(instances.len() * 6); - for (i, _) in instances.iter().enumerate() { - for corner in 0u32..6 { - verts.push(HatchBatchedVertex { - corner, - instance_index: i as u32, - }); + // Vertex buffer — tessellated triangles per instance, or an AABB quad + // fallback (poly_test==1) when tessellation produced nothing. + let mut verts: Vec = Vec::new(); + for (i, inst) in instances.iter().enumerate() { + let tris = &instance_tris[i]; + if !tris.is_empty() { + for &[x, y] in tris.iter() { + verts.push(HatchBatchedVertex { local_xy: [x, y], instance_index: i as u32 }); + } + } else { + // Fallback: two triangles over the local-space AABB, same corner + // order as the old shader (BL,BR,TL, BR,TR,TL). + let [xmin, ymin, xmax, ymax] = inst.aabb; + let quad = [ + [xmin, ymin], [xmax, ymin], [xmin, ymax], + [xmax, ymin], [xmax, ymax], [xmin, ymax], + ]; + for c in quad { + verts.push(HatchBatchedVertex { local_xy: c, instance_index: i as u32 }); + } } } diff --git a/src/scene/pipeline/hatch_tess.rs b/src/scene/pipeline/hatch_tess.rs new file mode 100644 index 000000000..4e2d39f2d --- /dev/null +++ b/src/scene/pipeline/hatch_tess.rs @@ -0,0 +1,150 @@ +//! CPU triangulation of hatch boundaries. Each hatch boundary is a flat +//! list of local-space verts with NaN markers separating sub-loops +//! (islands / holes). We split on the NaN markers into contours and fill +//! them with lyon's even-odd rule — identical coverage to the per-fragment +//! `in_polygon` ray-cast in `hatch_batched.wgsl` (including the #140 island +//! fix) — so the rasterized triangles can replace that test entirely. + +use lyon_tessellation::math::point; +use lyon_tessellation::path::Path; +use lyon_tessellation::{ + BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers, +}; + +/// Split a NaN-separated boundary into finite contours. +fn boundary_to_contours(boundary: &[[f32; 2]]) -> Vec> { + let mut contours = Vec::new(); + let mut cur: Vec<[f32; 2]> = Vec::new(); + for &[x, y] in boundary { + if x.is_finite() && y.is_finite() { + cur.push([x, y]); + } else if !cur.is_empty() { + contours.push(std::mem::take(&mut cur)); + } + } + if !cur.is_empty() { + contours.push(cur); + } + contours +} + +/// Triangulate a hatch boundary into a flat triangle-list in local space. +/// Empty on degenerate input or tessellation failure (caller falls back to +/// the AABB-quad + `in_polygon` path for that instance). +pub fn tessellate_boundary(boundary: &[[f32; 2]]) -> Vec<[f32; 2]> { + let contours = boundary_to_contours(boundary); + + let mut builder = Path::builder(); + for contour in &contours { + if contour.len() < 3 { + continue; + } + builder.begin(point(contour[0][0], contour[0][1])); + for p in &contour[1..] { + builder.line_to(point(p[0], p[1])); + } + builder.end(true); + } + let path = builder.build(); + + let mut geometry: VertexBuffers<[f32; 2], u32> = VertexBuffers::new(); + let mut tess = FillTessellator::new(); + if tess + .tessellate_path( + &path, + &FillOptions::default().with_fill_rule(FillRule::EvenOdd), + &mut BuffersBuilder::new(&mut geometry, |v: FillVertex| v.position().to_array()), + ) + .is_err() + { + return Vec::new(); + } + + let mut tris = Vec::with_capacity(geometry.indices.len()); + for &idx in &geometry.indices { + if let Some(&p) = geometry.vertices.get(idx as usize) { + tris.push(p); + } + } + tris +} + +#[cfg(test)] +mod tests { + use super::*; + + /// True if `p` lies inside any triangle of a flat triangle-list. + fn covered(tris: &[[f32; 2]], p: [f32; 2]) -> bool { + fn sign(a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> f32 { + (a[0] - c[0]) * (b[1] - c[1]) - (b[0] - c[0]) * (a[1] - c[1]) + } + tris.chunks_exact(3).any(|t| { + let (a, b, c) = (t[0], t[1], t[2]); + let d1 = sign(p, a, b); + let d2 = sign(p, b, c); + let d3 = sign(p, c, a); + let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(has_neg && has_pos) + }) + } + + #[test] + fn square_tessellates_and_covers_center() { + let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + let tris = tessellate_boundary(&sq); + assert!(!tris.is_empty(), "square should tessellate"); + assert_eq!(tris.len() % 3, 0, "triangle-list length must be a multiple of 3"); + assert!(covered(&tris, [5.0, 5.0]), "center must be covered"); + assert!(!covered(&tris, [20.0, 20.0]), "outside point must not be covered"); + } + + #[test] + fn even_odd_hole_is_not_covered() { + // Outer 0..10 square, NaN separator, inner 3..7 square (a hole). + let nan = f32::NAN; + let boundary = [ + [0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], + [nan, nan], + [3.0, 3.0], [7.0, 3.0], [7.0, 7.0], [3.0, 7.0], + ]; + let tris = tessellate_boundary(&boundary); + assert!(!tris.is_empty()); + assert!(covered(&tris, [1.0, 1.0]), "point in the ring must be covered"); + assert!(!covered(&tris, [5.0, 5.0]), "point in the hole must NOT be covered (even-odd)"); + } + + #[test] + fn degenerate_boundary_returns_empty() { + assert!(tessellate_boundary(&[[0.0, 0.0], [1.0, 1.0]]).is_empty()); + assert!(tessellate_boundary(&[]).is_empty()); + } + + #[test] + fn nested_island_even_odd_fills_center() { + // Even-odd: outer ring filled, hole empty, island inside the hole filled again. + let nan = f32::NAN; + let boundary = [ + [0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], // outer + [nan, nan], + [2.0, 2.0], [8.0, 2.0], [8.0, 8.0], [2.0, 8.0], // hole + [nan, nan], + [3.0, 3.0], [7.0, 3.0], [7.0, 7.0], [3.0, 7.0], // island inside the hole + ]; + let tris = tessellate_boundary(&boundary); + assert!(!tris.is_empty()); + assert!(covered(&tris, [5.0, 5.0]), "innermost island must be filled"); + assert!(!covered(&tris, [2.5, 5.0]), "the hole ring must be empty"); + assert!(covered(&tris, [0.5, 5.0]), "outer ring must be filled"); + } + + #[test] + fn self_intersecting_boundary_is_well_formed() { + // Bowtie / figure-8: a self-intersecting single loop. Must not panic and + // must return a well-formed triangle-list (possibly empty -> caller falls + // back to the AABB-quad + in_polygon path). + let bowtie = [[0.0, 0.0], [10.0, 10.0], [0.0, 10.0], [10.0, 0.0]]; + let tris = tessellate_boundary(&bowtie); + assert_eq!(tris.len() % 3, 0); + } +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index d0dad43a2..a33ac8a0b 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,5 +1,6 @@ pub mod face3d_gpu; pub mod hatch_batched_gpu; +pub mod hatch_tess; pub mod hatch_gpu; pub mod image_gpu; pub mod mesh_gpu; diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 75dc8f5e2..d9966593c 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1202,7 +1202,7 @@ impl Scene { // actively moving; the scene-render cache holds the full-quality // (hatched) frame once it settles. Only applied to the on-screen // Model / paper content — the paper *sheet* keeps its fills. - skip_hatch: !inst.paper_sheet && self.navigating_lod(), + skip_hatch: self.hatch_lod_enabled() && !inst.paper_sheet && self.navigating_lod(), geometry_epoch: self.geometry_epoch, camera_generation: self.camera_generation, wire_content_id, diff --git a/src/shaders/hatch_batched.wgsl b/src/shaders/hatch_batched.wgsl index 80f15274c..4c02d124b 100644 --- a/src/shaders/hatch_batched.wgsl +++ b/src/shaders/hatch_batched.wgsl @@ -1,19 +1,23 @@ // Phase 4-B — batched hatch shader. All hatches in one draw call; // per-instance data fetched from storage buffers indexed by the // `instance_index` vertex attribute (passed from the per-vertex -// (corner, instance_index) stream so we don't depend on -// @builtin(instance_index) edge cases across backends). +// (local_xy, instance_index) stream of tessellated boundary-triangle +// positions so we don't depend on @builtin(instance_index) edge cases +// across backends). // // Layout — matches `hatch_batched_gpu.rs`: -// group 1 binding 0 InstanceBuffer HatchInstance[] (112 B / inst) +// group 1 binding 0 InstanceBuffer HatchInstance[] (128 B / inst) // group 1 binding 1 BoundaryBuffer vec4[] (xy in .xy) // group 1 binding 2 FamilyBuffer LineFamilyGpu[] (48 B / fam) // group 1 binding 3 DashBuffer f32[] // -// Vertex shader emits a per-instance AABB quad (two triangles). A -// `visible == 0` instance gets a NaN clip position so the fragment -// shader never runs for it — that's the GPU-side cull (Phase 4-B -// equivalent of `compute_hatch_lod` writing `hatch_skip_flags`). +// The vertex shader emits tessellated boundary triangles in local space +// (an instance whose boundary failed to tessellate falls back to an +// AABB quad with `poly_test == 1`, so the fragment shader still runs +// `in_polygon` to clip it to the real shape). A `visible == 0` instance +// gets an out-of-NDC clip position so the fragment shader never runs +// for it — that's the GPU-side cull (Phase 4-B equivalent of +// `compute_hatch_lod` writing `hatch_skip_flags`). // ── Group 0: shared frame uniforms (matches hatch.wgsl) ────────────────── @@ -54,7 +58,7 @@ struct HatchInstance { family_offset: u32, family_count: u32, draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral - _pad0: u32, + poly_test: u32, // 1 = run in_polygon (fallback), 0 = skip _pad1: u32, _pad2: u32, } @@ -91,7 +95,7 @@ struct LineFamily { // ── Vertex shader ──────────────────────────────────────────────────────── struct VIn { - @location(0) corner: u32, + @location(0) local_xy: vec2, @location(1) instance_index: u32, } @@ -101,21 +105,6 @@ struct VOut { @location(1) @interpolate(flat) instance_index: u32, } -fn corner_xy(c: u32, aabb: vec4) -> vec2 { - // Two-triangle quad covering the AABB: - // 0 BL, 1 BR, 2 TL, 3 BR, 4 TR, 5 TL - let xmin = aabb.x; let ymin = aabb.y; - let xmax = aabb.z; let ymax = aabb.w; - switch c { - case 0u: { return vec2(xmin, ymin); } - case 1u: { return vec2(xmax, ymin); } - case 2u: { return vec2(xmin, ymax); } - case 3u: { return vec2(xmax, ymin); } - case 4u: { return vec2(xmax, ymax); } - default: { return vec2(xmin, ymax); } - } -} - @vertex fn vs_main(v: VIn) -> VOut { var o: VOut; let inst = instances[v.instance_index]; @@ -132,7 +121,7 @@ fn corner_xy(c: u32, aabb: vec4) -> vec2 { return o; } - let local = corner_xy(v.corner, inst.aabb); + let local = v.local_xy; // Double-single relative-to-eye: the anchor high half cancels exactly // against eye_high (Sterbenz); local + anchor low + (−eye_low) carry the // residual. `local` is small (boundary-relative), so adding it in the low @@ -270,9 +259,12 @@ fn check_family( @fragment fn fs_main(v: VOut) -> @location(0) vec4 { let inst = instances[v.instance_index]; - // 1. Boundary test. - if !in_polygon(v.xz, inst.boundary_offset, inst.boundary_count) { - discard; + // 1. Boundary test — only on the fallback path (poly_test==1). On the + // tessellated fast path the triangles already bound the fill. + if inst.poly_test == 1u { + if !in_polygon(v.xz, inst.boundary_offset, inst.boundary_count) { + discard; + } } // 2. Mode dispatch.