gputex 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,8 +90,13 @@ moments and accepted per block only when they lower the error; its
90
90
  near-flat blocks skip the line fit and take the endpoint pair whose ⅔/⅓
91
91
  interpolant lands nearest the block colour (direct 565 rounding is up to 4
92
92
  levels off — worth up to +3.9 dB on maps with flat regions). BC5 refits
93
- once; BC7's 16-level mode-6 palette makes the refit redundant on a
94
- principal-axis seed (≤0.05 dB). ASTC spends every one of its 128 bits: a
93
+ once from levels assigned against a slightly inset range (so the extreme
94
+ levels gather every pixel near the extremes, not just the extreme pixel),
95
+ then shifts both endpoints by the mean residual of the final levels, and
96
+ encodes blocks spanning ≤ 7 values losslessly — roughly 4–9% lower error than a
97
+ plain min/max seed on real textures, up to 60% on displacement maps. BC7's
98
+ 16-level mode-6 palette makes the refit redundant on a principal-axis seed
99
+ (≤0.05 dB). ASTC spends every one of its 128 bits: a
95
100
  wide-span opaque block gets 16 weight levels with 192-level (trit-coded)
96
101
  endpoints, a small-span one exact 8-bit endpoints with 8 levels,
97
102
  exactly-grayscale blocks a luminance-only mode with 32 levels. On GPUs that
package/dist/index.js CHANGED
@@ -1098,7 +1098,7 @@ var BC1Encoder = class extends Encoder {
1098
1098
  };
1099
1099
 
1100
1100
  // src/bc5.wgsl
1101
- var bc5_default = "// BC5 (RGTC2) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into a 16-byte BC5 block\n// written as 4 x u32 into the destination storage buffer. This is the f32\n// fallback; bc5_fast_f16.wgsl is the same algorithm and is preferred when\n// the device reports shader-f16.\n//\n// BC5 = two BC4 blocks concatenated:\n// block bytes 0..7 : BC4 of R channel (normal.x for tangent-space normals)\n// block bytes 8..15 : BC4 of G channel (normal.y)\n//\n// Each BC4 half-block (8 bytes):\n// byte 0 : red0 (8-bit endpoint)\n// byte 1 : red1 (8-bit endpoint)\n// bytes 2..7 : 16 \xD7 3-bit indices, LSB-first, pixel 0 at bit 0\n//\n// We always produce the 6-interpolation mode (red0 > red1). See `bc4_ref.ts`\n// for the reference this encoder is validated against.\n//\n// Same algorithm as bc5_fast_f16.wgsl (see that file for the full notes):\n// \u2022 both channels processed in the same fused passes, texels held as\n// quad-major vec4s per channel (the gather layout);\n// \u2022 pass 1 accumulates MOMENTS (\u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d with d = v \u2212 r0) from\n// which every LSQ normal-equation sum is an O(1) per-block expression;\n// the seed covers the data, so pass 1 needs no clamp; the rank guard\n// is the exact 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2 test;\n// \u2022 the closed-form refit prices the nearest rounding of the solve\n// through E(\u03B4) = err \u2212 2(\u03B40\xB7sAR + \u03B41\xB7sBR) + \u03B40\xB2sAA + 2\u03B40\u03B41\xB7sAB\n// + \u03B41\xB2sBB, accept-if-better, both channels as branch-free vec2 lanes;\n// \u2022 pass 2 derives the levels ONCE, against the FINAL endpoints \u2014 full\n// reprojection quality;\n// \u2022 the 16 texel reads are 8 textureGather fetches (4 quads \xD7 R,G)\n// through a clamp-to-edge sampler, byte-identical to per-texel loads;\n// \u2022 3-bit levels pack as \u03A3 L\xB78^k in f32 (exact below 2^24), then one\n// SWAR level \u2192 BC4 index map (0\u21920, 7\u21921, L\u2192L+1) per 24-bit word.\n// Values are kept in the [0,255] f32 domain throughout.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n@group(0) @binding(3) var smp: sampler;\n\n// 8 packed 3-bit levels \u2192 BC4 indices (0\u21920, 7\u21921, L\u2192L+1 otherwise).\nfn lvl_to_idx(x: u32) -> u32 {\n let y = ((x & 0x6DB6DBu) + 0x249249u) ^ (x & 0x924924u);\n return y ^ (~((y >> 1u) | (y >> 2u)) & 0x249249u);\n}\n\n// Per-quad pixel weights 8^k (gather order x,y,z,w = (0,1),(1,1),(1,0),(0,0)).\nconst W0 = vec4<f32>(4096.0, 32768.0, 8.0, 1.0);\nconst W1 = vec4<f32>(262144.0, 2097152.0, 512.0, 64.0);\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n\n // Load 4\xD74 R and G as quad-major vec4s in gather order (w=(0,0) z=(1,0)\n // x=(0,1) y=(1,1) of quad q = (x \u2265 2) + 2\xB7(y \u2265 2)). Interior blocks read\n // via 8 gathers normalised by the PHYSICAL (padded) texture size; blocks\n // straddling the source edge of a non-multiple-of-4 image fall back to\n // per-texel loads clamped to the last real texel (the padding strip is\n // zero-initialised \u2014 see bc5_fast_f16.wgsl).\n var vr: array<vec4<f32>, 4>;\n var vg: array<vec4<f32>, 4>;\n if (u32(base.x) + 4u <= params.width && u32(base.y) + 4u <= params.height) {\n let inv_size = vec2<f32>(1.0, 1.0) / vec2<f32>(textureDimensions(src_tex));\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = vec2<u32>((q & 1u) * 2u, (q >> 1u) * 2u);\n let cc = (vec2<f32>(base) + vec2<f32>(qo) + vec2<f32>(1.0, 1.0)) * inv_size;\n vr[q] = textureGather(0, src_tex, smp, cc) * 255.0;\n vg[q] = textureGather(1, src_tex, smp, cc) * 255.0;\n }\n } else {\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = base + vec2<i32>(i32(q & 1u) * 2, i32(q >> 1u) * 2);\n let cx = textureLoad(src_tex, clamp(qo + vec2<i32>(0, 1), vec2<i32>(0), mx), 0);\n let cy = textureLoad(src_tex, clamp(qo + vec2<i32>(1, 1), vec2<i32>(0), mx), 0);\n let cz = textureLoad(src_tex, clamp(qo + vec2<i32>(1, 0), vec2<i32>(0), mx), 0);\n let cw = textureLoad(src_tex, clamp(qo, vec2<i32>(0), mx), 0);\n vr[q] = vec4<f32>(cx.r, cy.r, cz.r, cw.r) * 255.0;\n vg[q] = vec4<f32>(cx.g, cy.g, cz.g, cw.g) * 255.0;\n }\n }\n let mnr = min(min(vr[0], vr[1]), min(vr[2], vr[3]));\n let mxr = max(max(vr[0], vr[1]), max(vr[2], vr[3]));\n let mng = min(min(vg[0], vg[1]), min(vg[2], vg[3]));\n let mxg = max(max(vg[0], vg[1]), max(vg[2], vg[3]));\n let vmin = vec2<f32>(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));\n let vmax = vec2<f32>(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));\n\n // Seed endpoints at the exact per-channel extremes (round-to-nearest, the\n // same rule the CPU reference uses). Flat blocks get nudged apart to keep\n // the 6-interp mode (r0 > r1 strictly).\n var r0 = vec2<u32>(clamp(floor(vmax + 0.5), vec2<f32>(0.0), vec2<f32>(255.0)));\n var r1 = vec2<u32>(clamp(floor(vmin + 0.5), vec2<f32>(0.0), vec2<f32>(255.0)));\n if (r0.x == r1.x) { if (r1.x > 0u) { r1.x = r1.x - 1u; } else { r0.x = r0.x + 1u; } }\n if (r0.y == r1.y) { if (r1.y > 0u) { r1.y = r1.y - 1u; } else { r0.y = r0.y + 1u; } }\n\n let r0f = vec2<f32>(r0);\n let r1f = vec2<f32>(r1);\n let dir = r1f - r0f;\n let scale = vec2<f32>(7.0) / dir;\n\n // Pass 1, both channels \u2014 MOMENTS only. t = d\xB7scale \u2208 [0,7] (the seed\n // covers the data, so no clamp), L = round(t).\n var sL = vec2<f32>(0.0); var sLL = vec2<f32>(0.0);\n var sd = vec2<f32>(0.0); var sLd = vec2<f32>(0.0);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let dr = vr[q] - r0f.x;\n let dg = vg[q] - r0f.y;\n let Lr = floor(dr * scale.x + 0.5);\n let Lg = floor(dg * scale.y + 0.5);\n sL = sL + vec2<f32>(dot(Lr, vec4<f32>(1.0)), dot(Lg, vec4<f32>(1.0)));\n sLL = sLL + vec2<f32>(dot(Lr, Lr), dot(Lg, Lg));\n sd = sd + vec2<f32>(dot(dr, vec4<f32>(1.0)), dot(dg, vec4<f32>(1.0)));\n sLd = sLd + vec2<f32>(dot(Lr, dr), dot(Lg, dg));\n }\n\n // Per-block refit off the moments (see bc5_fast_f16.wgsl for the\n // identities): \u03A3\u03C1 = s\xB7\u03A3d \u2212 \u03A3L, \u03A3L\u03C1 = s\xB7\u03A3Ld \u2212 \u03A3L\xB2.\n let pR = scale * sd - sL;\n let pLR = scale * sLd - sLL;\n let sBB = sLL * (1.0 / 49.0);\n let sAB = sL * (1.0 / 7.0) - sBB;\n let sAA = vec2<f32>(16.0) - 2.0 * sL * (1.0 / 7.0) + sBB;\n let sBR = pLR * dir * (1.0 / 49.0);\n let sAR = (pR - pLR * (1.0 / 7.0)) * dir * (1.0 / 7.0);\n let spread = 16.0 * sLL != sL * sL;\n\n // Both channels at once, branch-free: nearest rounding of the LSQ\n // solve, accepted when it stays in 6-interp mode, moves, and prices\n // strictly better on the current indices. Endpoints clamp to [0,255],\n // NOT the block's value range: for a scalar channel, endpoints beyond\n // the data range are often genuinely optimal and there is no colour\n // axis to bend.\n let det = sAA * sBB - sAB * sAB;\n let idet = 1.0 / det;\n let q0f = floor(clamp(r0f + (sBB * sAR - sAB * sBR) * idet, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);\n let q1f = floor(clamp(r1f + (sAA * sBR - sAB * sAR) * idet, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);\n let dd0 = q0f - r0f;\n let dd1 = q1f - r1f;\n let eNew = -2.0 * (dd0 * sAR + dd1 * sBR) + dd0 * dd0 * sAA + 2.0 * dd0 * dd1 * sAB + dd1 * dd1 * sBB;\n let acc = spread & (abs(det) > vec2<f32>(1e-3)) & (q0f > q1f) & (eNew < vec2<f32>(0.0));\n let n0 = select(r0, vec2<u32>(q0f), acc);\n let n1 = select(r1, vec2<u32>(q1f), acc);\n\n // Pass 2, both channels \u2014 levels against the FINAL endpoints (rejected\n // channels re-derive their seed assignment), packed as \u03A3 L\xB78^k:\n // iA = pixels 0..7, iB = pixels 8..15.\n let n0f = vec2<f32>(n0);\n let sc2 = vec2<f32>(7.0) / (vec2<f32>(n1) - n0f);\n var Lr: array<vec4<f32>, 4>;\n var Lg: array<vec4<f32>, 4>;\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n Lr[q] = clamp(floor((vr[q] - n0f.x) * sc2.x + 0.5), vec4<f32>(0.0), vec4<f32>(7.0));\n Lg[q] = clamp(floor((vg[q] - n0f.y) * sc2.y + 0.5), vec4<f32>(0.0), vec4<f32>(7.0));\n }\n let iAx = lvl_to_idx(u32(dot(Lr[0], W0) + dot(Lr[1], W1)));\n let iBx = lvl_to_idx(u32(dot(Lr[2], W0) + dot(Lr[3], W1)));\n let iAy = lvl_to_idx(u32(dot(Lg[0], W0) + dot(Lg[1], W1)));\n let iBy = lvl_to_idx(u32(dot(Lg[2], W0) + dot(Lg[3], W1)));\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n let o = bi * 4u;\n dst[o] = n0.x | (n1.x << 8u) | (iAx << 16u);\n dst[o + 1u] = (iAx >> 16u) | (iBx << 8u);\n dst[o + 2u] = n0.y | (n1.y << 8u) | (iAy << 16u);\n dst[o + 3u] = (iAy >> 16u) | (iBy << 8u);\n}\n";
1101
+ var bc5_default = "// BC5 (RGTC2) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into a 16-byte BC5 block\n// written as 4 x u32 into the destination storage buffer. This is the f32\n// fallback; bc5_fast_f16.wgsl is the same algorithm and is preferred when\n// the device reports shader-f16.\n//\n// BC5 = two BC4 blocks concatenated:\n// block bytes 0..7 : BC4 of R channel (normal.x for tangent-space normals)\n// block bytes 8..15 : BC4 of G channel (normal.y)\n//\n// Each BC4 half-block (8 bytes):\n// byte 0 : red0 (8-bit endpoint)\n// byte 1 : red1 (8-bit endpoint)\n// bytes 2..7 : 16 \xD7 3-bit indices, LSB-first, pixel 0 at bit 0\n//\n// We always produce the 6-interpolation mode (red0 > red1). See `bc4_ref.ts`\n// for the reference this encoder is validated against.\n//\n// Same algorithm as bc5_fast_f16.wgsl (see that file for the full notes):\n// \u2022 both channels processed in the same fused passes, texels held as\n// quad-major vec4s per channel (the gather layout);\n// \u2022 seed at the per-channel extremes (spans \u2264 7: a lossless 7-wide\n// window); pass-1 levels come from that range inset by ~5.5/256 of the\n// span and accumulate MOMENTS (\u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d with d = v \u2212 r0) \u2014 the\n// seed covers the data, so pass 1 needs no clamp;\n// \u2022 refit = the least-squares line through those levels straight off the\n// moments (den = 16\u03A3L\xB2 \u2212 (\u03A3L)\xB2 = 0 keeps the seed), both channels as\n// branch-free vec2 lanes;\n// \u2022 pass 2 derives the levels ONCE, against the refit endpoints \u2014 full\n// reprojection quality \u2014 then an offset round shifts both endpoints by\n// the rounded mean residual of those levels (never worse on them);\n// \u2022 the 16 texel reads are 8 textureGather fetches (4 quads \xD7 R,G)\n// through a clamp-to-edge sampler, byte-identical to per-texel loads;\n// \u2022 3-bit levels pack as \u03A3 L\xB78^k in f32 (exact below 2^24), then one\n// SWAR level \u2192 BC4 index map (0\u21920, 7\u21921, L\u2192L+1) per 24-bit word.\n// Values are kept in the [0,255] f32 domain throughout.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n@group(0) @binding(3) var smp: sampler;\n\n// 8 packed 3-bit levels \u2192 BC4 indices (0\u21920, 7\u21921, L\u2192L+1 otherwise).\nfn lvl_to_idx(x: u32) -> u32 {\n let y = ((x & 0x6DB6DBu) + 0x249249u) ^ (x & 0x924924u);\n return y ^ (~((y >> 1u) | (y >> 2u)) & 0x249249u);\n}\n\n// Per-quad pixel weights 8^k (gather order x,y,z,w = (0,1),(1,1),(1,0),(0,0)).\nconst W0 = vec4<f32>(4096.0, 32768.0, 8.0, 1.0);\nconst W1 = vec4<f32>(262144.0, 2097152.0, 512.0, 64.0);\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n\n // Load 4\xD74 R and G as quad-major vec4s in gather order (w=(0,0) z=(1,0)\n // x=(0,1) y=(1,1) of quad q = (x \u2265 2) + 2\xB7(y \u2265 2)). Interior blocks read\n // via 8 gathers normalised by the PHYSICAL (padded) texture size; blocks\n // straddling the source edge of a non-multiple-of-4 image fall back to\n // per-texel loads clamped to the last real texel (the padding strip is\n // zero-initialised \u2014 see bc5_fast_f16.wgsl).\n var vr: array<vec4<f32>, 4>;\n var vg: array<vec4<f32>, 4>;\n if (u32(base.x) + 4u <= params.width && u32(base.y) + 4u <= params.height) {\n let inv_size = vec2<f32>(1.0, 1.0) / vec2<f32>(textureDimensions(src_tex));\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = vec2<u32>((q & 1u) * 2u, (q >> 1u) * 2u);\n let cc = (vec2<f32>(base) + vec2<f32>(qo) + vec2<f32>(1.0, 1.0)) * inv_size;\n vr[q] = textureGather(0, src_tex, smp, cc) * 255.0;\n vg[q] = textureGather(1, src_tex, smp, cc) * 255.0;\n }\n } else {\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = base + vec2<i32>(i32(q & 1u) * 2, i32(q >> 1u) * 2);\n let cx = textureLoad(src_tex, clamp(qo + vec2<i32>(0, 1), vec2<i32>(0), mx), 0);\n let cy = textureLoad(src_tex, clamp(qo + vec2<i32>(1, 1), vec2<i32>(0), mx), 0);\n let cz = textureLoad(src_tex, clamp(qo + vec2<i32>(1, 0), vec2<i32>(0), mx), 0);\n let cw = textureLoad(src_tex, clamp(qo, vec2<i32>(0), mx), 0);\n vr[q] = vec4<f32>(cx.r, cy.r, cz.r, cw.r) * 255.0;\n vg[q] = vec4<f32>(cx.g, cy.g, cz.g, cw.g) * 255.0;\n }\n }\n let mnr = min(min(vr[0], vr[1]), min(vr[2], vr[3]));\n let mxr = max(max(vr[0], vr[1]), max(vr[2], vr[3]));\n let mng = min(min(vg[0], vg[1]), min(vg[2], vg[3]));\n let mxg = max(max(vg[0], vg[1]), max(vg[2], vg[3]));\n let vmin = vec2<f32>(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));\n let vmax = vec2<f32>(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));\n\n // Seed endpoints at the per-channel extremes (round-to-nearest, the same\n // rule the CPU reference uses); spans \u2264 7 (incl. flat blocks) seed a\n // 7-wide window instead, whose levels land on every integer the block\n // holds \u2014 lossless, and the refit keeps it.\n let vhi = clamp(floor(vmax + 0.5), vec2<f32>(0.0), vec2<f32>(255.0));\n let vlo = clamp(floor(vmin + 0.5), vec2<f32>(0.0), vec2<f32>(255.0));\n let small = vhi - vlo <= vec2<f32>(7.0);\n let r1f = select(vlo, min(vlo, vec2<f32>(248.0)), small);\n let r0f = select(vhi, r1f + 7.0, small);\n // Pass-1 levels come from the seed range INSET by ~5.5/256 of the span\n // on both ends: scale \xD77.3125/7, offset \xBD \u2212 0.15625 (exact dyadic\n // constants, so the WebGL port folds them identically).\n let scale = vec2<f32>(7.3125) / (r1f - r0f);\n\n // Pass 1, both channels \u2014 MOMENTS only. t = d\xB7scale \u2208 [0,7.3125] (the seed\n // covers the data), L = floor(t + 11/32) \u2208 [0,7], no clamp.\n var sL = vec2<f32>(0.0); var sLL = vec2<f32>(0.0);\n var sd = vec2<f32>(0.0); var sLd = vec2<f32>(0.0);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let dr = vr[q] - r0f.x;\n let dg = vg[q] - r0f.y;\n let Lr = floor(dr * scale.x + 0.34375);\n let Lg = floor(dg * scale.y + 0.34375);\n sL = sL + vec2<f32>(dot(Lr, vec4<f32>(1.0)), dot(Lg, vec4<f32>(1.0)));\n sLL = sLL + vec2<f32>(dot(Lr, Lr), dot(Lg, Lg));\n sd = sd + vec2<f32>(dot(dr, vec4<f32>(1.0)), dot(dg, vec4<f32>(1.0)));\n sLd = sLd + vec2<f32>(dot(Lr, dr), dot(Lg, dg));\n }\n\n // Refit: least-squares line v \u2248 r0 + \u03B1 + \u03B2\xB7L through the pass-1 levels,\n // straight off the moments; den = 0 \u27FA every pixel on one level \u2014 keep\n // the seed then. Endpoints clamp to [0,255], NOT the block's value\n // range: for a scalar channel, endpoints beyond the data range are often\n // genuinely optimal and there is no colour axis to bend.\n let den = 16.0 * sLL - sL * sL;\n let beta = (16.0 * sLd - sL * sd) / den;\n let e0 = r0f + (sd - beta * sL) * (1.0 / 16.0);\n let q0f = floor(clamp(e0, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);\n let q1f = floor(clamp(e0 + 7.0 * beta, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);\n let acc = (den > vec2<f32>(0.0)) & (q0f > q1f);\n let n0f = select(r0f, q0f, acc);\n let n1f = select(r1f, q1f, acc);\n\n // Pass 2, both channels \u2014 levels against the refit endpoints, packed as\n // \u03A3 L\xB78^k (A = pixels 0..7, B = pixels 8..15), plus \u03A3L for the offset\n // round.\n let sc2 = vec2<f32>(7.0) / (n1f - n0f);\n var pk = vec4<f32>(0.0); // (Ax, Bx, Ay, By)\n var sL2 = vec2<f32>(0.0);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let Lr = clamp(floor((vr[q] - n0f.x) * sc2.x + 0.5), vec4<f32>(0.0), vec4<f32>(7.0));\n let Lg = clamp(floor((vg[q] - n0f.y) * sc2.y + 0.5), vec4<f32>(0.0), vec4<f32>(7.0));\n let w = select(W0, W1, (q & 1u) == 1u);\n let hi = q >= 2u;\n let pr = dot(Lr, w);\n let pg = dot(Lg, w);\n pk = pk + vec4<f32>(select(pr, 0.0, hi), select(0.0, pr, hi), select(pg, 0.0, hi), select(0.0, pg, hi));\n sL2 = sL2 + vec2<f32>(dot(Lr, vec4<f32>(1.0)), dot(Lg, vec4<f32>(1.0)));\n }\n\n // Offset round: shift both endpoints by the rounded mean residual of the\n // shipped levels (a whole-level shift moves every palette entry equally,\n // so the error on these indices can only drop, under any decoder).\n let res = sd + 16.0 * (r0f - n0f) - (n1f - n0f) * sL2 * (1.0 / 7.0);\n let sh = clamp(floor(res * (1.0 / 16.0) + 0.5), -n1f, vec2<f32>(255.0) - n0f);\n let m0 = vec2<u32>(n0f + sh);\n let m1 = vec2<u32>(n1f + sh);\n let iAx = lvl_to_idx(u32(pk.x));\n let iBx = lvl_to_idx(u32(pk.y));\n let iAy = lvl_to_idx(u32(pk.z));\n let iBy = lvl_to_idx(u32(pk.w));\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n let o = bi * 4u;\n dst[o] = m0.x | (m1.x << 8u) | (iAx << 16u);\n dst[o + 1u] = (iAx >> 16u) | (iBx << 8u);\n dst[o + 2u] = m0.y | (m1.y << 8u) | (iAy << 16u);\n dst[o + 3u] = (iAy >> 16u) | (iBy << 8u);\n}\n";
1102
1102
 
