From 1cf64161d329db4fb74d434b9384ec2b8523f1d2 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 21 Jul 2026 18:06:01 +0300 Subject: [PATCH] fix(render): hatch dpdx in uniform control flow (#414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tint (Chrome/Edge WebGPU) rejects derivative builtins reached after the family loop's non-uniform early return, invalidating hatch_web/wipeout shaders on Windows. Take dpdx/dpdy(xz) once at fs_main top and rotate into the family frame in check_family — same math, uniform flow. Co-Authored-By: Claude Fable 5 --- src/shaders/hatch.wgsl | 22 ++++++++++++++++++---- src/shaders/hatch_web.wgsl | 22 ++++++++++++++++++---- src/shaders/wipeout.wgsl | 22 ++++++++++++++++++---- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index a9bbf45cd..6b71ed6e5 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -190,8 +190,13 @@ fn in_polygon(p: vec2, offset: u32, count: u32) -> bool { // ── Per-family hatch test (same math as hatch.wgsl, dashes from // global DashBuffer instead of per-hatch FamilyBatch) ──────────────────── +// `ddx_xz`/`ddy_xz` are screen-space derivatives of `xz`, taken once in +// fs_main: derivative builtins must run in uniform control flow, and the +// per-family loop's early return makes later iterations non-uniform. fn check_family( xz: vec2, + ddx_xz: vec2, + ddy_xz: vec2, fam: LineFamily, cos_off: f32, sin_off: f32, @@ -213,13 +218,18 @@ fn check_family( let k = round(perp / perp_step); let dperp = perp - k * perp_step; let d = abs(dperp); - let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; + // perp is linear in xz (offsets are constant), so its derivatives are the + // xz derivatives rotated into the family frame. + let half_px = length(vec2( + -ddx_xz.x * sin_a + ddx_xz.y * cos_a, + -ddy_xz.x * sin_a + ddy_xz.y * cos_a, + )) * 0.5; // World units per screen pixel on each axis — used to light exactly the // one pixel that contains a dot's centre (pixel-snapped, so the dot stays // a steady single pixel at any pattern angle instead of flickering). - let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); - let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); + let wpx = length(vec2(ddx_xz.x, ddy_xz.x)); + let wpy = length(vec2(ddx_xz.y, ddy_xz.y)); // A fragment within ~1px of a line may be a dot; everything further out is // empty fill. (A dot's pixel sits on a line, so its perp offset is < 1px.) @@ -257,6 +267,10 @@ fn check_family( // ── Fragment shader ────────────────────────────────────────────────────── @fragment fn fs_main(v: VOut) -> @location(0) vec4 { + // Taken here, in uniform control flow — see check_family. + let ddx_xz = dpdx(v.xz); + let ddy_xz = dpdy(v.xz); + let inst = instances[v.instance_index]; // 1. Boundary test — only on the fallback path (poly_test==1). On the @@ -318,7 +332,7 @@ fn check_family( let cos_off = cos(inst.angle_offset); let sin_off = sin(inst.angle_offset); for (var i = 0u; i < inst.family_count; i++) { - if check_family(v.xz, families[inst.family_offset + i], cos_off, sin_off, inst.scale) { + if check_family(v.xz, ddx_xz, ddy_xz, families[inst.family_offset + i], cos_off, sin_off, inst.scale) { return inst.color; } } diff --git a/src/shaders/hatch_web.wgsl b/src/shaders/hatch_web.wgsl index 2a4d987b5..177e507fa 100644 --- a/src/shaders/hatch_web.wgsl +++ b/src/shaders/hatch_web.wgsl @@ -161,8 +161,13 @@ fn in_polygon(p: vec2) -> bool { // ── Per-family hatch test (identical math to wipeout.wgsl) ───────────────── +// `ddx_xz`/`ddy_xz` are screen-space derivatives of `xz`, taken once in +// fs_main: derivative builtins must run in uniform control flow, and the +// per-family loop's early return makes later iterations non-uniform. fn check_family( xz: vec2, + ddx_xz: vec2, + ddy_xz: vec2, fam: LineFamily, cos_off: f32, sin_off: f32, @@ -183,10 +188,15 @@ fn check_family( let k = round(perp / perp_step); let dperp = perp - k * perp_step; let d = abs(dperp); - let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; + // perp is linear in xz (offsets are constant), so its derivatives are the + // xz derivatives rotated into the family frame. + let half_px = length(vec2( + -ddx_xz.x * sin_a + ddx_xz.y * cos_a, + -ddy_xz.x * sin_a + ddy_xz.y * cos_a, + )) * 0.5; - let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); - let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); + let wpx = length(vec2(ddx_xz.x, ddy_xz.x)); + let wpy = length(vec2(ddx_xz.y, ddy_xz.y)); if d > half_px * 2.0 { return false; } @@ -220,6 +230,10 @@ fn check_family( // ── Fragment shader ──────────────────────────────────────────────────────── @fragment fn fs_main(v: VOut) -> @location(0) vec4 { + // Taken here, in uniform control flow — see check_family. + let ddx_xz = dpdx(v.xz); + let ddy_xz = dpdy(v.xz); + if !in_polygon(v.xz) { discard; } let base_mode = h.mode & 0xFFu; @@ -271,7 +285,7 @@ fn check_family( let cos_off = cos(h.angle_offset); let sin_off = sin(h.angle_offset); for (var i = 0u; i < h.n_families; i++) { - if check_family(v.xz, load_family(i), cos_off, sin_off, h.scale) { + if check_family(v.xz, ddx_xz, ddy_xz, load_family(i), cos_off, sin_off, h.scale) { return h.color; } } diff --git a/src/shaders/wipeout.wgsl b/src/shaders/wipeout.wgsl index 8e689efd7..541b1d611 100644 --- a/src/shaders/wipeout.wgsl +++ b/src/shaders/wipeout.wgsl @@ -170,8 +170,13 @@ fn in_polygon(p: vec2) -> bool { // `cos_off` / `sin_off` are cos/sin of `h.angle_offset` (precomputed once). // `scale` is `h.scale`. +// `ddx_xz`/`ddy_xz` are screen-space derivatives of `xz`, taken once in +// fs_main: derivative builtins must run in uniform control flow, and the +// per-family loop's early return makes later iterations non-uniform. fn check_family( xz: vec2, + ddx_xz: vec2, + ddy_xz: vec2, fam: LineFamily, cos_off: f32, sin_off: f32, @@ -196,13 +201,18 @@ fn check_family( let k = round(perp / perp_step); let dperp = perp - k * perp_step; let d = abs(dperp); - let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; + // perp is linear in xz (offsets are constant), so its derivatives are the + // xz derivatives rotated into the family frame. + let half_px = length(vec2( + -ddx_xz.x * sin_a + ddx_xz.y * cos_a, + -ddy_xz.x * sin_a + ddy_xz.y * cos_a, + )) * 0.5; // World units per screen pixel on each axis — used to light exactly the // one pixel that contains a dot's centre (pixel-snapped, so a dot stays a // steady single pixel at any pattern angle instead of flickering). - let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); - let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); + let wpx = length(vec2(ddx_xz.x, ddy_xz.x)); + let wpy = length(vec2(ddx_xz.y, ddy_xz.y)); // A fragment within ~1px of a line may be a dot; further out is empty fill. if d > half_px * 2.0 { return false; } @@ -242,6 +252,10 @@ fn check_family( // ── Fragment shader ──────────────────────────────────────────────────────── @fragment fn fs_main(v: VOut) -> @location(0) vec4 { + // Taken here, in uniform control flow — see check_family. + let ddx_xz = dpdx(v.xz); + let ddy_xz = dpdy(v.xz); + // 1. Boundary test. if !in_polygon(v.xz) { discard; } @@ -282,7 +296,7 @@ fn check_family( let cos_off = cos(h.angle_offset); let sin_off = sin(h.angle_offset); for (var i = 0u; i < f.n_families; i++) { - if check_family(v.xz, f.families[i], cos_off, sin_off, h.scale) { + if check_family(v.xz, ddx_xz, ddy_xz, f.families[i], cos_off, sin_off, h.scale) { return h.color; } }