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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-22 21:45:37 +03:00
commit 805da6428a
7 changed files with 46 additions and 10 deletions

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;

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;

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];
}
}