fix(hatch): finite GPU sentinel for boundary loop separators (#386, #416)

Hatch/wipeout boundary sub-loops were separated by NaN vertices that the
fill shaders detected with `p.x == p.x`. Intel Mesa folds that NaN
self-compare to `true` under fast math, so separators read as real
vertices, the ray-cast crossing count broke, and fills bled outside
their boundary — only on Intel iGPUs. GPU uploads now rewrite the
separators to a finite 1e30 sentinel (GPU_BOUNDARY_SEP) and the shaders
test `abs(x) < 1e29`, which every driver evaluates identically. The
CPU-side NaN representation is unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Fable 5 committed 2026-07-22 21:45:37 +03:00
1 parent 0c578b03f3
commit 805da6428a
7 files changed
+46 -10

No files matched your search

+11
View File
@@ -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<Vec<[f32; 2]>>,
/// Exact absolute-WCS boundary in f64, set only by the draw commands so a
/// typed boundary vertex is persisted without the f32 quantization the
+10 -1
View File
@@ -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;
+5 -3
View File
@@ -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;
+5 -3
View File
@@ -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];
}
}
+5 -1
View File
@@ -142,7 +142,11 @@ struct VOut {
// ── Point-in-polygon (ray casting) over a sub-range of BoundaryBuffer ────
fn valid_vertex(p: vec2<f32>) -> 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<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {
+5 -1
View File
@@ -116,7 +116,11 @@ struct VOut {
// ── Point-in-polygon (ray casting) ────────────────────────────────────────
fn valid_vertex(p: vec2<f32>) -> 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<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {
+5 -1
View File
@@ -118,7 +118,11 @@ struct VOut {
// ── Point-in-polygon (ray casting) ────────────────────────────────────────
fn valid_vertex(p: vec2<f32>) -> 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<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {