fix(hatch): render fills via the per-fragment in_polygon test again

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-08 13:30:49 +03:00
1 parent 492ad940eb
commit 4df41cdf34
3 files changed
+19 -178

No files matched your search

+19 -27
View File
@@ -199,7 +199,6 @@ impl HatchBatchedGpu {
}
let mut instances: Vec<HatchInstance> = Vec::with_capacity(hatches.len());
let mut instance_tris: Vec<Vec<[f32; 2]>> = Vec::with_capacity(hatches.len());
let mut boundary: Vec<[f32; 4]> = Vec::new();
let mut families: Vec<LineFamilyGpu> = Vec::new();
let mut dashes: Vec<f32> = 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<HatchBatchedVertex> = 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<HatchBatchedVertex> = 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 });
}
}
-150
View File
@@ -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<Vec<[f32; 2]>> {
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);
}
}
-1
View File
@@ -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;