diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 338f19c1d..208889abe 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -133,6 +133,13 @@ pub enum HatchPattern { }, } +/// GPU-side separator between disconnected boundary sub-loops. A finite +/// magnitude sentinel instead of NaN: shaders detect it with a plain +/// `abs(x) < 1e29` compare, which every driver evaluates identically, +/// unlike NaN self-comparison (#386, #416). Boundary verts are local +/// (anchor-relative), so real coordinates never approach it. +pub const GPU_BOUNDARY_SEP: f32 = 1.0e30; + /// A hatched region defined by a closed polygon boundary. #[derive(Clone, Debug)] pub struct HatchModel { @@ -152,6 +159,10 @@ pub struct HatchModel { /// World-XY coordinates of the boundary polygon vertices, stored as /// f32 offsets from `world_origin`. NaN-NaN sentinels separate /// disconnected paths and must be preserved un-shifted by consumers. + /// GPU uploads rewrite them to [`GPU_BOUNDARY_SEP`] — some drivers + /// (Intel Mesa) fold the shader-side `x == x` NaN test to `true` + /// under fast math, which turned separators into real vertices and + /// bled fills outside their boundary (#386, #416). pub boundary: Arc>, /// Exact absolute-WCS boundary in f64, set only by the draw commands so a /// typed boundary vertex is persisted without the f32 quantization the diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index 73e1235b8..129c2267e 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -216,7 +216,16 @@ impl HatchGpu { for h in hatches { let boundary_offset = boundary.len() as u32; for &[x, y] in h.boundary.iter() { - boundary.push([x, y, 0.0, 0.0]); + // NaN sub-loop separators become the finite sentinel on the + // GPU: Intel drivers fold the shader NaN self-compare to + // `true`, turning separators into vertices and bleeding the + // fill outside its boundary (#386, #416). + if x.is_finite() && y.is_finite() { + boundary.push([x, y, 0.0, 0.0]); + } else { + let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP; + boundary.push([s, s, 0.0, 0.0]); + } } let boundary_count = boundary.len() as u32 - boundary_offset; diff --git a/src/scene/pipeline/hatch_web_gpu.rs b/src/scene/pipeline/hatch_web_gpu.rs index c047b1b74..d1ceaae87 100644 --- a/src/scene/pipeline/hatch_web_gpu.rs +++ b/src/scene/pipeline/hatch_web_gpu.rs @@ -203,13 +203,15 @@ impl HatchWebGpu { // ── Pack the data texture: boundary | families | dashes ─────────── let mut texels: Vec<[f32; 4]> = Vec::new(); - // Boundary section (texels 0..vcount). NaN separators preserved (they - // survive the RGBA32F fetch byte-for-byte). + // Boundary section (texels 0..vcount). NaN separators become the + // finite GPU sentinel — the shader NaN self-compare is folded to + // `true` on some drivers (#386, #416). for &[x, y] in model.boundary.iter() { if x.is_finite() && y.is_finite() { texels.push([x + drift[0], y + drift[1], 0.0, 0.0]); } else { - texels.push([f32::NAN, f32::NAN, 0.0, 0.0]); + let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP; + texels.push([s, s, 0.0, 0.0]); } } let vcount = texels.len() as u32; diff --git a/src/scene/pipeline/wipeout_gpu.rs b/src/scene/pipeline/wipeout_gpu.rs index 1920344ab..dd114c2c1 100644 --- a/src/scene/pipeline/wipeout_gpu.rs +++ b/src/scene/pipeline/wipeout_gpu.rs @@ -310,12 +310,14 @@ impl WipeoutGpu { .take(MAX_HATCH_BOUNDARY_VERTS) .enumerate() { - // Preserve NaN separators — they signal path breaks to the - // in_polygon ray-cast, must not be shifted into finite numbers. + // Path-break separators go up as the finite GPU sentinel, not + // NaN — the shader NaN self-compare is folded to `true` on some + // drivers (#386, #416) — and must not be shifted by `drift`. if x.is_finite() && y.is_finite() { boundary_data.verts[i] = [x + drift[0], y + drift[1], 0.0, 0.0]; } else { - boundary_data.verts[i] = [f32::NAN, f32::NAN, 0.0, 0.0]; + let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP; + boundary_data.verts[i] = [s, s, 0.0, 0.0]; } } diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index 6b71ed6e5..21898c6e6 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -142,7 +142,11 @@ struct VOut { // ── Point-in-polygon (ray casting) over a sub-range of BoundaryBuffer ──── fn valid_vertex(p: vec2) -> bool { - return p.x == p.x && p.y == p.y; + // Sub-loop separators are a huge finite sentinel (1e30, see + // GPU_BOUNDARY_SEP), not NaN: `x == x` NaN detection gets folded to + // `true` by some drivers (Intel Mesa fast math), which made separators + // read as real vertices and bleed fills past their boundary (#386, #416). + return abs(p.x) < 1.0e29 && abs(p.y) < 1.0e29; } fn edge_crosses(p: vec2, a: vec2, c: vec2) -> bool { diff --git a/src/shaders/hatch_web.wgsl b/src/shaders/hatch_web.wgsl index 177e507fa..644cd1181 100644 --- a/src/shaders/hatch_web.wgsl +++ b/src/shaders/hatch_web.wgsl @@ -116,7 +116,11 @@ struct VOut { // ── Point-in-polygon (ray casting) ──────────────────────────────────────── fn valid_vertex(p: vec2) -> bool { - return p.x == p.x && p.y == p.y; + // Sub-loop separators are a huge finite sentinel (1e30, see + // GPU_BOUNDARY_SEP), not NaN: `x == x` NaN detection gets folded to + // `true` by some drivers (Intel Mesa fast math), which made separators + // read as real vertices and bleed fills past their boundary (#386, #416). + return abs(p.x) < 1.0e29 && abs(p.y) < 1.0e29; } fn edge_crosses(p: vec2, a: vec2, c: vec2) -> bool { diff --git a/src/shaders/wipeout.wgsl b/src/shaders/wipeout.wgsl index 541b1d611..67c08aaa8 100644 --- a/src/shaders/wipeout.wgsl +++ b/src/shaders/wipeout.wgsl @@ -118,7 +118,11 @@ struct VOut { // ── Point-in-polygon (ray casting) ──────────────────────────────────────── fn valid_vertex(p: vec2) -> bool { - return p.x == p.x && p.y == p.y; + // Sub-loop separators are a huge finite sentinel (1e30, see + // GPU_BOUNDARY_SEP), not NaN: `x == x` NaN detection gets folded to + // `true` by some drivers (Intel Mesa fast math), which made separators + // read as real vertices and bleed fills past their boundary (#386, #416). + return abs(p.x) < 1.0e29 && abs(p.y) < 1.0e29; } fn edge_crosses(p: vec2, a: vec2, c: vec2) -> bool {