1103
1103
  // src/bc5_fast_f16.wgsl
1104
1104
  var bc5_fast_f16_default = `// bc5 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
@@ -1122,43 +1122,58 @@ var bc5_fast_f16_default = `// bc5 "fast" encoder \u2014 f16 variant (requires t
1122
1122
  // upload pads the texture with ZEROS, so a normalised-coordinate
1123
1123
  // gather there would read padding (or mis-scale against the padded
1124
1124
  // size) instead of replicating the last real texel.
1125
- // \u2022 Pass 1 accumulates MOMENTS, not normal-equation sums: \u03A3L, \u03A3L\xB2, \u03A3d and
1126
- // \u03A3L\xB7d per channel, with d = v \u2212 r0 (exact integers) and L the seed
1127
- // level. With b = L/7, t = d\xB77/(r1\u2212r0) = d\xB7s and the level-space
1128
- // residual \u03C1 = t \u2212 L, the residual moments are O(1) per block \u2014
1129
- // \u03A3\u03C1 = s\xB7\u03A3d \u2212 \u03A3L, \u03A3L\u03C1 = s\xB7\u03A3Ld \u2212 \u03A3L\xB2 \u2014 and so is every LSQ sum:
1130
- // sBB = \u03A3L\xB2/49 sAB = \u03A3L/7 \u2212 \u03A3L\xB2/49 sAA = 16 \u2212 2\u03A3L/7 + \u03A3L\xB2/49
1131
- // sBR = \u03A3L\u03C1\xB7dir/49 sAR = (\u03A3\u03C1 \u2212 \u03A3L\u03C1/7)\xB7dir/7
1132
- // \u03A3L \u2264 112 and \u03A3L\xB2 \u2264 784 are exact f16 integers; \u03A3L\xB7d (\u2264 28560) and \u03A3d
1133
- // accumulate in f32 so they stay exact too. The seed covers the data,
1134
- // so t \u2208 [0,7] and pass 1 needs no clamp. The per-BLOCK refit math
1135
- // (solve, E(\u03B4) pricing) runs in f32 \u2014 free at block granularity.
1125
+ // \u2022 SEED: endpoints at the per-channel extremes, but pass-1 levels are
1126
+ // assigned against that range INSET by ~5.5/256 of the span on both
1127
+ // ends (level scale \xD77.3125/7, offset \xBD \u2212 0.15625 = 11/32 \u2014 exact dyadic
1128
+ // constants so every backend folds them identically), so each extreme level
1129
+ // gathers the pixels NEAR the extremes instead of only the extreme
1130
+ // pixel itself \u2014 the refit then lands much closer to the optimum
1131
+ // (an exhaustive search over all endpoint pairs showed the plain
1132
+ // bbox seed leaving 0.7\u20136 dB on the table). Swept 0..20/256; 5.5 wins
1133
+ // under both the /7 spec decode and Apple's hardware decode (below),
1134
+ // and per-block adaptive insets (variance, extreme gaps) all lost.
1135
+ // Spans \u2264 7 (incl. flat blocks) seed a 7-wide window instead, whose
1136
+ // levels land on every integer the block holds: lossless.
1137
+ // \u2022 Pass 1 accumulates MOMENTS \u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d per channel (d = v \u2212 r0,
1138
+ // exact integers). \u03A3L \u2264 112 and \u03A3L\xB2 \u2264 784 are exact f16 integers; \u03A3L\xB7d
1139
+ // (\u2264 28560) and \u03A3d accumulate in f32 so they stay exact too. The seed
1140
+ // covers the data, so t \u2208 [0,7.3125] and L = floor(t + 11/32) \u2208 [0,7] needs
1141
+ // no clamp.
1142
+ // \u2022 REFIT = the least-squares line v \u2248 r0 + \u03B1 + \u03B2\xB7L through those levels,
1143
+ // straight off the moments: \u03B2 = (16\u03A3Ld \u2212 \u03A3L\xB7\u03A3d)/(16\u03A3L\xB2 \u2212 (\u03A3L)\xB2), \u03B1 =
1144
+ // (\u03A3d \u2212 \u03B2\u03A3L)/16 \u2014 ~12 ops per channel. den = 0 \u27FA every pixel on one
1145
+ // level (exact integer test) keeps the seed. Accepted whenever it stays
1146
+ // in 6-interp mode: on the inset partition, pricing it against the seed
1147
+ // (the previous E(\u03B4) closed form) changed nothing, and dropping that
1148
+ // pricing is what pays for the offset round below.
1149
+ // \u2022 Pass 2 derives the shipped levels ONCE, against the refit endpoints \u2014
1150
+ // full reprojection quality \u2014 as a LOOP over quads (the unrolled form
1151
+ // with all eight level vectors live measured ~6% slower once \u03A3L was
1152
+ // added).
1153
+ // \u2022 OFFSET ROUND: both endpoints shift by round(mean residual) of the
1154
+ // shipped levels (\u03A3v is exact from pass 1, so only \u03A3L is new). A
1155
+ // whole-level shift moves every palette entry equally, so the error on
1156
+ // these indices can only drop, under ANY decoder's weights. Buys half
1157
+ // of a full second refit round (+0.05 dB) for ~1/4 of its cost; the
1158
+ // full round (\u03A3L\xB2, \u03A3L\xB7v in pass 2 + a second solve) measured +0.1 dB
1159
+ // more but +18% GPU at 1K \u2014 rejected.
1160
+ // \u2022 Apple GPUs (M3 measured) decode BC4/BC5 with BC7-style 6-bit weights
1161
+ // (0,9,18,27,37,46,55,64)/64, not exact sevenths \u2014 up to \xB10.0067\xB7span
1162
+ // off the spec palette. The encoder targets the spec (/7) palette;
1163
+ // /eval's hardware-decoded PSNR sits ~0.07 dB under the CPU-decoded one.
1136
1164
  // \u2022 Texels live as quad-major vec4<f16> per channel (the gather layout),
1137
1165
  // so min/max reduce as vectors and both passes run 4-wide; edge blocks
1138
1166
  // load into the same layout.
1139
- // \u2022 The rank guard is EXACT: all pixels on one level \u27FA 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2
1140
- // (integers, so the comparison is precise in f32) \u2014 no lmin/lmax
1141
- // tracking in the loop.
1142
- // \u2022 The refit is accepted or rejected CLOSED-FORM, with no trial
1143
- // projection pass: the solve is e = seed + M\u207B\xB9(sAR,sBR), and the error
1144
- // of re-quantised endpoints ON THE CURRENT INDICES is
1145
- // E(\u03B4) = err \u2212 2(\u03B40\xB7sAR + \u03B41\xB7sBR) + \u03B40\xB2sAA + 2\u03B40\u03B41\xB7sAB + \u03B41\xB2sBB
1146
- // with \u03B4 = quantised endpoint \u2212 base endpoint, compared as the delta
1147
- // form E \u2212 err < 0. Both channels run as vec2 lanes, branch-free (one
1148
- // reciprocal of det, select on the accept mask) \u2014 the per-channel
1149
- // function with early returns measured ~2% slower. Only the NEAREST
1150
- // rounding of the fractional solve is priced: pricing all four
1151
- // floor/ceil combinations measured \u22640.015 dB on every content class
1152
- // but ~8% GPU on smooth content.
1153
- // \u2022 Pass 2 derives the shipped levels ONCE, against the FINAL endpoints \u2014
1154
- // full reprojection quality.
1155
1167
  // \u2022 3-bit indices are packed as FLOAT: each group of 8 pixels'
