From 4df41cdf34e7e75a16226fa49d2e5b80cda95189 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Jul 2026 13:30:49 +0300 Subject: [PATCH] fix(hatch): render fills via the per-fragment in_polygon test again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batched hatch path pre-triangulated every boundary on the CPU (PR#300) with no cap on the resulting vertex buffer. A hatch-dense drawing (13k solid-filled hatches) built a ~6.75 GB `hatch_batched.vertex` buffer and panicked in `Device::create_buffer` (device max 256 MB), after a long stall spent triangulating on load. Drop the CPU triangulation and always draw each hatch as its AABB quad (6 verts) with `poly_test = 1`, letting the shader resolve inside/outside per fragment against the uploaded boundary — the path the shader always kept as a fallback. The vertex buffer is now 6 verts/hatch regardless of boundary complexity, so no drawing can overflow it. The interaction-LOD flicker PR#300 addressed no longer occurs (OCS_HATCH_LOD defaults off). Removes the now-unused hatch_tess triangulation module. Co-Authored-By: Claude Opus 4.8 --- src/scene/pipeline/hatch_batched_gpu.rs | 46 +++----- src/scene/pipeline/hatch_tess.rs | 150 ------------------------ src/scene/pipeline/mod.rs | 1 - 3 files changed, 19 insertions(+), 178 deletions(-) delete mode 100644 src/scene/pipeline/hatch_tess.rs diff --git a/src/scene/pipeline/hatch_batched_gpu.rs b/src/scene/pipeline/hatch_batched_gpu.rs index 69588827e..ee2fb719c 100644 --- a/src/scene/pipeline/hatch_batched_gpu.rs +++ b/src/scene/pipeline/hatch_batched_gpu.rs @@ -199,7 +199,6 @@ 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(); @@ -211,11 +210,6 @@ 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; @@ -350,12 +344,16 @@ impl HatchBatchedGpu { family_offset, family_count, draw_depth: h.draw_depth, - poly_test: if instance_tris.last().map_or(true, |t| t.is_empty()) { 1 } else { 0 }, + // Always resolve inside/outside per fragment on the GPU (the + // boundary is uploaded below). Dropping CPU pre-triangulation + // keeps the vertex buffer at 6 verts/hatch regardless of + // boundary complexity, so a hatch-dense drawing can't overflow + // the device buffer limit. Each instance draws its AABB quad. + poly_test: 1, _pad1: 0, _pad2: 0, }); } - // Empty fallbacks — storage buffers can't be zero-sized. if boundary.is_empty() { boundary.push([0.0; 4]); @@ -367,26 +365,20 @@ impl HatchBatchedGpu { dashes.push(0.0); } - // Vertex buffer — tessellated triangles per instance, or an AABB quad - // fallback (poly_test==1) when tessellation produced nothing. - let mut verts: Vec = Vec::new(); + // Vertex buffer — one AABB quad (two triangles, BL,BR,TL, BR,TR,TL) per + // instance; the GPU shader clips each fragment to the boundary via the + // in_polygon test (poly_test == 1). 6 verts/hatch, independent of + // boundary complexity, so the buffer can never overflow the device + // limit no matter how dense the drawing. + let mut verts: Vec = Vec::with_capacity(instances.len() * 6); 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 }); - } + 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 deleted file mode 100644 index 4e2d39f2d..000000000 --- a/src/scene/pipeline/hatch_tess.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! 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 84c4d5cf0..a40b0a8c6 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,6 +1,5 @@ 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;