1156
1168
  // levels accumulates as \u03A3 L\xB78^k in f32 (\u2264 2^24 \u2212 1, exact) \u2014 one fma
1157
1169
  // per texel \u2014 and the level \u2192 BC4 index map (0\u21920, 7\u21921, L\u2192L+1) is
1158
1170
  // applied to the whole 24-bit word with SWAR bit tricks.
1159
- // These passes measured \u221222% GPU vs the previous kernel (rg8 source; \u221217%
1160
- // on rgba8, where 2K/4K normal maps sit at the read floor either way) at
1161
- // equal PSNR \u2014 the 1K case is ALU-bound, not read-bound (/eval 2026-09).
1171
+ // The moment/float-packing structure measured \u221222% GPU vs the kernel
1172
+ // before it (rg8 source; 2K/4K normal maps sit at the read floor on rgba8
1173
+ // either way) \u2014 the 1K case is ALU-bound, not read-bound (/eval 2026-09).
1174
+ // The inset seed + regression refit + offset round then lowered MSE by
1175
+ // 3.8\u20139% on every real texture (normals 3.8\u20136.4%, displacement 9\u201360%,
1176
+ // hardware decode) at equal GPU time (/eval 2026-09-24).
1162
1177
  //
1163
1178
  // The host selects this module only when the device reports shader-f16,
1164
1179
  // falling back to bc5.wgsl otherwise.
@@ -1229,26 +1244,28 @@ fn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {
1229
1244
  let vmin = vec2<h>(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));
1230
1245
  let vmax = vec2<h>(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));
1231
1246
 
1232
- // Seed endpoints at the exact per-channel extremes. Flat blocks get
1233
- // nudged apart to keep the 6-interp mode (r0 > r1 strictly).
1234
- var r0 = vec2<u32>(vmax);
1235
- var r1 = vec2<u32>(vmin);
1236
- if (r0.x == r1.x) { if (r1.x > 0u) { r1.x = r1.x - 1u; } else { r0.x = r0.x + 1u; } }
1237
- if (r0.y == r1.y) { if (r1.y > 0u) { r1.y = r1.y - 1u; } else { r0.y = r0.y + 1u; } }
1247
+ // Seed endpoints at the exact per-channel extremes; spans \u2264 7 (incl.
1248
+ // flat blocks) seed a 7-wide window instead, whose levels land on every
1249
+ // integer the block holds \u2014 lossless, and the refit keeps it.
1250
+ let small = vmax - vmin <= vec2<h>(7.0);
1251
+ let r1 = vec2<u32>(select(vmin, min(vmin, vec2<h>(248.0)), small));
1252
+ let r0 = select(vec2<u32>(vmax), r1 + 7u, small);
1238
1253
 
1239
1254
  let r0h = vec2<h>(vec2<f32>(r0));
1240
1255
  let dirf = vec2<f32>(r1) - vec2<f32>(r0);
1241
- let scale = vec2<h>(vec2<f32>(7.0) / dirf);
1256
+ // Pass-1 levels come from the seed range INSET by ~5.5/256 of the span
1257
+ // on both ends (see header).
1258
+ let scale = vec2<h>(vec2<f32>(7.3125) / dirf);
1242
1259
 
1243
- // Pass 1 \u2014 t = d\xB7scale \u2208 [0,7] by construction (seed covers the data),
1244
- // so L = floor(t + \xBD) needs no clamp.
1260
+ // Pass 1 \u2014 t = d\xB7scale \u2208 [0,7.3125] by construction (seed covers the
1261
+ // data), so L = floor(t + 11/32) \u2208 [0,7] needs no clamp.
1245
1262
  var sLr = h(0.0); var sLLr = h(0.0); var sdr = 0.0; var sLdr = 0.0;
1246
1263
  var sLg = h(0.0); var sLLg = h(0.0); var sdg = 0.0; var sLdg = 0.0;
1247
1264
  for (var q: u32 = 0u; q < 4u; q = q + 1u) {
1248
1265
  let dr = vr[q] - r0h.x;
1249
1266
  let dg = vg[q] - r0h.y;
1250
- let Lr = floor(dr * scale.x + h(0.5));
1251
- let Lg = floor(dg * scale.y + h(0.5));
1267
+ let Lr = floor(dr * scale.x + h(0.34375));
1268
+ let Lg = floor(dg * scale.y + h(0.34375));
1252
1269
  sLr = sLr + dot(Lr, h4(1.0)); sLLr = sLLr + dot(Lr, Lr);
1253
1270
  sLg = sLg + dot(Lg, h4(1.0)); sLLg = sLLg + dot(Lg, Lg);
1254
1271
  sdr = sdr + f32(dot(dr, h4(1.0)));
@@ -1257,32 +1274,18 @@ fn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {
1257
1274
  sLdg = sLdg + dot(vec4<f32>(Lg), vec4<f32>(dg));
1258
1275
  }
1259
1276
 
1260
- // Per-block refit in f32 off the moments (see header for the identities).
1277
+ // Per-block refit in f32: least-squares line v \u2248 r0 + \u03B1 + \u03B2\xB7L through
1278
+ // the pass-1 levels, straight off the (exact-integer) moments. den = 0
1279
+ // \u27FA every pixel on one level (rank-deficient) \u2014 keep the seed then.
1261
1280
  let sLf = vec2<f32>(f32(sLr), f32(sLg));
1262
1281
  let sLLf = vec2<f32>(f32(sLLr), f32(sLLg));
1263
- let s32 = vec2<f32>(7.0) / dirf;
1264
- let pR = s32 * vec2<f32>(sdr, sdg) - sLf;
1265
- let pLR = s32 * vec2<f32>(sLdr, sLdg) - sLLf;
1266
- let sBB = sLLf * (1.0 / 49.0);
1267
- let sAB = sLf * (1.0 / 7.0) - sBB;
1268
- let sAA = vec2<f32>(16.0) - 2.0 * sLf * (1.0 / 7.0) + sBB;
1269
- let sBR = pLR * dirf * (1.0 / 49.0);
1270
- let sAR = (pR - pLR * (1.0 / 7.0)) * dirf * (1.0 / 7.0);
1271
- let spread = 16.0 * sLLf != sLf * sLf;
1272
-
1273
- // Both channels at once, branch-free: nearest rounding of the LSQ
1274
- // solve, accepted when it stays in 6-interp mode, moves, and prices
1275
- // strictly better on the current indices.
1276
- let r0f = vec2<f32>(r0);
1277
- let r1f = vec2<f32>(r1);
1278
- let det = sAA * sBB - sAB * sAB;
1279
- let idet = 1.0 / det;
1280
- let q0f = floor(clamp(r0f + (sBB * sAR - sAB * sBR) * idet, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);
1281
- let q1f = floor(clamp(r1f + (sAA * sBR - sAB * sAR) * idet, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);
1282
- let dd0 = q0f - r0f;
1283
- let dd1 = q1f - r1f;
1284
- let eNew = -2.0 * (dd0 * sAR + dd1 * sBR) + dd0 * dd0 * sAA + 2.0 * dd0 * dd1 * sAB + dd1 * dd1 * sBB;
1285
- let acc = spread & (abs(det) > vec2<f32>(1e-3)) & (q0f > q1f) & (eNew < vec2<f32>(0.0));
1282
+ let sdf = vec2<f32>(sdr, sdg);
1283
+ let den = 16.0 * sLLf - sLf * sLf;
1284
+ let beta = (16.0 * vec2<f32>(sLdr, sLdg) - sLf * sdf) / den;
1285
+ let e0 = vec2<f32>(r0) + (sdf - beta * sLf) * (1.0 / 16.0);
1286
+ let q0f = floor(clamp(e0, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);
1287
+ let q1f = floor(clamp(e0 + 7.0 * beta, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);
1288
+ let acc = (den > vec2<f32>(0.0)) & (q0f > q1f);
1286
1289
  let n0 = select(r0, vec2<u32>(q0f), acc);
1287
1290
  let n1 = select(r1, vec2<u32>(q1f), acc);
1288
1291
 
@@ -1291,24 +1294,34 @@ fn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {
1291
1294
  // (\u03A3 L\xB78^k \u2264 2^24 \u2212 1, exact): iA = pixels 0..7, iB = pixels 8..15.
1292
1295
  let n0h = vec2<h>(vec2<f32>(n0));
1293
1296
  let sc2 = vec2<h>(vec2<f32>(7.0) / (vec2<f32>(n1) - vec2<f32>(n0)));
1294
- let L0r = clamp(floor((vr[0] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1295
- let L1r = clamp(floor((vr[1] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1296
- let L2r = clamp(floor((vr[2] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1297
- let L3r = clamp(floor((vr[3] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1298
- let L0g = clamp(floor((vg[0] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1299
- let L1g = clamp(floor((vg[1] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1300
- let L2g = clamp(floor((vg[2] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1301
- let L3g = clamp(floor((vg[3] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1302
- let iAx = lvl_to_idx(u32(dot(vec4<f32>(L0r), W0) + dot(vec4<f32>(L1r), W1)));
1303
- let iBx = lvl_to_idx(u32(dot(vec4<f32>(L2r), W0) + dot(vec4<f32>(L3r), W1)));
1304
- let iAy = lvl_to_idx(u32(dot(vec4<f32>(L0g), W0) + dot(vec4<f32>(L1g), W1)));
1305
- let iBy = lvl_to_idx(u32(dot(vec4<f32>(L2g), W0) + dot(vec4<f32>(L3g), W1)));
1297
+ var pk = vec4<f32>(0.0); // (Ax, Bx, Ay, By) level words
1298
+ var sLq = vec2<h>(0.0);
1299
+ for (var q: u32 = 0u; q < 4u; q = q + 1u) {
1300
+ let Lr = clamp(floor((vr[q] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1301
+ let Lg = clamp(floor((vg[q] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1302
+ let w = select(W0, W1, (q & 1u) == 1u);
1303
+ let hi = q >= 2u;
1304
+ let pr = dot(vec4<f32>(Lr), w);
1305
+ let pg = dot(vec4<f32>(Lg), w);
1306
+ pk = pk + vec4<f32>(select(pr, 0.0, hi), select(0.0, pr, hi), select(pg, 0.0, hi), select(0.0, pg, hi));
1307
+ sLq = sLq + vec2<h>(dot(Lr, h4(1.0)), dot(Lg, h4(1.0)));
1308
+ }
1309
+ let n0f = vec2<f32>(n0);
1310
+ let sL2 = vec2<f32>(sLq);
1311
+ let res = sdf + 16.0 * (vec2<f32>(r0) - n0f) - (vec2<f32>(n1) - n0f) * sL2 * (1.0 / 7.0);
1312
+ let sh = clamp(floor(res * (1.0 / 16.0) + 0.5), -vec2<f32>(n1), vec2<f32>(255.0) - n0f);
1313
+ let m0 = vec2<u32>(n0f + sh);
1314
+ let m1 = vec2<u32>(vec2<f32>(n1) + sh);
1315
+ let iAx = lvl_to_idx(u32(pk.x));
1316
+ let iBx = lvl_to_idx(u32(pk.y));
1317
+ let iAy = lvl_to_idx(u32(pk.z));
1318
+ let iBy = lvl_to_idx(u32(pk.w));
1306
1319
 
1307
1320
  // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.
1308
1321
  let o = bi * 4u;
1309
- dst[o] = n0.x | (n1.x << 8u) | (iAx << 16u);
1322
+ dst[o] = m0.x | (m1.x << 8u) | (iAx << 16u);
1310
1323
  dst[o + 1u] = (iAx >> 16u) | (iBx << 8u);
1311
- dst[o + 2u] = n0.y | (n1.y << 8u) | (iAy << 16u);
1324
+ dst[o + 2u] = m0.y | (m1.y << 8u) | (iAy << 16u);
1312
1325
  dst[o + 3u] = (iAy >> 16u) | (iBy << 8u);
1313
1326
  }
1314
1327
  `;
@@ -2186,7 +2199,7 @@ var BC1WebGLEncoder = class extends WebGLBlockEncoder {
2186
2199
  };
2187
2200
 
2188
2201
  // src/webgl/glsl/bc5.frag.glsl
2189
- var bc5_frag_default = "#version 300 es\n// BC5 (RGTC2) fragment-shader encoder \u2014 WebGL2 port of bc5.wgsl.\n//\n// One fragment per 4\xD74 block \u2192 16-byte BC5 block as 4 \xD7 u32 in outColor.\n// BC5 = two BC4 halves (R then G), both channels processed together. Same\n// algorithm and arithmetic order as bc5.wgsl (see bc5_fast_f16.wgsl for the\n// full notes and measurements):\n// \u2022 texels held as quad-major vec4s per channel, in textureGather order\n// (x=(0,1) y=(1,1) z=(1,0) w=(0,0) within each 2\xD72 quad);\n// \u2022 seed endpoints at the per-channel extremes; pass 1 accumulates the\n// MOMENTS \u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d (d = v \u2212 r0, L = the seed level \u2014 the seed\n// covers the data, so no clamp), from which every least-squares sum is\n// O(1) per block; exact rank guard 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2;\n// \u2022 one closed-form refit accepted when it prices better on the seed\n// levels (nearest rounding of the solve, 6-interp mode kept);\n// \u2022 pass 2 derives the shipped levels against the FINAL endpoints, packed\n// as float fields \u03A3 L\xB78^k (exact below 2^24), then one SWAR level \u2192 BC4\n// index map (0\u21920, 7\u21921, L\u2192L+1) per 24-bit word.\n// Always emits 6-interpolation mode (red0 > red1).\n\nprecision highp float;\nprecision highp int;\n\nuniform sampler2D uSrc;\nuniform ivec2 uSrcSize;\nuniform int uFlipY;\n\nlayout(location = 0) out uvec4 outColor;\n\n// 8 packed 3-bit levels \u2192 BC4 indices (0\u21920, 7\u21921, L\u2192L+1 otherwise).\nuint lvlToIdx(uint x) {\n uint y = ((x & 0x6DB6DBu) + 0x249249u) ^ (x & 0x924924u);\n return y ^ (~((y >> 1u) | (y >> 2u)) & 0x249249u);\n}\n\n// Per-quad pixel weights 8^k (gather order x,y,z,w = (0,1),(1,1),(1,0),(0,0)).\nconst vec4 W0 = vec4(4096.0, 32768.0, 8.0, 1.0);\nconst vec4 W1 = vec4(262144.0, 2097152.0, 512.0, 64.0);\n\nvec4 fetchRG(ivec2 p, ivec2 maxXY) {\n ivec2 c = clamp(p, ivec2(0), maxXY);\n int sy = (uFlipY != 0) ? (uSrcSize.y - 1 - c.y) : c.y;\n return texelFetch(uSrc, ivec2(c.x, sy), 0);\n}\n\n// Closed-form accept-if-better refit for one channel off the pass-1\n// moments; returns the final (r0, r1).\nuvec2 refit(float sL, float sLL, float sd, float sLd, uint r0, uint r1) {\n float r0f = float(r0);\n float r1f = float(r1);\n float dir = r1f - r0f;\n float scale = 7.0 / dir;\n // \u03A3\u03C1 = s\xB7\u03A3d \u2212 \u03A3L, \u03A3L\u03C1 = s\xB7\u03A3Ld \u2212 \u03A3L\xB2 (\u03C1 = level-space residual).\n float pR = scale * sd - sL;\n float pLR = scale * sLd - sLL;\n float sBB = sLL * (1.0 / 49.0);\n float sAB = sL * (1.0 / 7.0) - sBB;\n float sAA = 16.0 - 2.0 * sL * (1.0 / 7.0) + sBB;\n float sBR = pLR * dir * (1.0 / 49.0);\n float sAR = (pR - pLR * (1.0 / 7.0)) * dir * (1.0 / 7.0);\n bool spread = 16.0 * sLL != sL * sL;\n float det = sAA * sBB - sAB * sAB;\n float idet = 1.0 / det;\n // Endpoints clamp to [0,255], NOT the block's value range: for a scalar\n // channel, endpoints beyond the data range are often genuinely optimal.\n float q0f = floor(clamp(r0f + (sBB * sAR - sAB * sBR) * idet, 0.0, 255.0) + 0.5);\n float q1f = floor(clamp(r1f + (sAA * sBR - sAB * sAR) * idet, 0.0, 255.0) + 0.5);\n float dd0 = q0f - r0f;\n float dd1 = q1f - r1f;\n float eNew = -2.0 * (dd0 * sAR + dd1 * sBR) + dd0 * dd0 * sAA + 2.0 * dd0 * dd1 * sAB + dd1 * dd1 * sBB;\n bool acc = spread && abs(det) > 1e-3 && q0f > q1f && eNew < 0.0;\n return acc ? uvec2(uint(q0f), uint(q1f)) : uvec2(r0, r1);\n}\n\nvoid main() {\n ivec2 base = ivec2(gl_FragCoord.xy) * 4;\n ivec2 maxXY = uSrcSize - ivec2(1);\n\n vec4 vr[4];\n vec4 vg[4];\n for (int q = 0; q < 4; q++) {\n ivec2 qo = base + ivec2((q & 1) * 2, (q >> 1) * 2);\n vec4 cx = fetchRG(qo + ivec2(0, 1), maxXY);\n vec4 cy = fetchRG(qo + ivec2(1, 1), maxXY);\n vec4 cz = fetchRG(qo + ivec2(1, 0), maxXY);\n vec4 cw = fetchRG(qo, maxXY);\n vr[q] = vec4(cx.r, cy.r, cz.r, cw.r) * 255.0;\n vg[q] = vec4(cx.g, cy.g, cz.g, cw.g) * 255.0;\n }\n vec4 mnr = min(min(vr[0], vr[1]), min(vr[2], vr[3]));\n vec4 mxr = max(max(vr[0], vr[1]), max(vr[2], vr[3]));\n vec4 mng = min(min(vg[0], vg[1]), min(vg[2], vg[3]));\n vec4 mxg = max(max(vg[0], vg[1]), max(vg[2], vg[3]));\n vec2 vmin = vec2(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));\n vec2 vmax = vec2(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));\n\n // Seed endpoints at the exact per-channel extremes (round-to-nearest).\n // Flat blocks get nudged apart to keep the 6-interp mode (r0 > r1).\n uvec2 r0 = uvec2(clamp(floor(vmax + 0.5), vec2(0.0), vec2(255.0)));\n uvec2 r1 = uvec2(clamp(floor(vmin + 0.5), vec2(0.0), vec2(255.0)));\n if (r0.x == r1.x) { if (r1.x > 0u) { r1.x = r1.x - 1u; } else { r0.x = r0.x + 1u; } }\n if (r0.y == r1.y) { if (r1.y > 0u) { r1.y = r1.y - 1u; } else { r0.y = r0.y + 1u; } }\n\n vec2 r0f = vec2(r0);\n vec2 scale = vec2(7.0) / (vec2(r1) - r0f);\n\n // Pass 1 \u2014 moments only.\n vec2 sL = vec2(0.0);\n vec2 sLL = vec2(0.0);\n vec2 sd = vec2(0.0);\n vec2 sLd = vec2(0.0);\n for (int q = 0; q < 4; q++) {\n vec4 dr = vr[q] - r0f.x;\n vec4 dg = vg[q] - r0f.y;\n vec4 Lr = floor(dr * scale.x + 0.5);\n vec4 Lg = floor(dg * scale.y + 0.5);\n sL += vec2(dot(Lr, vec4(1.0)), dot(Lg, vec4(1.0)));\n sLL += vec2(dot(Lr, Lr), dot(Lg, Lg));\n sd += vec2(dot(dr, vec4(1.0)), dot(dg, vec4(1.0)));\n sLd += vec2(dot(Lr, dr), dot(Lg, dg));\n }\n\n uvec2 fr = refit(sL.x, sLL.x, sd.x, sLd.x, r0.x, r1.x);\n uvec2 fg = refit(sL.y, sLL.y, sd.y, sLd.y, r0.y, r1.y);\n uvec2 n0 = uvec2(fr.x, fg.x);\n uvec2 n1 = uvec2(fr.y, fg.y);\n\n // Pass 2 \u2014 levels against the FINAL endpoints, packed as \u03A3 L\xB78^k:\n // iA = pixels 0..7, iB = pixels 8..15.\n vec2 n0f = vec2(n0);\n vec2 sc2 = vec2(7.0) / (vec2(n1) - n0f);\n vec4 Lr[4];\n vec4 Lg[4];\n for (int q = 0; q < 4; q++) {\n Lr[q] = clamp(floor((vr[q] - n0f.x) * sc2.x + 0.5), vec4(0.0), vec4(7.0));\n Lg[q] = clamp(floor((vg[q] - n0f.y) * sc2.y + 0.5), vec4(0.0), vec4(7.0));\n }\n uint iAx = lvlToIdx(uint(dot(Lr[0], W0) + dot(Lr[1], W1)));\n uint iBx = lvlToIdx(uint(dot(Lr[2], W0) + dot(Lr[3], W1)));\n uint iAy = lvlToIdx(uint(dot(Lg[0], W0) + dot(Lg[1], W1)));\n uint iBy = lvlToIdx(uint(dot(Lg[2], W0) + dot(Lg[3], W1)));\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n outColor = uvec4(\n n0.x | (n1.x << 8u) | (iAx << 16u),\n (iAx >> 16u) | (iBx << 8u),\n n0.y | (n1.y << 8u) | (iAy << 16u),\n (iAy >> 16u) | (iBy << 8u)\n );\n}\n";
2202
+ var bc5_frag_default = "#version 300 es\n// BC5 (RGTC2) fragment-shader encoder \u2014 WebGL2 port of bc5.wgsl.\n//\n// One fragment per 4\xD74 block \u2192 16-byte BC5 block as 4 \xD7 u32 in outColor.\n// BC5 = two BC4 halves (R then G), both channels processed together. Same\n// algorithm and arithmetic order as bc5.wgsl (see bc5_fast_f16.wgsl for the\n// full notes and measurements):\n// \u2022 texels held as quad-major vec4s per channel, in textureGather order\n// (x=(0,1) y=(1,1) z=(1,0) w=(0,0) within each 2\xD72 quad);\n// \u2022 seed endpoints at the per-channel extremes (spans \u2264 7: a 7-wide\n// window, lossless); pass-1 levels come from the seed range inset by\n// ~5.5/256 of the span (scale \xD77.3125/7, offset 11/32), accumulating\n// the MOMENTS \u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d (d = v \u2212 r0; the seed covers the\n// data, so no clamp);\n// \u2022 one least-squares line through the pass-1 levels straight off the\n// moments (den = 16\u03A3L\xB2 \u2212 (\u03A3L)\xB2 = 0 keeps the seed);\n// \u2022 pass 2 derives the shipped levels against the refit endpoints,\n// packed as float fields \u03A3 L\xB78^k (exact below 2^24), then one SWAR\n// level \u2192 BC4 index map (0\u21920, 7\u21921, L\u2192L+1) per 24-bit word;\n// \u2022 offset round: both endpoints shift by the rounded mean residual of\n// the shipped levels (never worse on those indices).\n// Always emits 6-interpolation mode (red0 > red1).\n\nprecision highp float;\nprecision highp int;\n\nuniform sampler2D uSrc;\nuniform ivec2 uSrcSize;\nuniform int uFlipY;\n\nlayout(location = 0) out uvec4 outColor;\n\n// 8 packed 3-bit levels \u2192 BC4 indices (0\u21920, 7\u21921, L\u2192L+1 otherwise).\nuint lvlToIdx(uint x) {\n uint y = ((x & 0x6DB6DBu) + 0x249249u) ^ (x & 0x924924u);\n return y ^ (~((y >> 1u) | (y >> 2u)) & 0x249249u);\n}\n\n// Per-quad pixel weights 8^k (gather order x,y,z,w = (0,1),(1,1),(1,0),(0,0)).\nconst vec4 W0 = vec4(4096.0, 32768.0, 8.0, 1.0);\nconst vec4 W1 = vec4(262144.0, 2097152.0, 512.0, 64.0);\n\nvec4 fetchRG(ivec2 p, ivec2 maxXY) {\n ivec2 c = clamp(p, ivec2(0), maxXY);\n int sy = (uFlipY != 0) ? (uSrcSize.y - 1 - c.y) : c.y;\n return texelFetch(uSrc, ivec2(c.x, sy), 0);\n}\n\nvoid main() {\n ivec2 base = ivec2(gl_FragCoord.xy) * 4;\n ivec2 maxXY = uSrcSize - ivec2(1);\n\n vec4 vr[4];\n vec4 vg[4];\n for (int q = 0; q < 4; q++) {\n ivec2 qo = base + ivec2((q & 1) * 2, (q >> 1) * 2);\n vec4 cx = fetchRG(qo + ivec2(0, 1), maxXY);\n vec4 cy = fetchRG(qo + ivec2(1, 1), maxXY);\n vec4 cz = fetchRG(qo + ivec2(1, 0), maxXY);\n vec4 cw = fetchRG(qo, maxXY);\n vr[q] = vec4(cx.r, cy.r, cz.r, cw.r) * 255.0;\n vg[q] = vec4(cx.g, cy.g, cz.g, cw.g) * 255.0;\n }\n vec4 mnr = min(min(vr[0], vr[1]), min(vr[2], vr[3]));\n vec4 mxr = max(max(vr[0], vr[1]), max(vr[2], vr[3]));\n vec4 mng = min(min(vg[0], vg[1]), min(vg[2], vg[3]));\n vec4 mxg = max(max(vg[0], vg[1]), max(vg[2], vg[3]));\n vec2 vmin = vec2(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));\n vec2 vmax = vec2(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));\n\n // Seed endpoints at the per-channel extremes (round-to-nearest); spans\n // \u2264 7 (incl. flat blocks) seed a 7-wide window instead \u2014 lossless.\n vec2 vhi = clamp(floor(vmax + 0.5), vec2(0.0), vec2(255.0));\n vec2 vlo = clamp(floor(vmin + 0.5), vec2(0.0), vec2(255.0));\n bvec2 small = lessThanEqual(vhi - vlo, vec2(7.0));\n vec2 r1f = vec2(small.x ? min(vlo.x, 248.0) : vlo.x, small.y ? min(vlo.y, 248.0) : vlo.y);\n vec2 r0f = vec2(small.x ? r1f.x + 7.0 : vhi.x, small.y ? r1f.y + 7.0 : vhi.y);\n // Pass-1 levels from the seed range inset by ~5.5/256 of the span.\n vec2 scale = vec2(7.3125) / (r1f - r0f);\n\n // Pass 1 \u2014 moments only.\n vec2 sL = vec2(0.0);\n vec2 sLL = vec2(0.0);\n vec2 sd = vec2(0.0);\n vec2 sLd = vec2(0.0);\n for (int q = 0; q < 4; q++) {\n vec4 dr = vr[q] - r0f.x;\n vec4 dg = vg[q] - r0f.y;\n vec4 Lr = floor(dr * scale.x + 0.34375);\n vec4 Lg = floor(dg * scale.y + 0.34375);\n sL += vec2(dot(Lr, vec4(1.0)), dot(Lg, vec4(1.0)));\n sLL += vec2(dot(Lr, Lr), dot(Lg, Lg));\n sd += vec2(dot(dr, vec4(1.0)), dot(dg, vec4(1.0)));\n sLd += vec2(dot(Lr, dr), dot(Lg, dg));\n }\n\n // Refit: least-squares line v \u2248 r0 + \u03B1 + \u03B2\xB7L through the pass-1 levels.\n // Endpoints clamp to [0,255], NOT the block's value range.\n vec2 den = 16.0 * sLL - sL * sL;\n vec2 beta = (16.0 * sLd - sL * sd) / den;\n vec2 e0 = r0f + (sd - beta * sL) * (1.0 / 16.0);\n vec2 q0f = floor(clamp(e0, vec2(0.0), vec2(255.0)) + 0.5);\n vec2 q1f = floor(clamp(e0 + 7.0 * beta, vec2(0.0), vec2(255.0)) + 0.5);\n bvec2 acc = bvec2(den.x > 0.0 && q0f.x > q1f.x, den.y > 0.0 && q0f.y > q1f.y);\n vec2 n0f = vec2(acc.x ? q0f.x : r0f.x, acc.y ? q0f.y : r0f.y);\n vec2 n1f = vec2(acc.x ? q1f.x : r1f.x, acc.y ? q1f.y : r1f.y);\n\n // Pass 2 \u2014 levels against the refit endpoints, packed as \u03A3 L\xB78^k\n // (A = pixels 0..7, B = pixels 8..15), plus \u03A3L for the offset round.\n vec2 sc2 = vec2(7.0) / (n1f - n0f);\n vec4 pk = vec4(0.0); // (Ax, Bx, Ay, By)\n vec2 sL2 = vec2(0.0);\n for (int q = 0; q < 4; q++) {\n vec4 Lr = clamp(floor((vr[q] - n0f.x) * sc2.x + 0.5), vec4(0.0), vec4(7.0));\n vec4 Lg = clamp(floor((vg[q] - n0f.y) * sc2.y + 0.5), vec4(0.0), vec4(7.0));\n vec4 w = (q & 1) == 1 ? W1 : W0;\n bool hi = q >= 2;\n float pr = dot(Lr, w);\n float pg = dot(Lg, w);\n pk += vec4(hi ? 0.0 : pr, hi ? pr : 0.0, hi ? 0.0 : pg, hi ? pg : 0.0);\n sL2 += vec2(dot(Lr, vec4(1.0)), dot(Lg, vec4(1.0)));\n }\n\n // Offset round: shift both endpoints by the rounded mean residual of the\n // shipped levels.\n vec2 res = sd + 16.0 * (r0f - n0f) - (n1f - n0f) * sL2 * (1.0 / 7.0);\n vec2 sh = clamp(floor(res * (1.0 / 16.0) + 0.5), -n1f, vec2(255.0) - n0f);\n uvec2 m0 = uvec2(n0f + sh);\n uvec2 m1 = uvec2(n1f + sh);\n uint iAx = lvlToIdx(uint(pk.x));\n uint iBx = lvlToIdx(uint(pk.y));\n uint iAy = lvlToIdx(uint(pk.z));\n uint iBy = lvlToIdx(uint(pk.w));\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n outColor = uvec4(\n m0.x | (m1.x << 8u) | (iAx << 16u),\n (iAx >> 16u) | (iBx << 8u),\n m0.y | (m1.y << 8u) | (iAy << 16u),\n (iAy >> 16u) | (iBy << 8u)\n );\n}\n";
2190
2203
 
2191
2204
  // src/webgl/BC5WebGLEncoder.ts
2192
2205
  var BC5WebGLEncoder = class extends WebGLBlockEncoder {
package/dist/three.js CHANGED
@@ -1098,7 +1098,7 @@ var BC1Encoder = class extends Encoder {
1098
1098
  };
1099
1099
 
1100
1100
  // src/bc5.wgsl
1101
- var bc5_default = "// BC5 (RGTC2) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into a 16-byte BC5 block\n// written as 4 x u32 into the destination storage buffer. This is the f32\n// fallback; bc5_fast_f16.wgsl is the same algorithm and is preferred when\n// the device reports shader-f16.\n//\n// BC5 = two BC4 blocks concatenated:\n// block bytes 0..7 : BC4 of R channel (normal.x for tangent-space normals)\n// block bytes 8..15 : BC4 of G channel (normal.y)\n//\n// Each BC4 half-block (8 bytes):\n// byte 0 : red0 (8-bit endpoint)\n// byte 1 : red1 (8-bit endpoint)\n// bytes 2..7 : 16 \xD7 3-bit indices, LSB-first, pixel 0 at bit 0\n//\n// We always produce the 6-interpolation mode (red0 > red1). See `bc4_ref.ts`\n// for the reference this encoder is validated against.\n//\n// Same algorithm as bc5_fast_f16.wgsl (see that file for the full notes):\n// \u2022 both channels processed in the same fused passes, texels held as\n// quad-major vec4s per channel (the gather layout);\n// \u2022 pass 1 accumulates MOMENTS (\u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d with d = v \u2212 r0) from\n// which every LSQ normal-equation sum is an O(1) per-block expression;\n// the seed covers the data, so pass 1 needs no clamp; the rank guard\n// is the exact 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2 test;\n// \u2022 the closed-form refit prices the nearest rounding of the solve\n// through E(\u03B4) = err \u2212 2(\u03B40\xB7sAR + \u03B41\xB7sBR) + \u03B40\xB2sAA + 2\u03B40\u03B41\xB7sAB\n// + \u03B41\xB2sBB, accept-if-better, both channels as branch-free vec2 lanes;\n// \u2022 pass 2 derives the levels ONCE, against the FINAL endpoints \u2014 full\n// reprojection quality;\n// \u2022 the 16 texel reads are 8 textureGather fetches (4 quads \xD7 R,G)\n// through a clamp-to-edge sampler, byte-identical to per-texel loads;\n// \u2022 3-bit levels pack as \u03A3 L\xB78^k in f32 (exact below 2^24), then one\n// SWAR level \u2192 BC4 index map (0\u21920, 7\u21921, L\u2192L+1) per 24-bit word.\n// Values are kept in the [0,255] f32 domain throughout.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n@group(0) @binding(3) var smp: sampler;\n\n// 8 packed 3-bit levels \u2192 BC4 indices (0\u21920, 7\u21921, L\u2192L+1 otherwise).\nfn lvl_to_idx(x: u32) -> u32 {\n let y = ((x & 0x6DB6DBu) + 0x249249u) ^ (x & 0x924924u);\n return y ^ (~((y >> 1u) | (y >> 2u)) & 0x249249u);\n}\n\n// Per-quad pixel weights 8^k (gather order x,y,z,w = (0,1),(1,1),(1,0),(0,0)).\nconst W0 = vec4<f32>(4096.0, 32768.0, 8.0, 1.0);\nconst W1 = vec4<f32>(262144.0, 2097152.0, 512.0, 64.0);\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n\n // Load 4\xD74 R and G as quad-major vec4s in gather order (w=(0,0) z=(1,0)\n // x=(0,1) y=(1,1) of quad q = (x \u2265 2) + 2\xB7(y \u2265 2)). Interior blocks read\n // via 8 gathers normalised by the PHYSICAL (padded) texture size; blocks\n // straddling the source edge of a non-multiple-of-4 image fall back to\n // per-texel loads clamped to the last real texel (the padding strip is\n // zero-initialised \u2014 see bc5_fast_f16.wgsl).\n var vr: array<vec4<f32>, 4>;\n var vg: array<vec4<f32>, 4>;\n if (u32(base.x) + 4u <= params.width && u32(base.y) + 4u <= params.height) {\n let inv_size = vec2<f32>(1.0, 1.0) / vec2<f32>(textureDimensions(src_tex));\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = vec2<u32>((q & 1u) * 2u, (q >> 1u) * 2u);\n let cc = (vec2<f32>(base) + vec2<f32>(qo) + vec2<f32>(1.0, 1.0)) * inv_size;\n vr[q] = textureGather(0, src_tex, smp, cc) * 255.0;\n vg[q] = textureGather(1, src_tex, smp, cc) * 255.0;\n }\n } else {\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = base + vec2<i32>(i32(q & 1u) * 2, i32(q >> 1u) * 2);\n let cx = textureLoad(src_tex, clamp(qo + vec2<i32>(0, 1), vec2<i32>(0), mx), 0);\n let cy = textureLoad(src_tex, clamp(qo + vec2<i32>(1, 1), vec2<i32>(0), mx), 0);\n let cz = textureLoad(src_tex, clamp(qo + vec2<i32>(1, 0), vec2<i32>(0), mx), 0);\n let cw = textureLoad(src_tex, clamp(qo, vec2<i32>(0), mx), 0);\n vr[q] = vec4<f32>(cx.r, cy.r, cz.r, cw.r) * 255.0;\n vg[q] = vec4<f32>(cx.g, cy.g, cz.g, cw.g) * 255.0;\n }\n }\n let mnr = min(min(vr[0], vr[1]), min(vr[2], vr[3]));\n let mxr = max(max(vr[0], vr[1]), max(vr[2], vr[3]));\n let mng = min(min(vg[0], vg[1]), min(vg[2], vg[3]));\n let mxg = max(max(vg[0], vg[1]), max(vg[2], vg[3]));\n let vmin = vec2<f32>(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));\n let vmax = vec2<f32>(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));\n\n // Seed endpoints at the exact per-channel extremes (round-to-nearest, the\n // same rule the CPU reference uses). Flat blocks get nudged apart to keep\n // the 6-interp mode (r0 > r1 strictly).\n var r0 = vec2<u32>(clamp(floor(vmax + 0.5), vec2<f32>(0.0), vec2<f32>(255.0)));\n var r1 = vec2<u32>(clamp(floor(vmin + 0.5), vec2<f32>(0.0), vec2<f32>(255.0)));\n if (r0.x == r1.x) { if (r1.x > 0u) { r1.x = r1.x - 1u; } else { r0.x = r0.x + 1u; } }\n if (r0.y == r1.y) { if (r1.y > 0u) { r1.y = r1.y - 1u; } else { r0.y = r0.y + 1u; } }\n\n let r0f = vec2<f32>(r0);\n let r1f = vec2<f32>(r1);\n let dir = r1f - r0f;\n let scale = vec2<f32>(7.0) / dir;\n\n // Pass 1, both channels \u2014 MOMENTS only. t = d\xB7scale \u2208 [0,7] (the seed\n // covers the data, so no clamp), L = round(t).\n var sL = vec2<f32>(0.0); var sLL = vec2<f32>(0.0);\n var sd = vec2<f32>(0.0); var sLd = vec2<f32>(0.0);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let dr = vr[q] - r0f.x;\n let dg = vg[q] - r0f.y;\n let Lr = floor(dr * scale.x + 0.5);\n let Lg = floor(dg * scale.y + 0.5);\n sL = sL + vec2<f32>(dot(Lr, vec4<f32>(1.0)), dot(Lg, vec4<f32>(1.0)));\n sLL = sLL + vec2<f32>(dot(Lr, Lr), dot(Lg, Lg));\n sd = sd + vec2<f32>(dot(dr, vec4<f32>(1.0)), dot(dg, vec4<f32>(1.0)));\n sLd = sLd + vec2<f32>(dot(Lr, dr), dot(Lg, dg));\n }\n\n // Per-block refit off the moments (see bc5_fast_f16.wgsl for the\n // identities): \u03A3\u03C1 = s\xB7\u03A3d \u2212 \u03A3L, \u03A3L\u03C1 = s\xB7\u03A3Ld \u2212 \u03A3L\xB2.\n let pR = scale * sd - sL;\n let pLR = scale * sLd - sLL;\n let sBB = sLL * (1.0 / 49.0);\n let sAB = sL * (1.0 / 7.0) - sBB;\n let sAA = vec2<f32>(16.0) - 2.0 * sL * (1.0 / 7.0) + sBB;\n let sBR = pLR * dir * (1.0 / 49.0);\n let sAR = (pR - pLR * (1.0 / 7.0)) * dir * (1.0 / 7.0);\n let spread = 16.0 * sLL != sL * sL;\n\n // Both channels at once, branch-free: nearest rounding of the LSQ\n // solve, accepted when it stays in 6-interp mode, moves, and prices\n // strictly better on the current indices. Endpoints clamp to [0,255],\n // NOT the block's value range: for a scalar channel, endpoints beyond\n // the data range are often genuinely optimal and there is no colour\n // axis to bend.\n let det = sAA * sBB - sAB * sAB;\n let idet = 1.0 / det;\n let q0f = floor(clamp(r0f + (sBB * sAR - sAB * sBR) * idet, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);\n let q1f = floor(clamp(r1f + (sAA * sBR - sAB * sAR) * idet, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);\n let dd0 = q0f - r0f;\n let dd1 = q1f - r1f;\n let eNew = -2.0 * (dd0 * sAR + dd1 * sBR) + dd0 * dd0 * sAA + 2.0 * dd0 * dd1 * sAB + dd1 * dd1 * sBB;\n let acc = spread & (abs(det) > vec2<f32>(1e-3)) & (q0f > q1f) & (eNew < vec2<f32>(0.0));\n let n0 = select(r0, vec2<u32>(q0f), acc);\n let n1 = select(r1, vec2<u32>(q1f), acc);\n\n // Pass 2, both channels \u2014 levels against the FINAL endpoints (rejected\n // channels re-derive their seed assignment), packed as \u03A3 L\xB78^k:\n // iA = pixels 0..7, iB = pixels 8..15.\n let n0f = vec2<f32>(n0);\n let sc2 = vec2<f32>(7.0) / (vec2<f32>(n1) - n0f);\n var Lr: array<vec4<f32>, 4>;\n var Lg: array<vec4<f32>, 4>;\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n Lr[q] = clamp(floor((vr[q] - n0f.x) * sc2.x + 0.5), vec4<f32>(0.0), vec4<f32>(7.0));\n Lg[q] = clamp(floor((vg[q] - n0f.y) * sc2.y + 0.5), vec4<f32>(0.0), vec4<f32>(7.0));\n }\n let iAx = lvl_to_idx(u32(dot(Lr[0], W0) + dot(Lr[1], W1)));\n let iBx = lvl_to_idx(u32(dot(Lr[2], W0) + dot(Lr[3], W1)));\n let iAy = lvl_to_idx(u32(dot(Lg[0], W0) + dot(Lg[1], W1)));\n let iBy = lvl_to_idx(u32(dot(Lg[2], W0) + dot(Lg[3], W1)));\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n let o = bi * 4u;\n dst[o] = n0.x | (n1.x << 8u) | (iAx << 16u);\n dst[o + 1u] = (iAx >> 16u) | (iBx << 8u);\n dst[o + 2u] = n0.y | (n1.y << 8u) | (iAy << 16u);\n dst[o + 3u] = (iAy >> 16u) | (iBy << 8u);\n}\n";
1101
+ var bc5_default = "// BC5 (RGTC2) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into a 16-byte BC5 block\n// written as 4 x u32 into the destination storage buffer. This is the f32\n// fallback; bc5_fast_f16.wgsl is the same algorithm and is preferred when\n// the device reports shader-f16.\n//\n// BC5 = two BC4 blocks concatenated:\n// block bytes 0..7 : BC4 of R channel (normal.x for tangent-space normals)\n// block bytes 8..15 : BC4 of G channel (normal.y)\n//\n// Each BC4 half-block (8 bytes):\n// byte 0 : red0 (8-bit endpoint)\n// byte 1 : red1 (8-bit endpoint)\n// bytes 2..7 : 16 \xD7 3-bit indices, LSB-first, pixel 0 at bit 0\n//\n// We always produce the 6-interpolation mode (red0 > red1). See `bc4_ref.ts`\n// for the reference this encoder is validated against.\n//\n// Same algorithm as bc5_fast_f16.wgsl (see that file for the full notes):\n// \u2022 both channels processed in the same fused passes, texels held as\n// quad-major vec4s per channel (the gather layout);\n// \u2022 seed at the per-channel extremes (spans \u2264 7: a lossless 7-wide\n// window); pass-1 levels come from that range inset by ~5.5/256 of the\n// span and accumulate MOMENTS (\u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d with d = v \u2212 r0) \u2014 the\n// seed covers the data, so pass 1 needs no clamp;\n// \u2022 refit = the least-squares line through those levels straight off the\n// moments (den = 16\u03A3L\xB2 \u2212 (\u03A3L)\xB2 = 0 keeps the seed), both channels as\n// branch-free vec2 lanes;\n// \u2022 pass 2 derives the levels ONCE, against the refit endpoints \u2014 full\n// reprojection quality \u2014 then an offset round shifts both endpoints by\n// the rounded mean residual of those levels (never worse on them);\n// \u2022 the 16 texel reads are 8 textureGather fetches (4 quads \xD7 R,G)\n// through a clamp-to-edge sampler, byte-identical to per-texel loads;\n// \u2022 3-bit levels pack as \u03A3 L\xB78^k in f32 (exact below 2^24), then one\n// SWAR level \u2192 BC4 index map (0\u21920, 7\u21921, L\u2192L+1) per 24-bit word.\n// Values are kept in the [0,255] f32 domain throughout.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n y0: u32, // first block row of this dispatch (row-band encodes)\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n@group(0) @binding(3) var smp: sampler;\n\n// 8 packed 3-bit levels \u2192 BC4 indices (0\u21920, 7\u21921, L\u2192L+1 otherwise).\nfn lvl_to_idx(x: u32) -> u32 {\n let y = ((x & 0x6DB6DBu) + 0x249249u) ^ (x & 0x924924u);\n return y ^ (~((y >> 1u) | (y >> 2u)) & 0x249249u);\n}\n\n// Per-quad pixel weights 8^k (gather order x,y,z,w = (0,1),(1,1),(1,0),(0,0)).\nconst W0 = vec4<f32>(4096.0, 32768.0, 8.0, 1.0);\nconst W1 = vec4<f32>(262144.0, 2097152.0, 512.0, 64.0);\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {\n // Row-band encodes dispatch a slice of the block grid starting at row y0.\n let gid = vec3<u32>(gid_raw.x, gid_raw.y + params.y0, gid_raw.z);\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n\n // Load 4\xD74 R and G as quad-major vec4s in gather order (w=(0,0) z=(1,0)\n // x=(0,1) y=(1,1) of quad q = (x \u2265 2) + 2\xB7(y \u2265 2)). Interior blocks read\n // via 8 gathers normalised by the PHYSICAL (padded) texture size; blocks\n // straddling the source edge of a non-multiple-of-4 image fall back to\n // per-texel loads clamped to the last real texel (the padding strip is\n // zero-initialised \u2014 see bc5_fast_f16.wgsl).\n var vr: array<vec4<f32>, 4>;\n var vg: array<vec4<f32>, 4>;\n if (u32(base.x) + 4u <= params.width && u32(base.y) + 4u <= params.height) {\n let inv_size = vec2<f32>(1.0, 1.0) / vec2<f32>(textureDimensions(src_tex));\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = vec2<u32>((q & 1u) * 2u, (q >> 1u) * 2u);\n let cc = (vec2<f32>(base) + vec2<f32>(qo) + vec2<f32>(1.0, 1.0)) * inv_size;\n vr[q] = textureGather(0, src_tex, smp, cc) * 255.0;\n vg[q] = textureGather(1, src_tex, smp, cc) * 255.0;\n }\n } else {\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let qo = base + vec2<i32>(i32(q & 1u) * 2, i32(q >> 1u) * 2);\n let cx = textureLoad(src_tex, clamp(qo + vec2<i32>(0, 1), vec2<i32>(0), mx), 0);\n let cy = textureLoad(src_tex, clamp(qo + vec2<i32>(1, 1), vec2<i32>(0), mx), 0);\n let cz = textureLoad(src_tex, clamp(qo + vec2<i32>(1, 0), vec2<i32>(0), mx), 0);\n let cw = textureLoad(src_tex, clamp(qo, vec2<i32>(0), mx), 0);\n vr[q] = vec4<f32>(cx.r, cy.r, cz.r, cw.r) * 255.0;\n vg[q] = vec4<f32>(cx.g, cy.g, cz.g, cw.g) * 255.0;\n }\n }\n let mnr = min(min(vr[0], vr[1]), min(vr[2], vr[3]));\n let mxr = max(max(vr[0], vr[1]), max(vr[2], vr[3]));\n let mng = min(min(vg[0], vg[1]), min(vg[2], vg[3]));\n let mxg = max(max(vg[0], vg[1]), max(vg[2], vg[3]));\n let vmin = vec2<f32>(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));\n let vmax = vec2<f32>(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));\n\n // Seed endpoints at the per-channel extremes (round-to-nearest, the same\n // rule the CPU reference uses); spans \u2264 7 (incl. flat blocks) seed a\n // 7-wide window instead, whose levels land on every integer the block\n // holds \u2014 lossless, and the refit keeps it.\n let vhi = clamp(floor(vmax + 0.5), vec2<f32>(0.0), vec2<f32>(255.0));\n let vlo = clamp(floor(vmin + 0.5), vec2<f32>(0.0), vec2<f32>(255.0));\n let small = vhi - vlo <= vec2<f32>(7.0);\n let r1f = select(vlo, min(vlo, vec2<f32>(248.0)), small);\n let r0f = select(vhi, r1f + 7.0, small);\n // Pass-1 levels come from the seed range INSET by ~5.5/256 of the span\n // on both ends: scale \xD77.3125/7, offset \xBD \u2212 0.15625 (exact dyadic\n // constants, so the WebGL port folds them identically).\n let scale = vec2<f32>(7.3125) / (r1f - r0f);\n\n // Pass 1, both channels \u2014 MOMENTS only. t = d\xB7scale \u2208 [0,7.3125] (the seed\n // covers the data), L = floor(t + 11/32) \u2208 [0,7], no clamp.\n var sL = vec2<f32>(0.0); var sLL = vec2<f32>(0.0);\n var sd = vec2<f32>(0.0); var sLd = vec2<f32>(0.0);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let dr = vr[q] - r0f.x;\n let dg = vg[q] - r0f.y;\n let Lr = floor(dr * scale.x + 0.34375);\n let Lg = floor(dg * scale.y + 0.34375);\n sL = sL + vec2<f32>(dot(Lr, vec4<f32>(1.0)), dot(Lg, vec4<f32>(1.0)));\n sLL = sLL + vec2<f32>(dot(Lr, Lr), dot(Lg, Lg));\n sd = sd + vec2<f32>(dot(dr, vec4<f32>(1.0)), dot(dg, vec4<f32>(1.0)));\n sLd = sLd + vec2<f32>(dot(Lr, dr), dot(Lg, dg));\n }\n\n // Refit: least-squares line v \u2248 r0 + \u03B1 + \u03B2\xB7L through the pass-1 levels,\n // straight off the moments; den = 0 \u27FA every pixel on one level \u2014 keep\n // the seed then. Endpoints clamp to [0,255], NOT the block's value\n // range: for a scalar channel, endpoints beyond the data range are often\n // genuinely optimal and there is no colour axis to bend.\n let den = 16.0 * sLL - sL * sL;\n let beta = (16.0 * sLd - sL * sd) / den;\n let e0 = r0f + (sd - beta * sL) * (1.0 / 16.0);\n let q0f = floor(clamp(e0, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);\n let q1f = floor(clamp(e0 + 7.0 * beta, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);\n let acc = (den > vec2<f32>(0.0)) & (q0f > q1f);\n let n0f = select(r0f, q0f, acc);\n let n1f = select(r1f, q1f, acc);\n\n // Pass 2, both channels \u2014 levels against the refit endpoints, packed as\n // \u03A3 L\xB78^k (A = pixels 0..7, B = pixels 8..15), plus \u03A3L for the offset\n // round.\n let sc2 = vec2<f32>(7.0) / (n1f - n0f);\n var pk = vec4<f32>(0.0); // (Ax, Bx, Ay, By)\n var sL2 = vec2<f32>(0.0);\n for (var q: u32 = 0u; q < 4u; q = q + 1u) {\n let Lr = clamp(floor((vr[q] - n0f.x) * sc2.x + 0.5), vec4<f32>(0.0), vec4<f32>(7.0));\n let Lg = clamp(floor((vg[q] - n0f.y) * sc2.y + 0.5), vec4<f32>(0.0), vec4<f32>(7.0));\n let w = select(W0, W1, (q & 1u) == 1u);\n let hi = q >= 2u;\n let pr = dot(Lr, w);\n let pg = dot(Lg, w);\n pk = pk + vec4<f32>(select(pr, 0.0, hi), select(0.0, pr, hi), select(pg, 0.0, hi), select(0.0, pg, hi));\n sL2 = sL2 + vec2<f32>(dot(Lr, vec4<f32>(1.0)), dot(Lg, vec4<f32>(1.0)));\n }\n\n // Offset round: shift both endpoints by the rounded mean residual of the\n // shipped levels (a whole-level shift moves every palette entry equally,\n // so the error on these indices can only drop, under any decoder).\n let res = sd + 16.0 * (r0f - n0f) - (n1f - n0f) * sL2 * (1.0 / 7.0);\n let sh = clamp(floor(res * (1.0 / 16.0) + 0.5), -n1f, vec2<f32>(255.0) - n0f);\n let m0 = vec2<u32>(n0f + sh);\n let m1 = vec2<u32>(n1f + sh);\n let iAx = lvl_to_idx(u32(pk.x));\n let iBx = lvl_to_idx(u32(pk.y));\n let iAy = lvl_to_idx(u32(pk.z));\n let iBy = lvl_to_idx(u32(pk.w));\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n let o = bi * 4u;\n dst[o] = m0.x | (m1.x << 8u) | (iAx << 16u);\n dst[o + 1u] = (iAx >> 16u) | (iBx << 8u);\n dst[o + 2u] = m0.y | (m1.y << 8u) | (iAy << 16u);\n dst[o + 3u] = (iAy >> 16u) | (iBy << 8u);\n}\n";
1102
1102
 
1103
1103
  // src/bc5_fast_f16.wgsl
1104
1104
  var bc5_fast_f16_default = `// bc5 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
@@ -1122,43 +1122,58 @@ var bc5_fast_f16_default = `// bc5 "fast" encoder \u2014 f16 variant (requires t
1122
1122
  // upload pads the texture with ZEROS, so a normalised-coordinate
1123
1123
  // gather there would read padding (or mis-scale against the padded
1124
1124
  // size) instead of replicating the last real texel.
1125
- // \u2022 Pass 1 accumulates MOMENTS, not normal-equation sums: \u03A3L, \u03A3L\xB2, \u03A3d and
1126
- // \u03A3L\xB7d per channel, with d = v \u2212 r0 (exact integers) and L the seed
1127
- // level. With b = L/7, t = d\xB77/(r1\u2212r0) = d\xB7s and the level-space
1128
- // residual \u03C1 = t \u2212 L, the residual moments are O(1) per block \u2014
1129
- // \u03A3\u03C1 = s\xB7\u03A3d \u2212 \u03A3L, \u03A3L\u03C1 = s\xB7\u03A3Ld \u2212 \u03A3L\xB2 \u2014 and so is every LSQ sum:
1130
- // sBB = \u03A3L\xB2/49 sAB = \u03A3L/7 \u2212 \u03A3L\xB2/49 sAA = 16 \u2212 2\u03A3L/7 + \u03A3L\xB2/49
1131
- // sBR = \u03A3L\u03C1\xB7dir/49 sAR = (\u03A3\u03C1 \u2212 \u03A3L\u03C1/7)\xB7dir/7
1132
- // \u03A3L \u2264 112 and \u03A3L\xB2 \u2264 784 are exact f16 integers; \u03A3L\xB7d (\u2264 28560) and \u03A3d
1133
- // accumulate in f32 so they stay exact too. The seed covers the data,
1134
- // so t \u2208 [0,7] and pass 1 needs no clamp. The per-BLOCK refit math
1135
- // (solve, E(\u03B4) pricing) runs in f32 \u2014 free at block granularity.
1125
+ // \u2022 SEED: endpoints at the per-channel extremes, but pass-1 levels are
1126
+ // assigned against that range INSET by ~5.5/256 of the span on both
1127
+ // ends (level scale \xD77.3125/7, offset \xBD \u2212 0.15625 = 11/32 \u2014 exact dyadic
1128
+ // constants so every backend folds them identically), so each extreme level
1129
+ // gathers the pixels NEAR the extremes instead of only the extreme
1130
+ // pixel itself \u2014 the refit then lands much closer to the optimum
1131
+ // (an exhaustive search over all endpoint pairs showed the plain
1132
+ // bbox seed leaving 0.7\u20136 dB on the table). Swept 0..20/256; 5.5 wins
1133
+ // under both the /7 spec decode and Apple's hardware decode (below),
1134
+ // and per-block adaptive insets (variance, extreme gaps) all lost.
1135
+ // Spans \u2264 7 (incl. flat blocks) seed a 7-wide window instead, whose
1136
+ // levels land on every integer the block holds: lossless.
1137
+ // \u2022 Pass 1 accumulates MOMENTS \u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d per channel (d = v \u2212 r0,
1138
+ // exact integers). \u03A3L \u2264 112 and \u03A3L\xB2 \u2264 784 are exact f16 integers; \u03A3L\xB7d
1139
+ // (\u2264 28560) and \u03A3d accumulate in f32 so they stay exact too. The seed
1140
+ // covers the data, so t \u2208 [0,7.3125] and L = floor(t + 11/32) \u2208 [0,7] needs
1141
+ // no clamp.
1142
+ // \u2022 REFIT = the least-squares line v \u2248 r0 + \u03B1 + \u03B2\xB7L through those levels,
1143
+ // straight off the moments: \u03B2 = (16\u03A3Ld \u2212 \u03A3L\xB7\u03A3d)/(16\u03A3L\xB2 \u2212 (\u03A3L)\xB2), \u03B1 =
1144
+ // (\u03A3d \u2212 \u03B2\u03A3L)/16 \u2014 ~12 ops per channel. den = 0 \u27FA every pixel on one
1145
+ // level (exact integer test) keeps the seed. Accepted whenever it stays
1146
+ // in 6-interp mode: on the inset partition, pricing it against the seed
1147
+ // (the previous E(\u03B4) closed form) changed nothing, and dropping that
1148
+ // pricing is what pays for the offset round below.
1149
+ // \u2022 Pass 2 derives the shipped levels ONCE, against the refit endpoints \u2014
1150
+ // full reprojection quality \u2014 as a LOOP over quads (the unrolled form
1151
+ // with all eight level vectors live measured ~6% slower once \u03A3L was
1152
+ // added).
1153
+ // \u2022 OFFSET ROUND: both endpoints shift by round(mean residual) of the
1154
+ // shipped levels (\u03A3v is exact from pass 1, so only \u03A3L is new). A
1155
+ // whole-level shift moves every palette entry equally, so the error on
1156
+ // these indices can only drop, under ANY decoder's weights. Buys half
1157
+ // of a full second refit round (+0.05 dB) for ~1/4 of its cost; the
1158
+ // full round (\u03A3L\xB2, \u03A3L\xB7v in pass 2 + a second solve) measured +0.1 dB
1159
+ // more but +18% GPU at 1K \u2014 rejected.
1160
+ // \u2022 Apple GPUs (M3 measured) decode BC4/BC5 with BC7-style 6-bit weights
1161
+ // (0,9,18,27,37,46,55,64)/64, not exact sevenths \u2014 up to \xB10.0067\xB7span
1162
+ // off the spec palette. The encoder targets the spec (/7) palette;
1163
+ // /eval's hardware-decoded PSNR sits ~0.07 dB under the CPU-decoded one.
1136
1164
  // \u2022 Texels live as quad-major vec4<f16> per channel (the gather layout),
1137
1165
  // so min/max reduce as vectors and both passes run 4-wide; edge blocks
1138
1166
  // load into the same layout.
1139
- // \u2022 The rank guard is EXACT: all pixels on one level \u27FA 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2
1140
- // (integers, so the comparison is precise in f32) \u2014 no lmin/lmax
1141
- // tracking in the loop.
1142
- // \u2022 The refit is accepted or rejected CLOSED-FORM, with no trial
1143
- // projection pass: the solve is e = seed + M\u207B\xB9(sAR,sBR), and the error
1144
- // of re-quantised endpoints ON THE CURRENT INDICES is
1145
- // E(\u03B4) = err \u2212 2(\u03B40\xB7sAR + \u03B41\xB7sBR) + \u03B40\xB2sAA + 2\u03B40\u03B41\xB7sAB + \u03B41\xB2sBB
1146
- // with \u03B4 = quantised endpoint \u2212 base endpoint, compared as the delta
1147
- // form E \u2212 err < 0. Both channels run as vec2 lanes, branch-free (one
1148
- // reciprocal of det, select on the accept mask) \u2014 the per-channel
1149
- // function with early returns measured ~2% slower. Only the NEAREST
1150
- // rounding of the fractional solve is priced: pricing all four
1151
- // floor/ceil combinations measured \u22640.015 dB on every content class
1152
- // but ~8% GPU on smooth content.
1153
- // \u2022 Pass 2 derives the shipped levels ONCE, against the FINAL endpoints \u2014
1154
- // full reprojection quality.
1155
1167
  // \u2022 3-bit indices are packed as FLOAT: each group of 8 pixels'
1156
1168
  // levels accumulates as \u03A3 L\xB78^k in f32 (\u2264 2^24 \u2212 1, exact) \u2014 one fma
1157
1169
  // per texel \u2014 and the level \u2192 BC4 index map (0\u21920, 7\u21921, L\u2192L+1) is
1158
1170
  // applied to the whole 24-bit word with SWAR bit tricks.
1159
- // These passes measured \u221222% GPU vs the previous kernel (rg8 source; \u221217%
1160
- // on rgba8, where 2K/4K normal maps sit at the read floor either way) at
1161
- // equal PSNR \u2014 the 1K case is ALU-bound, not read-bound (/eval 2026-09).
1171
+ // The moment/float-packing structure measured \u221222% GPU vs the kernel
1172
+ // before it (rg8 source; 2K/4K normal maps sit at the read floor on rgba8
1173
+ // either way) \u2014 the 1K case is ALU-bound, not read-bound (/eval 2026-09).
1174
+ // The inset seed + regression refit + offset round then lowered MSE by
1175
+ // 3.8\u20139% on every real texture (normals 3.8\u20136.4%, displacement 9\u201360%,
1176
+ // hardware decode) at equal GPU time (/eval 2026-09-24).
1162
1177
  //
1163
1178
  // The host selects this module only when the device reports shader-f16,
1164
1179
  // falling back to bc5.wgsl otherwise.
@@ -1229,26 +1244,28 @@ fn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {
1229
1244
  let vmin = vec2<h>(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));
1230
1245
  let vmax = vec2<h>(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));
1231
1246
 
1232
- // Seed endpoints at the exact per-channel extremes. Flat blocks get
1233
- // nudged apart to keep the 6-interp mode (r0 > r1 strictly).
1234
- var r0 = vec2<u32>(vmax);
1235
- var r1 = vec2<u32>(vmin);
1236
- if (r0.x == r1.x) { if (r1.x > 0u) { r1.x = r1.x - 1u; } else { r0.x = r0.x + 1u; } }
1237
- if (r0.y == r1.y) { if (r1.y > 0u) { r1.y = r1.y - 1u; } else { r0.y = r0.y + 1u; } }
1247
+ // Seed endpoints at the exact per-channel extremes; spans \u2264 7 (incl.
1248
+ // flat blocks) seed a 7-wide window instead, whose levels land on every
1249
+ // integer the block holds \u2014 lossless, and the refit keeps it.
1250
+ let small = vmax - vmin <= vec2<h>(7.0);
1251
+ let r1 = vec2<u32>(select(vmin, min(vmin, vec2<h>(248.0)), small));
1252
+ let r0 = select(vec2<u32>(vmax), r1 + 7u, small);
1238
1253
 
1239
1254
  let r0h = vec2<h>(vec2<f32>(r0));
1240
1255
  let dirf = vec2<f32>(r1) - vec2<f32>(r0);
1241
- let scale = vec2<h>(vec2<f32>(7.0) / dirf);
1256
+ // Pass-1 levels come from the seed range INSET by ~5.5/256 of the span
1257
+ // on both ends (see header).
1258
+ let scale = vec2<h>(vec2<f32>(7.3125) / dirf);
1242
1259
 
1243
- // Pass 1 \u2014 t = d\xB7scale \u2208 [0,7] by construction (seed covers the data),
1244
- // so L = floor(t + \xBD) needs no clamp.
1260
+ // Pass 1 \u2014 t = d\xB7scale \u2208 [0,7.3125] by construction (seed covers the
1261
+ // data), so L = floor(t + 11/32) \u2208 [0,7] needs no clamp.
1245
1262
  var sLr = h(0.0); var sLLr = h(0.0); var sdr = 0.0; var sLdr = 0.0;
1246
1263
  var sLg = h(0.0); var sLLg = h(0.0); var sdg = 0.0; var sLdg = 0.0;
1247
1264
  for (var q: u32 = 0u; q < 4u; q = q + 1u) {
1248
1265
  let dr = vr[q] - r0h.x;
1249
1266
  let dg = vg[q] - r0h.y;
1250
- let Lr = floor(dr * scale.x + h(0.5));
1251
- let Lg = floor(dg * scale.y + h(0.5));
1267
+ let Lr = floor(dr * scale.x + h(0.34375));
1268
+ let Lg = floor(dg * scale.y + h(0.34375));
1252
1269
  sLr = sLr + dot(Lr, h4(1.0)); sLLr = sLLr + dot(Lr, Lr);
1253
1270
  sLg = sLg + dot(Lg, h4(1.0)); sLLg = sLLg + dot(Lg, Lg);
1254
1271
  sdr = sdr + f32(dot(dr, h4(1.0)));
@@ -1257,32 +1274,18 @@ fn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {
1257
1274
  sLdg = sLdg + dot(vec4<f32>(Lg), vec4<f32>(dg));
1258
1275
  }
1259
1276
 
1260
- // Per-block refit in f32 off the moments (see header for the identities).
1277
+ // Per-block refit in f32: least-squares line v \u2248 r0 + \u03B1 + \u03B2\xB7L through
1278
+ // the pass-1 levels, straight off the (exact-integer) moments. den = 0
1279
+ // \u27FA every pixel on one level (rank-deficient) \u2014 keep the seed then.
1261
1280
  let sLf = vec2<f32>(f32(sLr), f32(sLg));
1262
1281
  let sLLf = vec2<f32>(f32(sLLr), f32(sLLg));
1263
- let s32 = vec2<f32>(7.0) / dirf;
1264
- let pR = s32 * vec2<f32>(sdr, sdg) - sLf;
1265
- let pLR = s32 * vec2<f32>(sLdr, sLdg) - sLLf;
1266
- let sBB = sLLf * (1.0 / 49.0);
1267
- let sAB = sLf * (1.0 / 7.0) - sBB;
1268
- let sAA = vec2<f32>(16.0) - 2.0 * sLf * (1.0 / 7.0) + sBB;
1269
- let sBR = pLR * dirf * (1.0 / 49.0);
1270
- let sAR = (pR - pLR * (1.0 / 7.0)) * dirf * (1.0 / 7.0);
1271
- let spread = 16.0 * sLLf != sLf * sLf;
1272
-
1273
- // Both channels at once, branch-free: nearest rounding of the LSQ
1274
- // solve, accepted when it stays in 6-interp mode, moves, and prices
1275
- // strictly better on the current indices.
1276
- let r0f = vec2<f32>(r0);
1277
- let r1f = vec2<f32>(r1);
1278
- let det = sAA * sBB - sAB * sAB;
1279
- let idet = 1.0 / det;
1280
- let q0f = floor(clamp(r0f + (sBB * sAR - sAB * sBR) * idet, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);
1281
- let q1f = floor(clamp(r1f + (sAA * sBR - sAB * sAR) * idet, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);
1282
- let dd0 = q0f - r0f;
1283
- let dd1 = q1f - r1f;
1284
- let eNew = -2.0 * (dd0 * sAR + dd1 * sBR) + dd0 * dd0 * sAA + 2.0 * dd0 * dd1 * sAB + dd1 * dd1 * sBB;
1285
- let acc = spread & (abs(det) > vec2<f32>(1e-3)) & (q0f > q1f) & (eNew < vec2<f32>(0.0));
1282
+ let sdf = vec2<f32>(sdr, sdg);
1283
+ let den = 16.0 * sLLf - sLf * sLf;
1284
+ let beta = (16.0 * vec2<f32>(sLdr, sLdg) - sLf * sdf) / den;
1285
+ let e0 = vec2<f32>(r0) + (sdf - beta * sLf) * (1.0 / 16.0);
1286
+ let q0f = floor(clamp(e0, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);
1287
+ let q1f = floor(clamp(e0 + 7.0 * beta, vec2<f32>(0.0), vec2<f32>(255.0)) + 0.5);
1288
+ let acc = (den > vec2<f32>(0.0)) & (q0f > q1f);
1286
1289
  let n0 = select(r0, vec2<u32>(q0f), acc);
1287
1290
  let n1 = select(r1, vec2<u32>(q1f), acc);
1288
1291
 
@@ -1291,24 +1294,34 @@ fn encode(@builtin(global_invocation_id) gid_raw: vec3<u32>) {
1291
1294
  // (\u03A3 L\xB78^k \u2264 2^24 \u2212 1, exact): iA = pixels 0..7, iB = pixels 8..15.
1292
1295
  let n0h = vec2<h>(vec2<f32>(n0));
1293
1296
  let sc2 = vec2<h>(vec2<f32>(7.0) / (vec2<f32>(n1) - vec2<f32>(n0)));
1294
- let L0r = clamp(floor((vr[0] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1295
- let L1r = clamp(floor((vr[1] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1296
- let L2r = clamp(floor((vr[2] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1297
- let L3r = clamp(floor((vr[3] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1298
- let L0g = clamp(floor((vg[0] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1299
- let L1g = clamp(floor((vg[1] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1300
- let L2g = clamp(floor((vg[2] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1301
- let L3g = clamp(floor((vg[3] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1302
- let iAx = lvl_to_idx(u32(dot(vec4<f32>(L0r), W0) + dot(vec4<f32>(L1r), W1)));
1303
- let iBx = lvl_to_idx(u32(dot(vec4<f32>(L2r), W0) + dot(vec4<f32>(L3r), W1)));
1304
- let iAy = lvl_to_idx(u32(dot(vec4<f32>(L0g), W0) + dot(vec4<f32>(L1g), W1)));
1305
- let iBy = lvl_to_idx(u32(dot(vec4<f32>(L2g), W0) + dot(vec4<f32>(L3g), W1)));
1297
+ var pk = vec4<f32>(0.0); // (Ax, Bx, Ay, By) level words
1298
+ var sLq = vec2<h>(0.0);
1299
+ for (var q: u32 = 0u; q < 4u; q = q + 1u) {
1300
+ let Lr = clamp(floor((vr[q] - n0h.x) * sc2.x + h(0.5)), h4(0.0), h4(7.0));
1301
+ let Lg = clamp(floor((vg[q] - n0h.y) * sc2.y + h(0.5)), h4(0.0), h4(7.0));
1302
+ let w = select(W0, W1, (q & 1u) == 1u);
1303
+ let hi = q >= 2u;
1304
+ let pr = dot(vec4<f32>(Lr), w);
1305
+ let pg = dot(vec4<f32>(Lg), w);
1306
+ pk = pk + vec4<f32>(select(pr, 0.0, hi), select(0.0, pr, hi), select(pg, 0.0, hi), select(0.0, pg, hi));
1307
+ sLq = sLq + vec2<h>(dot(Lr, h4(1.0)), dot(Lg, h4(1.0)));
1308
+ }
1309
+ let n0f = vec2<f32>(n0);
1310
+ let sL2 = vec2<f32>(sLq);
1311
+ let res = sdf + 16.0 * (vec2<f32>(r0) - n0f) - (vec2<f32>(n1) - n0f) * sL2 * (1.0 / 7.0);
1312
+ let sh = clamp(floor(res * (1.0 / 16.0) + 0.5), -vec2<f32>(n1), vec2<f32>(255.0) - n0f);
1313
+ let m0 = vec2<u32>(n0f + sh);
1314
+ let m1 = vec2<u32>(vec2<f32>(n1) + sh);
1315
+ let iAx = lvl_to_idx(u32(pk.x));
1316
+ let iBx = lvl_to_idx(u32(pk.y));
1317
+ let iAy = lvl_to_idx(u32(pk.z));
1318
+ let iBy = lvl_to_idx(u32(pk.w));
1306
1319
 
1307
1320
  // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.
1308
1321
  let o = bi * 4u;
1309
- dst[o] = n0.x | (n1.x << 8u) | (iAx << 16u);
1322
+ dst[o] = m0.x | (m1.x << 8u) | (iAx << 16u);
1310
1323
  dst[o + 1u] = (iAx >> 16u) | (iBx << 8u);
1311
- dst[o + 2u] = n0.y | (n1.y << 8u) | (iAy << 16u);
1324
+ dst[o + 2u] = m0.y | (m1.y << 8u) | (iAy << 16u);
1312
1325
  dst[o + 3u] = (iAy >> 16u) | (iBy << 8u);
1313
1326
  }
1314
1327
  `;
@@ -2186,7 +2199,7 @@ var BC1WebGLEncoder = class extends WebGLBlockEncoder {
2186
2199
  };
2187
2200
 
2188
2201
  // src/webgl/glsl/bc5.frag.glsl
2189
- var bc5_frag_default = "#version 300 es\n// BC5 (RGTC2) fragment-shader encoder \u2014 WebGL2 port of bc5.wgsl.\n//\n// One fragment per 4\xD74 block \u2192 16-byte BC5 block as 4 \xD7 u32 in outColor.\n// BC5 = two BC4 halves (R then G), both channels processed together. Same\n// algorithm and arithmetic order as bc5.wgsl (see bc5_fast_f16.wgsl for the\n// full notes and measurements):\n// \u2022 texels held as quad-major vec4s per channel, in textureGather order\n// (x=(0,1) y=(1,1) z=(1,0) w=(0,0) within each 2\xD72 quad);\n// \u2022 seed endpoints at the per-channel extremes; pass 1 accumulates the\n// MOMENTS \u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d (d = v \u2212 r0, L = the seed level \u2014 the seed\n// covers the data, so no clamp), from which every least-squares sum is\n// O(1) per block; exact rank guard 16\xB7\u03A3L\xB2 == (\u03A3L)\xB2;\n// \u2022 one closed-form refit accepted when it prices better on the seed\n// levels (nearest rounding of the solve, 6-interp mode kept);\n// \u2022 pass 2 derives the shipped levels against the FINAL endpoints, packed\n// as float fields \u03A3 L\xB78^k (exact below 2^24), then one SWAR level \u2192 BC4\n// index map (0\u21920, 7\u21921, L\u2192L+1) per 24-bit word.\n// Always emits 6-interpolation mode (red0 > red1).\n\nprecision highp float;\nprecision highp int;\n\nuniform sampler2D uSrc;\nuniform ivec2 uSrcSize;\nuniform int uFlipY;\n\nlayout(location = 0) out uvec4 outColor;\n\n// 8 packed 3-bit levels \u2192 BC4 indices (0\u21920, 7\u21921, L\u2192L+1 otherwise).\nuint lvlToIdx(uint x) {\n uint y = ((x & 0x6DB6DBu) + 0x249249u) ^ (x & 0x924924u);\n return y ^ (~((y >> 1u) | (y >> 2u)) & 0x249249u);\n}\n\n// Per-quad pixel weights 8^k (gather order x,y,z,w = (0,1),(1,1),(1,0),(0,0)).\nconst vec4 W0 = vec4(4096.0, 32768.0, 8.0, 1.0);\nconst vec4 W1 = vec4(262144.0, 2097152.0, 512.0, 64.0);\n\nvec4 fetchRG(ivec2 p, ivec2 maxXY) {\n ivec2 c = clamp(p, ivec2(0), maxXY);\n int sy = (uFlipY != 0) ? (uSrcSize.y - 1 - c.y) : c.y;\n return texelFetch(uSrc, ivec2(c.x, sy), 0);\n}\n\n// Closed-form accept-if-better refit for one channel off the pass-1\n// moments; returns the final (r0, r1).\nuvec2 refit(float sL, float sLL, float sd, float sLd, uint r0, uint r1) {\n float r0f = float(r0);\n float r1f = float(r1);\n float dir = r1f - r0f;\n float scale = 7.0 / dir;\n // \u03A3\u03C1 = s\xB7\u03A3d \u2212 \u03A3L, \u03A3L\u03C1 = s\xB7\u03A3Ld \u2212 \u03A3L\xB2 (\u03C1 = level-space residual).\n float pR = scale * sd - sL;\n float pLR = scale * sLd - sLL;\n float sBB = sLL * (1.0 / 49.0);\n float sAB = sL * (1.0 / 7.0) - sBB;\n float sAA = 16.0 - 2.0 * sL * (1.0 / 7.0) + sBB;\n float sBR = pLR * dir * (1.0 / 49.0);\n float sAR = (pR - pLR * (1.0 / 7.0)) * dir * (1.0 / 7.0);\n bool spread = 16.0 * sLL != sL * sL;\n float det = sAA * sBB - sAB * sAB;\n float idet = 1.0 / det;\n // Endpoints clamp to [0,255], NOT the block's value range: for a scalar\n // channel, endpoints beyond the data range are often genuinely optimal.\n float q0f = floor(clamp(r0f + (sBB * sAR - sAB * sBR) * idet, 0.0, 255.0) + 0.5);\n float q1f = floor(clamp(r1f + (sAA * sBR - sAB * sAR) * idet, 0.0, 255.0) + 0.5);\n float dd0 = q0f - r0f;\n float dd1 = q1f - r1f;\n float eNew = -2.0 * (dd0 * sAR + dd1 * sBR) + dd0 * dd0 * sAA + 2.0 * dd0 * dd1 * sAB + dd1 * dd1 * sBB;\n bool acc = spread && abs(det) > 1e-3 && q0f > q1f && eNew < 0.0;\n return acc ? uvec2(uint(q0f), uint(q1f)) : uvec2(r0, r1);\n}\n\nvoid main() {\n ivec2 base = ivec2(gl_FragCoord.xy) * 4;\n ivec2 maxXY = uSrcSize - ivec2(1);\n\n vec4 vr[4];\n vec4 vg[4];\n for (int q = 0; q < 4; q++) {\n ivec2 qo = base + ivec2((q & 1) * 2, (q >> 1) * 2);\n vec4 cx = fetchRG(qo + ivec2(0, 1), maxXY);\n vec4 cy = fetchRG(qo + ivec2(1, 1), maxXY);\n vec4 cz = fetchRG(qo + ivec2(1, 0), maxXY);\n vec4 cw = fetchRG(qo, maxXY);\n vr[q] = vec4(cx.r, cy.r, cz.r, cw.r) * 255.0;\n vg[q] = vec4(cx.g, cy.g, cz.g, cw.g) * 255.0;\n }\n vec4 mnr = min(min(vr[0], vr[1]), min(vr[2], vr[3]));\n vec4 mxr = max(max(vr[0], vr[1]), max(vr[2], vr[3]));\n vec4 mng = min(min(vg[0], vg[1]), min(vg[2], vg[3]));\n vec4 mxg = max(max(vg[0], vg[1]), max(vg[2], vg[3]));\n vec2 vmin = vec2(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));\n vec2 vmax = vec2(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));\n\n // Seed endpoints at the exact per-channel extremes (round-to-nearest).\n // Flat blocks get nudged apart to keep the 6-interp mode (r0 > r1).\n uvec2 r0 = uvec2(clamp(floor(vmax + 0.5), vec2(0.0), vec2(255.0)));\n uvec2 r1 = uvec2(clamp(floor(vmin + 0.5), vec2(0.0), vec2(255.0)));\n if (r0.x == r1.x) { if (r1.x > 0u) { r1.x = r1.x - 1u; } else { r0.x = r0.x + 1u; } }\n if (r0.y == r1.y) { if (r1.y > 0u) { r1.y = r1.y - 1u; } else { r0.y = r0.y + 1u; } }\n\n vec2 r0f = vec2(r0);\n vec2 scale = vec2(7.0) / (vec2(r1) - r0f);\n\n // Pass 1 \u2014 moments only.\n vec2 sL = vec2(0.0);\n vec2 sLL = vec2(0.0);\n vec2 sd = vec2(0.0);\n vec2 sLd = vec2(0.0);\n for (int q = 0; q < 4; q++) {\n vec4 dr = vr[q] - r0f.x;\n vec4 dg = vg[q] - r0f.y;\n vec4 Lr = floor(dr * scale.x + 0.5);\n vec4 Lg = floor(dg * scale.y + 0.5);\n sL += vec2(dot(Lr, vec4(1.0)), dot(Lg, vec4(1.0)));\n sLL += vec2(dot(Lr, Lr), dot(Lg, Lg));\n sd += vec2(dot(dr, vec4(1.0)), dot(dg, vec4(1.0)));\n sLd += vec2(dot(Lr, dr), dot(Lg, dg));\n }\n\n uvec2 fr = refit(sL.x, sLL.x, sd.x, sLd.x, r0.x, r1.x);\n uvec2 fg = refit(sL.y, sLL.y, sd.y, sLd.y, r0.y, r1.y);\n uvec2 n0 = uvec2(fr.x, fg.x);\n uvec2 n1 = uvec2(fr.y, fg.y);\n\n // Pass 2 \u2014 levels against the FINAL endpoints, packed as \u03A3 L\xB78^k:\n // iA = pixels 0..7, iB = pixels 8..15.\n vec2 n0f = vec2(n0);\n vec2 sc2 = vec2(7.0) / (vec2(n1) - n0f);\n vec4 Lr[4];\n vec4 Lg[4];\n for (int q = 0; q < 4; q++) {\n Lr[q] = clamp(floor((vr[q] - n0f.x) * sc2.x + 0.5), vec4(0.0), vec4(7.0));\n Lg[q] = clamp(floor((vg[q] - n0f.y) * sc2.y + 0.5), vec4(0.0), vec4(7.0));\n }\n uint iAx = lvlToIdx(uint(dot(Lr[0], W0) + dot(Lr[1], W1)));\n uint iBx = lvlToIdx(uint(dot(Lr[2], W0) + dot(Lr[3], W1)));\n uint iAy = lvlToIdx(uint(dot(Lg[0], W0) + dot(Lg[1], W1)));\n uint iBy = lvlToIdx(uint(dot(Lg[2], W0) + dot(Lg[3], W1)));\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n outColor = uvec4(\n n0.x | (n1.x << 8u) | (iAx << 16u),\n (iAx >> 16u) | (iBx << 8u),\n n0.y | (n1.y << 8u) | (iAy << 16u),\n (iAy >> 16u) | (iBy << 8u)\n );\n}\n";
2202
+ var bc5_frag_default = "#version 300 es\n// BC5 (RGTC2) fragment-shader encoder \u2014 WebGL2 port of bc5.wgsl.\n//\n// One fragment per 4\xD74 block \u2192 16-byte BC5 block as 4 \xD7 u32 in outColor.\n// BC5 = two BC4 halves (R then G), both channels processed together. Same\n// algorithm and arithmetic order as bc5.wgsl (see bc5_fast_f16.wgsl for the\n// full notes and measurements):\n// \u2022 texels held as quad-major vec4s per channel, in textureGather order\n// (x=(0,1) y=(1,1) z=(1,0) w=(0,0) within each 2\xD72 quad);\n// \u2022 seed endpoints at the per-channel extremes (spans \u2264 7: a 7-wide\n// window, lossless); pass-1 levels come from the seed range inset by\n// ~5.5/256 of the span (scale \xD77.3125/7, offset 11/32), accumulating\n// the MOMENTS \u03A3L, \u03A3L\xB2, \u03A3d, \u03A3L\xB7d (d = v \u2212 r0; the seed covers the\n// data, so no clamp);\n// \u2022 one least-squares line through the pass-1 levels straight off the\n// moments (den = 16\u03A3L\xB2 \u2212 (\u03A3L)\xB2 = 0 keeps the seed);\n// \u2022 pass 2 derives the shipped levels against the refit endpoints,\n// packed as float fields \u03A3 L\xB78^k (exact below 2^24), then one SWAR\n// level \u2192 BC4 index map (0\u21920, 7\u21921, L\u2192L+1) per 24-bit word;\n// \u2022 offset round: both endpoints shift by the rounded mean residual of\n// the shipped levels (never worse on those indices).\n// Always emits 6-interpolation mode (red0 > red1).\n\nprecision highp float;\nprecision highp int;\n\nuniform sampler2D uSrc;\nuniform ivec2 uSrcSize;\nuniform int uFlipY;\n\nlayout(location = 0) out uvec4 outColor;\n\n// 8 packed 3-bit levels \u2192 BC4 indices (0\u21920, 7\u21921, L\u2192L+1 otherwise).\nuint lvlToIdx(uint x) {\n uint y = ((x & 0x6DB6DBu) + 0x249249u) ^ (x & 0x924924u);\n return y ^ (~((y >> 1u) | (y >> 2u)) & 0x249249u);\n}\n\n// Per-quad pixel weights 8^k (gather order x,y,z,w = (0,1),(1,1),(1,0),(0,0)).\nconst vec4 W0 = vec4(4096.0, 32768.0, 8.0, 1.0);\nconst vec4 W1 = vec4(262144.0, 2097152.0, 512.0, 64.0);\n\nvec4 fetchRG(ivec2 p, ivec2 maxXY) {\n ivec2 c = clamp(p, ivec2(0), maxXY);\n int sy = (uFlipY != 0) ? (uSrcSize.y - 1 - c.y) : c.y;\n return texelFetch(uSrc, ivec2(c.x, sy), 0);\n}\n\nvoid main() {\n ivec2 base = ivec2(gl_FragCoord.xy) * 4;\n ivec2 maxXY = uSrcSize - ivec2(1);\n\n vec4 vr[4];\n vec4 vg[4];\n for (int q = 0; q < 4; q++) {\n ivec2 qo = base + ivec2((q & 1) * 2, (q >> 1) * 2);\n vec4 cx = fetchRG(qo + ivec2(0, 1), maxXY);\n vec4 cy = fetchRG(qo + ivec2(1, 1), maxXY);\n vec4 cz = fetchRG(qo + ivec2(1, 0), maxXY);\n vec4 cw = fetchRG(qo, maxXY);\n vr[q] = vec4(cx.r, cy.r, cz.r, cw.r) * 255.0;\n vg[q] = vec4(cx.g, cy.g, cz.g, cw.g) * 255.0;\n }\n vec4 mnr = min(min(vr[0], vr[1]), min(vr[2], vr[3]));\n vec4 mxr = max(max(vr[0], vr[1]), max(vr[2], vr[3]));\n vec4 mng = min(min(vg[0], vg[1]), min(vg[2], vg[3]));\n vec4 mxg = max(max(vg[0], vg[1]), max(vg[2], vg[3]));\n vec2 vmin = vec2(min(min(mnr.x, mnr.y), min(mnr.z, mnr.w)), min(min(mng.x, mng.y), min(mng.z, mng.w)));\n vec2 vmax = vec2(max(max(mxr.x, mxr.y), max(mxr.z, mxr.w)), max(max(mxg.x, mxg.y), max(mxg.z, mxg.w)));\n\n // Seed endpoints at the per-channel extremes (round-to-nearest); spans\n // \u2264 7 (incl. flat blocks) seed a 7-wide window instead \u2014 lossless.\n vec2 vhi = clamp(floor(vmax + 0.5), vec2(0.0), vec2(255.0));\n vec2 vlo = clamp(floor(vmin + 0.5), vec2(0.0), vec2(255.0));\n bvec2 small = lessThanEqual(vhi - vlo, vec2(7.0));\n vec2 r1f = vec2(small.x ? min(vlo.x, 248.0) : vlo.x, small.y ? min(vlo.y, 248.0) : vlo.y);\n vec2 r0f = vec2(small.x ? r1f.x + 7.0 : vhi.x, small.y ? r1f.y + 7.0 : vhi.y);\n // Pass-1 levels from the seed range inset by ~5.5/256 of the span.\n vec2 scale = vec2(7.3125) / (r1f - r0f);\n\n // Pass 1 \u2014 moments only.\n vec2 sL = vec2(0.0);\n vec2 sLL = vec2(0.0);\n vec2 sd = vec2(0.0);\n vec2 sLd = vec2(0.0);\n for (int q = 0; q < 4; q++) {\n vec4 dr = vr[q] - r0f.x;\n vec4 dg = vg[q] - r0f.y;\n vec4 Lr = floor(dr * scale.x + 0.34375);\n vec4 Lg = floor(dg * scale.y + 0.34375);\n sL += vec2(dot(Lr, vec4(1.0)), dot(Lg, vec4(1.0)));\n sLL += vec2(dot(Lr, Lr), dot(Lg, Lg));\n sd += vec2(dot(dr, vec4(1.0)), dot(dg, vec4(1.0)));\n sLd += vec2(dot(Lr, dr), dot(Lg, dg));\n }\n\n // Refit: least-squares line v \u2248 r0 + \u03B1 + \u03B2\xB7L through the pass-1 levels.\n // Endpoints clamp to [0,255], NOT the block's value range.\n vec2 den = 16.0 * sLL - sL * sL;\n vec2 beta = (16.0 * sLd - sL * sd) / den;\n vec2 e0 = r0f + (sd - beta * sL) * (1.0 / 16.0);\n vec2 q0f = floor(clamp(e0, vec2(0.0), vec2(255.0)) + 0.5);\n vec2 q1f = floor(clamp(e0 + 7.0 * beta, vec2(0.0), vec2(255.0)) + 0.5);\n bvec2 acc = bvec2(den.x > 0.0 && q0f.x > q1f.x, den.y > 0.0 && q0f.y > q1f.y);\n vec2 n0f = vec2(acc.x ? q0f.x : r0f.x, acc.y ? q0f.y : r0f.y);\n vec2 n1f = vec2(acc.x ? q1f.x : r1f.x, acc.y ? q1f.y : r1f.y);\n\n // Pass 2 \u2014 levels against the refit endpoints, packed as \u03A3 L\xB78^k\n // (A = pixels 0..7, B = pixels 8..15), plus \u03A3L for the offset round.\n vec2 sc2 = vec2(7.0) / (n1f - n0f);\n vec4 pk = vec4(0.0); // (Ax, Bx, Ay, By)\n vec2 sL2 = vec2(0.0);\n for (int q = 0; q < 4; q++) {\n vec4 Lr = clamp(floor((vr[q] - n0f.x) * sc2.x + 0.5), vec4(0.0), vec4(7.0));\n vec4 Lg = clamp(floor((vg[q] - n0f.y) * sc2.y + 0.5), vec4(0.0), vec4(7.0));\n vec4 w = (q & 1) == 1 ? W1 : W0;\n bool hi = q >= 2;\n float pr = dot(Lr, w);\n float pg = dot(Lg, w);\n pk += vec4(hi ? 0.0 : pr, hi ? pr : 0.0, hi ? 0.0 : pg, hi ? pg : 0.0);\n sL2 += vec2(dot(Lr, vec4(1.0)), dot(Lg, vec4(1.0)));\n }\n\n // Offset round: shift both endpoints by the rounded mean residual of the\n // shipped levels.\n vec2 res = sd + 16.0 * (r0f - n0f) - (n1f - n0f) * sL2 * (1.0 / 7.0);\n vec2 sh = clamp(floor(res * (1.0 / 16.0) + 0.5), -n1f, vec2(255.0) - n0f);\n uvec2 m0 = uvec2(n0f + sh);\n uvec2 m1 = uvec2(n1f + sh);\n uint iAx = lvlToIdx(uint(pk.x));\n uint iBx = lvlToIdx(uint(pk.y));\n uint iAy = lvlToIdx(uint(pk.z));\n uint iBy = lvlToIdx(uint(pk.w));\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n outColor = uvec4(\n m0.x | (m1.x << 8u) | (iAx << 16u),\n (iAx >> 16u) | (iBx << 8u),\n m0.y | (m1.y << 8u) | (iAy << 16u),\n (iAy >> 16u) | (iBy << 8u)\n );\n}\n";
2190
2203
 
2191
2204
  // src/webgl/BC5WebGLEncoder.ts
2192
2205
  var BC5WebGLEncoder = class extends WebGLBlockEncoder {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gputex",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "dist"