gputex 0.3.0 → 0.3.1

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
@@ -33,10 +33,17 @@ bun add gputex
33
33
  | **BC7** | 16 (8 bpp) | Color / RGBA on desktop (`texture-compression-bc`) |
34
34
  | **BC5** | 16 (8 bpp) | Normal maps — RG only (`texture-compression-bc`) |
35
35
  | **ASTC 4x4** | 16 (8 bpp) | Color / RGBA on mobile / iOS (`texture-compression-astc`) |
36
- | **BC1** | 8 (4 bpp) | Legacy (never auto-selected) |
36
+ | **BC1** | 8 (4 bpp) | Opaque color at half BC7's size (opt-in) |
37
37
 
38
38
  Format selection is automatic: BC7/BC5 on desktop, ASTC on mobile, uncompressed RGBA8 fallback otherwise.
39
39
 
40
+ BC1 is never picked by default — it's half the memory of BC7 but visibly lower
41
+ quality, a trade-off only the application can make. Opt in per-texture with
42
+ `preferredFormat: 'bc1'`: on BC-capable devices the texture encodes as BC1;
43
+ everywhere else (e.g. ASTC-only mobile) selection proceeds as normal. The
44
+ preference is only honoured with `hint: 'color'`, since BC1 can't carry real
45
+ alpha or a normal map.
46
+
40
47
  ## WebGL fallback
41
48
 
42
49
  WebGPU is the primary path. When it's unavailable (older Safari, Firefox without WebGPU, locked-down environments) `compressTexture()` automatically falls back to a **WebGL2** path that runs the same family of block encoders as fragment shaders — each 4×4 block is computed in one fragment, written to an `RGBA32UI` render target, and read back. The WebGPU fast paths have since been rewritten for speed (projection assignment, f16), so the two backends are no longer byte-identical, but they implement the same algorithms at the same quality level and the resulting `CompressedTexture` looks the same under either renderer.
@@ -204,13 +211,14 @@ const tex = buildCompressedTexture([bytes], TextureFormat.BC7_SRGB)
204
211
 
205
212
  ### `compressTexture` options
206
213
 
207
- | Option | Type | Default | Description |
208
- | ------------ | -------------------- | --------- | ------------------------------------------------------- |
209
- | `hint` | `TextureHint` | `'color'` | `'color'`, `'colorWithAlpha'`, or `'normal'` |
210
- | `colorSpace` | `'srgb' \| 'linear'` | `'srgb'` | Use the sRGB or linear variant of the chosen format |
211
- | `flipY` | `boolean` | `true` | Flip vertically (matches Three.js convention) |
212
- | `mipmaps` | `boolean` | `false` | Generate full mip chain down to 1x1 |
213
- | `device` | `GPUDevice` | — | Reuse an existing WebGPU device instead of creating one |
214
+ | Option | Type | Default | Description |
215
+ | ----------------- | -------------------- | --------- | ------------------------------------------------------------------------------------------------ |
216
+ | `hint` | `TextureHint` | `'color'` | `'color'`, `'colorWithAlpha'`, or `'normal'` |
217
+ | `preferredFormat` | `'bc1'` | — | Prefer BC1 (half of BC7's size) when supported; normal selection otherwise. `hint: 'color'` only |
218
+ | `colorSpace` | `'srgb' \| 'linear'` | `'srgb'` | Use the sRGB or linear variant of the chosen format |
219
+ | `flipY` | `boolean` | `true` | Flip vertically (matches Three.js convention) |
220
+ | `mipmaps` | `boolean` | `false` | Generate full mip chain down to 1x1 |
221
+ | `device` | `GPUDevice` | — | Reuse an existing WebGPU device instead of creating one |
214
222
 
215
223
  ## Benchmarks
216
224
 
package/dist/index.d.ts CHANGED
@@ -377,9 +377,19 @@ declare function detectWebGLCapabilities(gl: ExtensionProvider): WebGLCapabiliti
377
377
  * • 'normal' — tangent-space normal map (R=x, G=y, z reconstructed).
378
378
  */
379
379
  type TextureHint = 'color' | 'colorWithAlpha' | 'normal';
380
+ /**
381
+ * Optional format preference — a wish, not a demand. Applied when the
382
+ * device supports the format and the hint is compatible; otherwise
383
+ * selection proceeds normally (BC7 → ASTC → null). Currently only
384
+ * 'bc1': half the memory of BC7 (0.5 vs 1 byte/pixel) for opaque
385
+ * colour, at visibly lower quality on smooth content.
386
+ */
387
+ type PreferredFormat = 'bc1';
380
388
  interface SelectFormatOptions {
381
389
  /** Pick the sRGB variant when the format has one. Default 'srgb'. */
382
390
  colorSpace?: 'srgb' | 'linear';
391
+ /** Prefer a specific format when supported. See `PreferredFormat`. */
392
+ preferredFormat?: PreferredFormat;
383
393
  }
384
394
  interface FormatSelection {
385
395
  /** null = no compressed path on this adapter; caller should fall back. */
@@ -430,4 +440,4 @@ declare function generateMipChain(level0: MipLevel): MipLevel[];
430
440
  */
431
441
  declare function padToBlockMultiple(level: MipLevel): MipLevel;
432
442
 
433
- export { ASTC4x4Encoder, ASTC4x4WebGLEncoder, BC1Encoder, BC1WebGLEncoder, BC5Encoder, BC5WebGLEncoder, BC7Encoder, BC7WebGLEncoder, type Capabilities, type EncodeBytesResult, type EncodeCallOptions, type EncodeQuality, Encoder, type EncoderConstructor, type EncoderImageSource, type EncoderOptions, type ExtensionProvider, type FeatureProvider, type FormatSelection, type FormatVariant, type MipLevel, type RawPixelSource, type SelectFormatOptions, TextureFormat, type TextureHint, WebGLBlockEncoder, type WebGLCapabilities, type WebGLEncodeBytesResult, type WebGLEncoderConstructor, type WebGLEncoderImageSource, type WebGLEncoderOptions, type WebGLFormatSelection, WebGPUFeature, createWebGLContext, detectCapabilities, detectWebGLCapabilities, generateMipChain, getSharedWebGLContext, isWebGLAvailable, padToBlockMultiple, selectFormat, selectWebGLFormat };
443
+ export { ASTC4x4Encoder, ASTC4x4WebGLEncoder, BC1Encoder, BC1WebGLEncoder, BC5Encoder, BC5WebGLEncoder, BC7Encoder, BC7WebGLEncoder, type Capabilities, type EncodeBytesResult, type EncodeCallOptions, type EncodeQuality, Encoder, type EncoderConstructor, type EncoderImageSource, type EncoderOptions, type ExtensionProvider, type FeatureProvider, type FormatSelection, type FormatVariant, type MipLevel, type PreferredFormat, type RawPixelSource, type SelectFormatOptions, TextureFormat, type TextureHint, WebGLBlockEncoder, type WebGLCapabilities, type WebGLEncodeBytesResult, type WebGLEncoderConstructor, type WebGLEncoderImageSource, type WebGLEncoderOptions, type WebGLFormatSelection, WebGPUFeature, createWebGLContext, detectCapabilities, detectWebGLCapabilities, generateMipChain, getSharedWebGLContext, isWebGLAvailable, padToBlockMultiple, selectFormat, selectWebGLFormat };
package/dist/index.js CHANGED
@@ -631,7 +631,171 @@ var BC5Encoder = class extends Encoder {
631
631
  var bc7_default = "// BC7 (BPTC) mode 6 compute shader encoder.\n//\n// One invocation per 4\xD74 block. Emits 16 bytes = 4 u32s into the storage\n// buffer at `dst[block_index * 4 .. + 3]`.\n//\n// QUALITY LEVELS (pipeline-overridable constant `QUALITY_HIGH`)\n// fast (0, default): O(N) bounding-box seed \u2192 one fused pass that projects\n// each pixel onto the endpoint line (the 16 palette entries are colinear,\n// so the nearest index is the rounded projection \u2014 no palette build, no\n// 16-entry search) while accumulating the least-squares refit sums, then\n// a reprojection against the quantised refit endpoints for the final\n// indices, packed on the fly into two nibble words.\n// high (1): farthest-pair seed, exhaustive p-bit search over all four\n// (p0,p1) \u2208 {0,1}\xB2 combos, full 16-entry nearest search, one LSQ refit \u2014\n// matches bc7_ref.ts up to FP tie-breaks.\n//\n// The fast/high branch is selected at pipeline-compile time, so the driver\n// eliminates the unused code entirely.\n//\n// MODE 6 LAYOUT (LSB-first, bit 0 = byte 0's bit 0)\n// bits 0..6 mode field (0b0000001 \u2014 only bit 6 is 1)\n// bits 7..13 R0 (7-bit) bits 14..20 R1 bits 21..27 G0 bits 28..34 G1\n// bits 35..41 B0 bits 42..48 B1 bits 49..55 A0 bits 56..62 A1\n// bit 63 P0 bit 64 P1\n// bits 65..67 pixel 0 index (3 bits; anchor, MSB implicit 0)\n// bits 68..71 pixel 1 index (4 bits) ... bits 124..127 pixel 15 index\n//\n// Effective 8-bit endpoint channel = (7_bit_value << 1) | p_bit.\n// Palette[i] = ((64 \u2212 W4[i]) \xD7 e0_8 + W4[i] \xD7 e1_8 + 32) >> 6, integer.\n//\n// The block is assembled with straight-line constant shifts (see the layout\n// summary in bc7_fast_f16.wgsl) \u2014 a generic write_bits() helper's dynamic\n// word indexing keeps the output array out of registers.\n\n// 0 = fast (default), 1 = exhaustive/high-quality. Set via pipeline constants.\noverride QUALITY_HIGH: u32 = 0u;\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\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\n// Mode 6 interpolation weights (\xD7 1/64), fixed by the spec (`W4` in bc7_ref.ts).\nfn w4(i: u32) -> u32 {\n switch i {\n case 0u: { return 0u; }\n case 1u: { return 4u; }\n case 2u: { return 9u; }\n case 3u: { return 13u; }\n case 4u: { return 17u; }\n case 5u: { return 21u; }\n case 6u: { return 26u; }\n case 7u: { return 30u; }\n case 8u: { return 34u; }\n case 9u: { return 38u; }\n case 10u: { return 43u; }\n case 11u: { return 47u; }\n case 12u: { return 51u; }\n case 13u: { return 55u; }\n case 14u: { return 60u; }\n default: { return 64u; } // case 15u\n }\n}\n\nfn interp4(e0: vec4<i32>, e1: vec4<i32>, w: i32) -> vec4<i32> {\n return ((64 - w) * e0 + w * e1 + vec4<i32>(32)) >> vec4<u32>(6u);\n}\n\nfn to8(v: vec4<f32>) -> vec4<i32> {\n return vec4<i32>(clamp(floor(v * 255.0 + 0.5), vec4<f32>(0.0), vec4<f32>(255.0)));\n}\n\nfn dist2(a: vec4<i32>, b: vec4<i32>) -> i32 {\n let d = a - b;\n let e = d * d;\n return e.x + e.y + e.z + e.w;\n}\n\n// Quantize an 8-bit ideal endpoint to (7-bit value, reconstructed 8-bit) under\n// a fixed p-bit, all four channels at once. q7 = round((ideal8 \u2212 p)/2); used by\n// both paths.\nstruct QuantPair { seven: vec4<i32>, eight: vec4<i32> };\nfn quantize_endpoint(ideal8: vec4<i32>, p: u32) -> QuantPair {\n let q = vec4<i32>(clamp(\n floor((vec4<f32>(ideal8) - f32(p)) / 2.0 + 0.5),\n vec4<f32>(0.0), vec4<f32>(127.0),\n ));\n let eff = (q << vec4<u32>(1u)) | vec4<i32>(i32(p));\n return QuantPair(q, eff);\n}\n\n// ============================ FAST PATH ================================ //\n\n// Endpoint with its chosen p-bit, picked by minimum quantisation error.\nstruct Ep { seven: vec4<i32>, eight: vec4<i32>, p: u32 };\nfn pick_ep(ideal: vec4<i32>) -> Ep {\n let a = quantize_endpoint(ideal, 0u);\n let b = quantize_endpoint(ideal, 1u);\n if (dist2(b.eight, ideal) < dist2(a.eight, ideal)) { return Ep(b.seven, b.eight, 1u); }\n return Ep(a.seven, a.eight, 0u);\n}\n\n// One pass over the block: project every pixel onto the e0\u2192e1 line and\n// accumulate the least-squares normal-equation sums; solve for the refit\n// endpoints (in 8-bit space). Indices are not produced here \u2014 the caller\n// reprojects against the quantised refit endpoints anyway.\nstruct Fit { e0: vec4<i32>, e1: vec4<i32>, valid: bool };\nfn proj_fit(pixels: ptr<function, array<vec4<i32>, 16>>, e0: vec4<i32>, e1: vec4<i32>) -> Fit {\n var out: Fit;\n out.valid = false;\n let dir = vec4<f32>(e1 - e0);\n let dd = dot(dir, dir);\n if (dd == 0.0) { return out; }\n let e0f = vec4<f32>(e0);\n let inv = 15.0 / dd;\n var sAA: f32 = 0.0; var sBB: f32 = 0.0; var sAB: f32 = 0.0;\n var sAV: vec4<f32> = vec4<f32>(0.0); var sBV: vec4<f32> = vec4<f32>(0.0);\n var s_min = 15.0; var s_max = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let v = vec4<f32>((*pixels)[k]);\n let s = clamp(floor(dot(v - e0f, dir) * inv + 0.5), 0.0, 15.0);\n s_min = min(s_min, s); s_max = max(s_max, s);\n let b = s * (1.0 / 15.0); let a = 1.0 - b;\n sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;\n sAV = sAV + a * v; sBV = sBV + b * v;\n }\n // Rank-1 guard: if every pixel projects to ONE level the system is\n // singular \u2014 det and the numerators are pure float rounding noise and the\n // solve returns garbage endpoints. With \u22652 levels det \u2265 15/225 \u2248 0.067.\n if (s_min == s_max) { return out; }\n let det = sAA * sBB - sAB * sAB;\n if (abs(det) < 1e-3) { return out; }\n out.e0 = vec4<i32>(clamp(round((sBB * sAV - sAB * sBV) / det), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.e1 = vec4<i32>(clamp(round((sAA * sBV - sAB * sAV) / det), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.valid = true;\n return out;\n}\n\n// ============================ HIGH PATH ================================ //\n\nstruct Pair { a: vec4<i32>, b: vec4<i32> };\nfn farthest_pair(pixels: ptr<function, array<vec4<i32>, 16>>) -> Pair {\n var best_d: i32 = 0;\n var pa = (*pixels)[0];\n var pb = (*pixels)[1];\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let xi = (*pixels)[i];\n for (var j: u32 = i + 1u; j < 16u; j = j + 1u) {\n let d = dist2(xi, (*pixels)[j]);\n if (d > best_d) { best_d = d; pa = xi; pb = (*pixels)[j]; }\n }\n }\n return Pair(pa, pb);\n}\n\nfn build_palette_6(e0: vec4<i32>, e1: vec4<i32>, pal: ptr<function, array<vec4<i32>, 16>>) {\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n (*pal)[i] = interp4(e0, e1, i32(w4(i)));\n }\n}\n\nfn assign_all(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n pal: ptr<function, array<vec4<i32>, 16>>,\n out_idx: ptr<function, array<u32, 16>>,\n) -> i32 {\n var err: i32 = 0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let px = (*pixels)[k];\n var best_i: u32 = 0u;\n var best_d: i32 = 2147483647;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let d = dist2(px, (*pal)[i]);\n if (d < best_d) { best_d = d; best_i = i; }\n }\n (*out_idx)[k] = best_i;\n err = err + best_d;\n }\n return err;\n}\n\nstruct BestMode6 {\n e0_7: vec4<i32>, e1_7: vec4<i32>,\n p0: u32, p1: u32,\n indices: array<u32, 16>,\n err: i32,\n};\n\n// Exhaustive p-bit search (high path); commits to `*best` only on improvement.\nfn try_pbit_combos(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n ideal0: vec4<i32>,\n ideal1: vec4<i32>,\n best: ptr<function, BestMode6>,\n) {\n var local_best = (*best).err;\n var pal: array<vec4<i32>, 16>;\n var tmp: array<u32, 16>;\n for (var p0: u32 = 0u; p0 < 2u; p0 = p0 + 1u) {\n let q0 = quantize_endpoint(ideal0, p0);\n for (var p1: u32 = 0u; p1 < 2u; p1 = p1 + 1u) {\n let q1 = quantize_endpoint(ideal1, p1);\n build_palette_6(q0.eight, q1.eight, &pal);\n let err = assign_all(pixels, &pal, &tmp);\n if (err < local_best) {\n local_best = err;\n (*best).e0_7 = q0.seven;\n (*best).e1_7 = q1.seven;\n (*best).p0 = p0;\n (*best).p1 = p1;\n (*best).indices = tmp;\n (*best).err = err;\n }\n }\n }\n}\n\n// Exact-weight LSQ refit (high path); matches bc7_ref.ts `refitEndpointsMode6`.\nstruct RefitResult { e0: vec4<i32>, e1: vec4<i32>, valid: bool };\nfn refit_endpoints(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n indices: ptr<function, array<u32, 16>>,\n) -> RefitResult {\n var sAA: f32 = 0.0;\n var sBB: f32 = 0.0;\n var sAB: f32 = 0.0;\n var sAV: vec4<f32> = vec4<f32>(0.0);\n var sBV: vec4<f32> = vec4<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let i = (*indices)[k];\n let a = f32(64u - w4(i)) / 64.0;\n let b = f32(w4(i)) / 64.0;\n let v = vec4<f32>((*pixels)[k]);\n sAA = sAA + a * a;\n sBB = sBB + b * b;\n sAB = sAB + a * b;\n sAV = sAV + a * v;\n sBV = sBV + b * v;\n }\n let det = sAA * sBB - sAB * sAB;\n var out: RefitResult;\n if (abs(det) < 1e-9) {\n out.valid = false;\n return out;\n }\n let e0f = (sBB * sAV - sAB * sBV) / det;\n let e1f = (sAA * sBV - sAB * sAV) / det;\n out.e0 = vec4<i32>(clamp(round(e0f), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.e1 = vec4<i32>(clamp(round(e1f), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.valid = true;\n return out;\n}\n\n// ------------------------------- Entry --------------------------------- //\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Load 16 RGBA pixels (8-bit integer domain) and the per-channel bbox.\n var pixels: array<vec4<i32>, 16>;\n var lo = vec4<i32>(255);\n var hi = vec4<i32>(0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let px = to8(textureLoad(src_tex, p, 0));\n pixels[i] = px;\n lo = min(lo, px);\n hi = max(hi, px);\n }\n\n // Both branches produce: 7-bit endpoints + p-bits, and the 16 4-bit indices\n // packed LSB-first into two nibble words (pixel k \u2192 bits 4k..4k+3).\n var e0_7: vec4<i32>;\n var e1_7: vec4<i32>;\n var p0: u32;\n var p1: u32;\n var ilo: u32 = 0u;\n var ihi: u32 = 0u;\n\n if (QUALITY_HIGH != 0u) {\n let fp = farthest_pair(&pixels);\n var best: BestMode6;\n best.err = 2147483647;\n try_pbit_combos(&pixels, fp.a, fp.b, &best);\n let refit = refit_endpoints(&pixels, &best.indices);\n if (refit.valid) {\n try_pbit_combos(&pixels, refit.e0, refit.e1, &best);\n }\n e0_7 = best.e0_7; e1_7 = best.e1_7; p0 = best.p0; p1 = best.p1;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n ilo = ilo | (best.indices[k] << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n ihi = ihi | (best.indices[k] << ((k - 8u) * 4u));\n }\n } else {\n // Seed the fused LSQ fit from the raw bbox, then quantise the refit\n // endpoints and reproject for the final indices.\n let r = proj_fit(&pixels, lo, hi);\n var ep0: Ep;\n var ep1: Ep;\n if (r.valid) { ep0 = pick_ep(r.e0); ep1 = pick_ep(r.e1); }\n else { ep0 = pick_ep(lo); ep1 = pick_ep(hi); }\n let dir = vec4<f32>(ep1.eight - ep0.eight);\n let dd = dot(dir, dir);\n if (dd > 0.0) {\n let e0f = vec4<f32>(ep0.eight);\n let inv = 15.0 / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 15.0);\n ilo = ilo | (u32(s) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 15.0);\n ihi = ihi | (u32(s) << ((k - 8u) * 4u));\n }\n }\n e0_7 = ep0.seven; e1_7 = ep1.seven; p0 = ep0.p; p1 = ep1.p;\n }\n\n // Anchor rule \u2014 pixel 0's index MSB must be 0. Swapping endpoints reflects\n // every index (i \u2192 15\u2212i), which on packed nibbles is a bitwise NOT.\n if ((ilo & 0x8u) != 0u) {\n let t7 = e0_7; e0_7 = e1_7; e1_7 = t7;\n let tp = p0; p0 = p1; p1 = tp;\n ilo = ~ilo; ihi = ~ihi;\n }\n\n // Straight-line mode-6 packing (see layout at the top of the file).\n let e0 = vec4<u32>(e0_7);\n let e1 = vec4<u32>(e1_7);\n let w0 = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);\n let w1 = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (p0 << 31u);\n let w2 = p1 | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n let w3 = ihi;\n\n let out = block_index * 4u;\n dst[out + 0u] = w0;\n dst[out + 1u] = w1;\n dst[out + 2u] = w2;\n dst[out + 3u] = w3;\n}\n";
632
632
 
633
633
  // src/bc7_fast_f16.wgsl
634
- var bc7_fast_f16_default = '// bc7 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).\n// Same algorithm family as the f32 fast path in bc7.wgsl (bbox seed \u2192\n// projection-based index assignment with a fused least-squares refit \u2192\n// reproject), tuned for throughput:\n//\n// \u2022 All projection / refit math in f16 ([0,1] domain, so dot products stay\n// well inside f16 range). ~2\xD7 ALU throughput on f16-capable GPUs.\n// \u2022 The LSQ seed pass projects against the RAW bbox endpoints \u2014 quantising\n// the seed first (pick_ep) costs two extra quantisation searches and\n// doesn\'t measurably change where the refit lands.\n// \u2022 Indices are packed into two u32 nibble words ON THE FLY during the\n// final projection pass \u2014 no array<u32,16> private array. The BC7 anchor\n// reflection (i \u2192 15\u2212i) is then just a bitwise NOT of both words.\n// \u2022 The 128-bit block is assembled with straight-line constant shifts\n// instead of a generic write_bits() helper (whose dynamic word indexing\n// defeats register promotion of the output array).\n//\n// The host selects this module only when the device reports shader-f16,\n// falling back to bc7.wgsl otherwise. "high" never uses this.\n//\n// MODE 6 BIT LAYOUT (LSB-first): see bc7.wgsl. Summary:\n// w0: mode(7 bits, 0x40) R0 R1 G0 G1[3:0]\n// w1: G1[6:4] B0 B1 A0 A1 P0\n// w2: P1, pixel0 index (3 bits), pixels 1..7 (4 bits each)\n// w3: pixels 8..15 (4 bits each)\nenable f16;\nstruct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };\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;\nalias h = f16;\nalias h4 = vec4<f16>;\n\n// Quantise an ideal endpoint (h4 in [0,1]) to 7-bit + p-bit, choosing the\n// p-bit with the lower quantisation error. `eight` is the decoded value the\n// hardware will interpolate with, back in [0,1].\nstruct Ep { seven: vec4<u32>, eight: h4, p: u32 };\nfn pick_ep(ideal01: h4) -> Ep {\n let ideal = ideal01 * h(255.0);\n let q0 = clamp(floor(ideal * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=0\n let e0 = q0 * h(2.0);\n let q1 = clamp(floor((ideal - h(1.0)) * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=1\n let e1 = q1 * h(2.0) + h(1.0);\n let d0 = e0 - ideal; let d1 = e1 - ideal;\n if (dot(d1, d1) < dot(d0, d0)) { return Ep(vec4<u32>(q1), e1 * h(1.0 / 255.0), 1u); }\n return Ep(vec4<u32>(q0), e0 * h(1.0 / 255.0), 0u);\n}\n\n// One pass over the block: project every pixel onto the e0\u2192e1 line and\n// accumulate the least-squares normal-equation sums; solve for the refit\n// endpoints. Indices are NOT produced here \u2014 the caller reprojects against\n// the quantised refit endpoints anyway.\nstruct Fit { e0: h4, e1: h4, valid: bool };\nfn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {\n var out: Fit;\n out.valid = false;\n let dir = e1 - e0;\n let dd = dot(dir, dir);\n if (dd == h(0.0)) { return out; }\n let inv = h(15.0) / dd;\n var sAA = h(0.0); var sBB = h(0.0); var sAB = h(0.0);\n var sAV = h4(0.0); var sBV = h4(0.0);\n var s_min = h(15.0); var s_max = h(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let v = (*pix)[k];\n let s = clamp(floor(dot(v - e0, dir) * inv + h(0.5)), h(0.0), h(15.0));\n s_min = min(s_min, s); s_max = max(s_max, s);\n let b = s * h(1.0 / 15.0); let a = h(1.0) - b;\n sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;\n sAV = sAV + a * v; sBV = sBV + b * v;\n }\n // Rank-1 guard: if every pixel projects to ONE level the system is\n // singular \u2014 det/numerators are pure f16 rounding noise and the solve\n // returns garbage endpoints. With \u22652 distinct levels\n // det = \u03A3_i<j (b_j \u2212 b_i)\xB2 \u2265 15/225 \u2248 0.067, so 0.02 is a safe floor.\n if (s_min == s_max) { return out; }\n let det = sAA * sBB - sAB * sAB;\n if (abs(det) < h(0.02)) { return out; }\n out.e0 = clamp((sBB * sAV - sAB * sBV) / det, h4(0.0), h4(1.0));\n out.e1 = clamp((sAA * sBV - sAB * sAV) / det, h4(0.0), h4(1.0));\n out.valid = true;\n return out;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var pix: array<h4, 16>;\n var lo = h4(1.0);\n var hi = h4(0.0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);\n let px = h4(textureLoad(src_tex, p, 0));\n pix[i] = px; lo = min(lo, px); hi = max(hi, px);\n }\n\n // Seed fit from the raw bbox, then quantise the refit endpoints.\n let r = proj_fit(&pix, lo, hi);\n var ep0: Ep;\n var ep1: Ep;\n if (r.valid) { ep0 = pick_ep(r.e0); ep1 = pick_ep(r.e1); }\n else { ep0 = pick_ep(lo); ep1 = pick_ep(hi); }\n\n // Final projection against the decoded endpoints, packing the 4-bit indices\n // into two nibble words as we go (pixel k \u2192 bits 4k..4k+3 of ilo/ihi).\n var ilo: u32 = 0u;\n var ihi: u32 = 0u;\n let dir = ep1.eight - ep0.eight;\n let dd = dot(dir, dir);\n if (dd > h(0.0)) {\n let inv = h(15.0) / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - ep0.eight, dir) * inv + h(0.5)), h(0.0), h(15.0));\n ilo = ilo | (u32(s) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - ep0.eight, dir) * inv + h(0.5)), h(0.0), h(15.0));\n ihi = ihi | (u32(s) << ((k - 8u) * 4u));\n }\n }\n\n // Anchor rule \u2014 pixel 0\'s index MSB must be 0. Swapping endpoints reflects\n // every index (i \u2192 15\u2212i), which on packed nibbles is a bitwise NOT.\n if ((ilo & 0x8u) != 0u) {\n let t = ep0; ep0 = ep1; ep1 = t;\n ilo = ~ilo; ihi = ~ihi;\n }\n\n // Straight-line mode-6 packing (see layout above).\n let e0 = ep0.seven;\n let e1 = ep1.seven;\n let w0 = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);\n let w1 = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (ep0.p << 31u);\n let w2 = ep1.p | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n let w3 = ihi;\n\n let o = bi * 4u;\n dst[o] = w0; dst[o + 1u] = w1; dst[o + 2u] = w2; dst[o + 3u] = w3;\n}\n';
634
+ var bc7_fast_f16_default = `// bc7 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
635
+ // Same algorithm family as the f32 fast path in bc7.wgsl (bbox seed \u2192
636
+ // projection-based index assignment with a fused least-squares refit \u2192
637
+ // reproject), tuned for throughput:
638
+ //
639
+ // \u2022 All projection / refit math in f16 ([0,1] domain). ~2\xD7 ALU throughput
640
+ // on f16-capable GPUs. The projection direction is pre-scaled by 32:
641
+ // a shallow block (endpoints ~1/255 apart) has dd = dot(dir,dir) \u2248 1.5e-5,
642
+ // where 15/dd \u2248 10\u2076 overflows f16 (max 65504) to +inf and the products
643
+ // inside the projection dot are subnormal \u2014 the indices and the LSQ refit
644
+ // feeding on them turn to garbage (visible as banding on smooth
645
+ // gradients). Scaling dir by 32 multiplies the dots by 32 and dd by 1024;
646
+ // s = dot\xB7(32\xB715/dd\u2083\u2082) is the same quantity with every intermediate in
647
+ // f16's normal range (worst case inv = 480/0.0157 \u2248 3.0e4 < 65504).
648
+ // \u2022 The LSQ seed pass projects against the RAW bbox endpoints \u2014 quantising
649
+ // the seed first (pick_ep) costs two extra quantisation searches and
650
+ // doesn't measurably change where the refit lands.
651
+ // \u2022 Indices are packed into two u32 nibble words ON THE FLY during the
652
+ // final projection pass \u2014 no array<u32,16> private array. The BC7 anchor
653
+ // reflection (i \u2192 15\u2212i) is then just a bitwise NOT of both words.
654
+ // \u2022 The 128-bit block is assembled with straight-line constant shifts
655
+ // instead of a generic write_bits() helper (whose dynamic word indexing
656
+ // defeats register promotion of the output array).
657
+ //
658
+ // The host selects this module only when the device reports shader-f16,
659
+ // falling back to bc7.wgsl otherwise. "high" never uses this.
660
+ //
661
+ // MODE 6 BIT LAYOUT (LSB-first): see bc7.wgsl. Summary:
662
+ // w0: mode(7 bits, 0x40) R0 R1 G0 G1[3:0]
663
+ // w1: G1[6:4] B0 B1 A0 A1 P0
664
+ // w2: P1, pixel0 index (3 bits), pixels 1..7 (4 bits each)
665
+ // w3: pixels 8..15 (4 bits each)
666
+ enable f16;
667
+ struct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };
668
+ @group(0) @binding(0) var src_tex: texture_2d<f32>;
669
+ @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
670
+ @group(0) @binding(2) var<uniform> params: Params;
671
+ alias h = f16;
672
+ alias h4 = vec4<f16>;
673
+
674
+ // Quantise an ideal endpoint (h4 in [0,1]) to 7-bit + p-bit, choosing the
675
+ // p-bit with the lower quantisation error. \`eight\` is the decoded value the
676
+ // hardware will interpolate with, back in [0,1].
677
+ struct Ep { seven: vec4<u32>, eight: h4, p: u32 };
678
+ fn pick_ep(ideal01: h4) -> Ep {
679
+ let ideal = ideal01 * h(255.0);
680
+ let q0 = clamp(floor(ideal * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=0
681
+ let e0 = q0 * h(2.0);
682
+ let q1 = clamp(floor((ideal - h(1.0)) * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=1
683
+ let e1 = q1 * h(2.0) + h(1.0);
684
+ let d0 = e0 - ideal; let d1 = e1 - ideal;
685
+ if (dot(d1, d1) < dot(d0, d0)) { return Ep(vec4<u32>(q1), e1 * h(1.0 / 255.0), 1u); }
686
+ return Ep(vec4<u32>(q0), e0 * h(1.0 / 255.0), 0u);
687
+ }
688
+
689
+ // One pass over the block: project every pixel onto the e0\u2192e1 line and
690
+ // accumulate the least-squares normal-equation sums; solve for the refit
691
+ // endpoints. Indices are NOT produced here \u2014 the caller reprojects against
692
+ // the quantised refit endpoints anyway.
693
+ //
694
+ // The value sums accumulate v \u2212 e0, not v: the basis is affine (a + b = 1),
695
+ // so fitting the shifted data and adding e0 back is the same fit, but the
696
+ // accumulators scale with the block's span instead of its absolute level \u2014
697
+ // on a shallow dark block, f16 rounding of absolute sums (ulp \u2248 0.12 of an
698
+ // 8-bit level per add) drifts the refit endpoints by \xB11 level.
699
+ struct Fit { e0: h4, e1: h4, valid: bool };
700
+ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
701
+ var out: Fit;
702
+ out.valid = false;
703
+ // dir pre-scaled by 32 to keep dd and the projection dots in f16's normal
704
+ // range (see header). Spans below ~0.7 of an 8-bit step (dd\u2083\u2082 < 0.008,
705
+ // possible only for non-8-bit sources) are treated as flat \u2014 encoding them
706
+ // flat is under half a level of error, while running the math on them risks
707
+ // inv overflowing to +inf.
708
+ let dir = (e1 - e0) * h(32.0);
709
+ let dd = dot(dir, dir);
710
+ if (dd < h(0.008)) { return out; }
711
+ let inv = h(480.0) / dd; // 32\xB715/dd\u2083\u2082 \u2261 15/dd
712
+ var sAA = h(0.0); var sBB = h(0.0); var sAB = h(0.0);
713
+ var sAV = h4(0.0); var sBV = h4(0.0);
714
+ var s_min = h(15.0); var s_max = h(0.0);
715
+ for (var k: u32 = 0u; k < 16u; k = k + 1u) {
716
+ let vr = (*pix)[k] - e0;
717
+ let s = clamp(floor(dot(vr, dir) * inv + h(0.5)), h(0.0), h(15.0));
718
+ s_min = min(s_min, s); s_max = max(s_max, s);
719
+ let b = s * h(1.0 / 15.0); let a = h(1.0) - b;
720
+ sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;
721
+ sAV = sAV + a * vr; sBV = sBV + b * vr;
722
+ }
723
+ // Rank-1 guard: if every pixel projects to ONE level the system is
724
+ // singular \u2014 det/numerators are pure f16 rounding noise and the solve
725
+ // returns garbage endpoints. With \u22652 distinct levels
726
+ // det = \u03A3_i<j (b_j \u2212 b_i)\xB2 \u2265 15/225 \u2248 0.067, so 0.02 is a safe floor.
727
+ if (s_min == s_max) { return out; }
728
+ let det = sAA * sBB - sAB * sAB;
729
+ if (abs(det) < h(0.02)) { return out; }
730
+ out.e0 = clamp(e0 + (sBB * sAV - sAB * sBV) / det, h4(0.0), h4(1.0));
731
+ out.e1 = clamp(e0 + (sAA * sBV - sAB * sAV) / det, h4(0.0), h4(1.0));
732
+ out.valid = true;
733
+ return out;
734
+ }
735
+
736
+ @compute @workgroup_size(8, 8, 1)
737
+ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
738
+ if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }
739
+ let bi = gid.y * params.blocks_x + gid.x;
740
+ let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);
741
+ let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);
742
+
743
+ var pix: array<h4, 16>;
744
+ var lo = h4(1.0);
745
+ var hi = h4(0.0);
746
+ for (var i: u32 = 0u; i < 16u; i = i + 1u) {
747
+ let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);
748
+ let px = h4(textureLoad(src_tex, p, 0));
749
+ pix[i] = px; lo = min(lo, px); hi = max(hi, px);
750
+ }
751
+
752
+ // Seed fit from the raw bbox, then quantise the refit endpoints.
753
+ let r = proj_fit(&pix, lo, hi);
754
+ var ep0: Ep;
755
+ var ep1: Ep;
756
+ if (r.valid) { ep0 = pick_ep(r.e0); ep1 = pick_ep(r.e1); }
757
+ else { ep0 = pick_ep(lo); ep1 = pick_ep(hi); }
758
+
759
+ // Final projection against the decoded endpoints, packing the 4-bit indices
760
+ // into two nibble words as we go (pixel k \u2192 bits 4k..4k+3 of ilo/ihi).
761
+ var ilo: u32 = 0u;
762
+ var ihi: u32 = 0u;
763
+ // Same \xD732 pre-scale as proj_fit; distinct quantised endpoints are \u22651/255
764
+ // apart, i.e. dd\u2083\u2082 \u2265 0.0157, so the flat-block threshold only catches
765
+ // truly identical endpoints.
766
+ let dir = (ep1.eight - ep0.eight) * h(32.0);
767
+ let dd = dot(dir, dir);
768
+ if (dd >= h(0.008)) {
769
+ let inv = h(480.0) / dd;
770
+ for (var k: u32 = 0u; k < 8u; k = k + 1u) {
771
+ let s = clamp(floor(dot(pix[k] - ep0.eight, dir) * inv + h(0.5)), h(0.0), h(15.0));
772
+ ilo = ilo | (u32(s) << (k * 4u));
773
+ }
774
+ for (var k: u32 = 8u; k < 16u; k = k + 1u) {
775
+ let s = clamp(floor(dot(pix[k] - ep0.eight, dir) * inv + h(0.5)), h(0.0), h(15.0));
776
+ ihi = ihi | (u32(s) << ((k - 8u) * 4u));
777
+ }
778
+ }
779
+
780
+ // Anchor rule \u2014 pixel 0's index MSB must be 0. Swapping endpoints reflects
781
+ // every index (i \u2192 15\u2212i), which on packed nibbles is a bitwise NOT.
782
+ if ((ilo & 0x8u) != 0u) {
783
+ let t = ep0; ep0 = ep1; ep1 = t;
784
+ ilo = ~ilo; ihi = ~ihi;
785
+ }
786
+
787
+ // Straight-line mode-6 packing (see layout above).
788
+ let e0 = ep0.seven;
789
+ let e1 = ep1.seven;
790
+ let w0 = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);
791
+ let w1 = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (ep0.p << 31u);
792
+ let w2 = ep1.p | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);
793
+ let w3 = ihi;
794
+
795
+ let o = bi * 4u;
796
+ dst[o] = w0; dst[o + 1u] = w1; dst[o + 2u] = w2; dst[o + 3u] = w3;
797
+ }
798
+ `;
635
799
 
636
800
  // src/BC7Encoder.ts
637
801
  var BC7Encoder = class extends Encoder {
@@ -669,7 +833,13 @@ var astc4x4_fast_f16_default = `// astc4x4 "fast" encoder \u2014 f16 variant (re
669
833
  // projection weight assignment with a fused least-squares refit \u2192
670
834
  // reproject), tuned for throughput:
671
835
  //
672
- // \u2022 All projection / refit math in f16 ([0,1] domain).
836
+ // \u2022 All projection / refit math in f16 ([0,1] domain). The projection
837
+ // direction is pre-scaled by 32 \u2014 a shallow block (endpoints ~1/255
838
+ // apart) has dd \u2248 1.5e-5, where 3/dd \u2248 2e5 overflows f16 (max 65504) to
839
+ // +inf and the projection dots go subnormal, turning weights and the LSQ
840
+ // refit to garbage (banding on smooth gradients). Scaling dir by 32 puts
841
+ // every intermediate in f16's normal range; s = dot\xB7(32\xB73/dd\u2083\u2082) is the
842
+ // same quantity (worst case inv = 96/0.0157 \u2248 6.1e3).
673
843
  // \u2022 The seed pass only accumulates the LSQ sums (no weight output) \u2014 the
674
844
  // final weights come from reprojecting against the refit endpoints.
675
845
  // \u2022 Endpoint ordering (the blue-contraction rule: sum(e0.rgb) must not
@@ -696,20 +866,27 @@ struct Fit { e0: h4, e1: h4, valid: bool };
696
866
  fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
697
867
  var out: Fit;
698
868
  out.valid = false;
699
- let dir = e1 - e0;
869
+ // dir pre-scaled by 32 to keep dd and the projection dots in f16's normal
870
+ // range (see header). Spans below ~0.7 of an 8-bit step (dd\u2083\u2082 < 0.008,
871
+ // possible only for non-8-bit sources) are treated as flat.
872
+ let dir = (e1 - e0) * h(32.0);
700
873
  let dd = dot(dir, dir);
701
- if (dd == h(0.0)) { return out; }
702
- let inv = h(3.0) / dd;
874
+ if (dd < h(0.008)) { return out; }
875
+ let inv = h(96.0) / dd; // 32\xB73/dd\u2083\u2082 \u2261 3/dd
703
876
  var sAA = h(0.0); var sBB = h(0.0); var sAB = h(0.0);
704
877
  var sAV = h4(0.0); var sBV = h4(0.0);
705
878
  var s_min = h(3.0); var s_max = h(0.0);
879
+ // Value sums accumulate v \u2212 e0 (basis is affine, a + b = 1, so the fit
880
+ // commutes with the shift): accumulators scale with the block span, keeping
881
+ // f16 rounding a fraction of the span instead of \xB11 level at high absolute
882
+ // values.
706
883
  for (var k: u32 = 0u; k < 16u; k = k + 1u) {
707
- let v = (*pix)[k];
708
- let s = clamp(floor(dot(v - e0, dir) * inv + h(0.5)), h(0.0), h(3.0));
884
+ let vr = (*pix)[k] - e0;
885
+ let s = clamp(floor(dot(vr, dir) * inv + h(0.5)), h(0.0), h(3.0));
709
886
  s_min = min(s_min, s); s_max = max(s_max, s);
710
887
  let b = s * h(1.0 / 3.0); let a = h(1.0) - b;
711
888
  sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;
712
- sAV = sAV + a * v; sBV = sBV + b * v;
889
+ sAV = sAV + a * vr; sBV = sBV + b * vr;
713
890
  }
714
891
  // Rank-1 guard: if every pixel projects to ONE level the system is
715
892
  // singular \u2014 det/numerators are pure f16 rounding noise and the solve
@@ -718,8 +895,8 @@ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
718
895
  if (s_min == s_max) { return out; }
719
896
  let det = sAA * sBB - sAB * sAB;
720
897
  if (abs(det) < h(0.5)) { return out; }
721
- out.e0 = clamp((sBB * sAV - sAB * sBV) / det, h4(0.0), h4(1.0));
722
- out.e1 = clamp((sAA * sBV - sAB * sAV) / det, h4(0.0), h4(1.0));
898
+ out.e0 = clamp(e0 + (sBB * sAV - sAB * sBV) / det, h4(0.0), h4(1.0));
899
+ out.e1 = clamp(e0 + (sAA * sBV - sAB * sAV) / det, h4(0.0), h4(1.0));
723
900
  out.valid = true;
724
901
  return out;
725
902
  }
@@ -762,10 +939,12 @@ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
762
939
  // Weight pass, packing on the fly: weight k's lsb at bit 31\u22122k of the last
763
940
  // word, msb at bit 30\u22122k.
764
941
  var w3: u32 = 0u;
765
- let dir = d1 - d0;
942
+ // Same \xD732 pre-scale as proj_fit; distinct 8-bit endpoints are \u22651/255
943
+ // apart (dd\u2083\u2082 \u2265 0.0157), so the threshold only catches identical ones.
944
+ let dir = (d1 - d0) * h(32.0);
766
945
  let dd = dot(dir, dir);
767
- if (dd > h(0.0)) {
768
- let inv = h(3.0) / dd;
946
+ if (dd >= h(0.008)) {
947
+ let inv = h(96.0) / dd;
769
948
  for (var k: u32 = 0u; k < 16u; k = k + 1u) {
770
949
  let s = u32(clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(3.0)));
771
950
  w3 = w3 | ((s & 1u) << (31u - 2u * k)) | (((s >> 1u) & 1u) << (30u - 2u * k));
@@ -1114,13 +1293,26 @@ function detectWebGLCapabilities(gl) {
1114
1293
  // src/webgl/selectWebGLFormat.ts
1115
1294
  var NONE = { format: null, encoderClass: null, astcNormalRemap: false };
1116
1295
  function selectWebGLFormat(caps, hint, options = {}) {
1117
- const { colorSpace = "srgb" } = options;
1296
+ const { colorSpace = "srgb", preferredFormat } = options;
1118
1297
  const srgb = colorSpace === "srgb";
1119
1298
  const astc = (astcNormalRemap) => ({
1120
1299
  format: srgb ? TextureFormat.ASTC_4x4_SRGB : TextureFormat.ASTC_4x4,
1121
1300
  encoderClass: ASTC4x4WebGLEncoder,
1122
1301
  astcNormalRemap
1123
1302
  });
1303
+ if (preferredFormat === "bc1") {
1304
+ if (hint !== "color") {
1305
+ console.warn(
1306
+ `[gputex] preferredFormat 'bc1' ignored for hint '${hint}' \u2014 BC1 has no real alpha channel and is unsuitable for normal maps.`
1307
+ );
1308
+ } else if (srgb ? caps.s3tcSrgb : caps.s3tc) {
1309
+ return {
1310
+ format: srgb ? TextureFormat.BC1_SRGB : TextureFormat.BC1,
1311
+ encoderClass: BC1WebGLEncoder,
1312
+ astcNormalRemap: false
1313
+ };
1314
+ }
1315
+ }
1124
1316
  if (hint === "normal") {
1125
1317
  if (caps.rgtc) return { format: TextureFormat.BC5, encoderClass: BC5WebGLEncoder, astcNormalRemap: false };
1126
1318
  if (caps.astc) return astc(true);
@@ -1147,9 +1339,22 @@ function selectWebGLFormat(caps, hint, options = {}) {
1147
1339
 
1148
1340
  // src/selectFormat.ts
1149
1341
  function selectFormat(adapter, hint, options = {}) {
1150
- const { colorSpace = "srgb" } = options;
1342
+ const { colorSpace = "srgb", preferredFormat } = options;
1151
1343
  const srgb = colorSpace === "srgb";
1152
1344
  const caps = detectCapabilities(adapter);
1345
+ if (preferredFormat === "bc1") {
1346
+ if (hint !== "color") {
1347
+ console.warn(
1348
+ `[gputex] preferredFormat 'bc1' ignored for hint '${hint}' \u2014 BC1 has no real alpha channel and is unsuitable for normal maps.`
1349
+ );
1350
+ } else if (caps.bc) {
1351
+ return {
1352
+ format: srgb ? TextureFormat.BC1_SRGB : TextureFormat.BC1,
1353
+ encoderClass: BC1Encoder,
1354
+ astcNormalRemap: false
1355
+ };
1356
+ }
1357
+ }
1153
1358
  if (caps.bc) {
1154
1359
  if (hint === "normal") {
1155
1360
  return { format: TextureFormat.BC5, encoderClass: BC5Encoder, astcNormalRemap: false };
package/dist/three.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { TextureHint, EncodeQuality, TextureFormat, Encoder, EncoderImageSource } from './index.js';
1
+ import { TextureHint, PreferredFormat, EncodeQuality, TextureFormat, Encoder, EncoderImageSource } from './index.js';
2
2
  export { ASTC4x4Encoder, ASTC4x4WebGLEncoder, BC1Encoder, BC1WebGLEncoder, BC5Encoder, BC5WebGLEncoder, BC7Encoder, BC7WebGLEncoder, Capabilities, EncodeBytesResult, EncodeCallOptions, EncoderConstructor, EncoderOptions, ExtensionProvider, FeatureProvider, FormatSelection, FormatVariant, MipLevel, RawPixelSource, SelectFormatOptions, WebGLBlockEncoder, WebGLCapabilities, WebGLEncodeBytesResult, WebGLEncoderConstructor, WebGLEncoderImageSource, WebGLEncoderOptions, WebGLFormatSelection, WebGPUFeature, createWebGLContext, detectCapabilities, detectWebGLCapabilities, generateMipChain, getSharedWebGLContext, isWebGLAvailable, padToBlockMultiple, selectFormat, selectWebGLFormat } from './index.js';
3
3
  import { Texture, CompressedTexture, Loader, CompressedPixelFormat } from 'three';
4
4
 
@@ -11,6 +11,14 @@ type CompressTextureSource = string | Blob | File | ImageBitmap | HTMLImageEleme
11
11
  interface CompressOptions {
12
12
  /** How the texture will be used. Drives format selection. Default 'color'. */
13
13
  hint?: TextureHint;
14
+ /**
15
+ * Prefer a specific format over the default choice when the device
16
+ * supports it; falls back to the normal selection (BC7 → ASTC → RGBA8)
17
+ * when it doesn't. Currently only 'bc1': half the memory of BC7 for
18
+ * opaque colour textures, at lower quality. Only honoured with
19
+ * `hint: 'color'` — BC1 can't carry real alpha or normal maps.
20
+ */
21
+ preferredFormat?: PreferredFormat;
14
22
  /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
15
23
  colorSpace?: 'srgb' | 'linear';
16
24
  /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
@@ -63,6 +71,12 @@ declare function compressTexture(source: CompressTextureSource, options?: Compre
63
71
  declare class GputexLoader extends Loader<Texture> {
64
72
  /** Format-selection hint. Default 'color'. */
65
73
  hint: TextureHint;
74
+ /**
75
+ * Prefer a specific format (currently only 'bc1') when the device
76
+ * supports it; normal selection otherwise. See
77
+ * `CompressOptions.preferredFormat`.
78
+ */
79
+ preferredFormat?: PreferredFormat;
66
80
  /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
67
81
  colorSpace: 'srgb' | 'linear';
68
82
  /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
@@ -138,4 +152,4 @@ interface EncodeToTextureOptions {
138
152
  */
139
153
  declare function encodeToTexture(encoder: Encoder, source: EncoderImageSource, { colorSpace, quality, flipY }?: EncodeToTextureOptions): Promise<EncodeResult>;
140
154
 
141
- export { type CompressOptions, type CompressResult, type CompressTextureSource, EncodeQuality, type EncodeResult, type EncodeToTextureOptions, Encoder, EncoderImageSource, GputexLoader, TextureFormat, TextureHint, buildCompressedTexture, compressTexture, encodeToTexture, threeFormatFor };
155
+ export { type CompressOptions, type CompressResult, type CompressTextureSource, EncodeQuality, type EncodeResult, type EncodeToTextureOptions, Encoder, EncoderImageSource, GputexLoader, PreferredFormat, TextureFormat, TextureHint, buildCompressedTexture, compressTexture, encodeToTexture, threeFormatFor };
package/dist/three.js CHANGED
@@ -635,7 +635,171 @@ var BC5Encoder = class extends Encoder {
635
635
  var bc7_default = "// BC7 (BPTC) mode 6 compute shader encoder.\n//\n// One invocation per 4\xD74 block. Emits 16 bytes = 4 u32s into the storage\n// buffer at `dst[block_index * 4 .. + 3]`.\n//\n// QUALITY LEVELS (pipeline-overridable constant `QUALITY_HIGH`)\n// fast (0, default): O(N) bounding-box seed \u2192 one fused pass that projects\n// each pixel onto the endpoint line (the 16 palette entries are colinear,\n// so the nearest index is the rounded projection \u2014 no palette build, no\n// 16-entry search) while accumulating the least-squares refit sums, then\n// a reprojection against the quantised refit endpoints for the final\n// indices, packed on the fly into two nibble words.\n// high (1): farthest-pair seed, exhaustive p-bit search over all four\n// (p0,p1) \u2208 {0,1}\xB2 combos, full 16-entry nearest search, one LSQ refit \u2014\n// matches bc7_ref.ts up to FP tie-breaks.\n//\n// The fast/high branch is selected at pipeline-compile time, so the driver\n// eliminates the unused code entirely.\n//\n// MODE 6 LAYOUT (LSB-first, bit 0 = byte 0's bit 0)\n// bits 0..6 mode field (0b0000001 \u2014 only bit 6 is 1)\n// bits 7..13 R0 (7-bit) bits 14..20 R1 bits 21..27 G0 bits 28..34 G1\n// bits 35..41 B0 bits 42..48 B1 bits 49..55 A0 bits 56..62 A1\n// bit 63 P0 bit 64 P1\n// bits 65..67 pixel 0 index (3 bits; anchor, MSB implicit 0)\n// bits 68..71 pixel 1 index (4 bits) ... bits 124..127 pixel 15 index\n//\n// Effective 8-bit endpoint channel = (7_bit_value << 1) | p_bit.\n// Palette[i] = ((64 \u2212 W4[i]) \xD7 e0_8 + W4[i] \xD7 e1_8 + 32) >> 6, integer.\n//\n// The block is assembled with straight-line constant shifts (see the layout\n// summary in bc7_fast_f16.wgsl) \u2014 a generic write_bits() helper's dynamic\n// word indexing keeps the output array out of registers.\n\n// 0 = fast (default), 1 = exhaustive/high-quality. Set via pipeline constants.\noverride QUALITY_HIGH: u32 = 0u;\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\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\n// Mode 6 interpolation weights (\xD7 1/64), fixed by the spec (`W4` in bc7_ref.ts).\nfn w4(i: u32) -> u32 {\n switch i {\n case 0u: { return 0u; }\n case 1u: { return 4u; }\n case 2u: { return 9u; }\n case 3u: { return 13u; }\n case 4u: { return 17u; }\n case 5u: { return 21u; }\n case 6u: { return 26u; }\n case 7u: { return 30u; }\n case 8u: { return 34u; }\n case 9u: { return 38u; }\n case 10u: { return 43u; }\n case 11u: { return 47u; }\n case 12u: { return 51u; }\n case 13u: { return 55u; }\n case 14u: { return 60u; }\n default: { return 64u; } // case 15u\n }\n}\n\nfn interp4(e0: vec4<i32>, e1: vec4<i32>, w: i32) -> vec4<i32> {\n return ((64 - w) * e0 + w * e1 + vec4<i32>(32)) >> vec4<u32>(6u);\n}\n\nfn to8(v: vec4<f32>) -> vec4<i32> {\n return vec4<i32>(clamp(floor(v * 255.0 + 0.5), vec4<f32>(0.0), vec4<f32>(255.0)));\n}\n\nfn dist2(a: vec4<i32>, b: vec4<i32>) -> i32 {\n let d = a - b;\n let e = d * d;\n return e.x + e.y + e.z + e.w;\n}\n\n// Quantize an 8-bit ideal endpoint to (7-bit value, reconstructed 8-bit) under\n// a fixed p-bit, all four channels at once. q7 = round((ideal8 \u2212 p)/2); used by\n// both paths.\nstruct QuantPair { seven: vec4<i32>, eight: vec4<i32> };\nfn quantize_endpoint(ideal8: vec4<i32>, p: u32) -> QuantPair {\n let q = vec4<i32>(clamp(\n floor((vec4<f32>(ideal8) - f32(p)) / 2.0 + 0.5),\n vec4<f32>(0.0), vec4<f32>(127.0),\n ));\n let eff = (q << vec4<u32>(1u)) | vec4<i32>(i32(p));\n return QuantPair(q, eff);\n}\n\n// ============================ FAST PATH ================================ //\n\n// Endpoint with its chosen p-bit, picked by minimum quantisation error.\nstruct Ep { seven: vec4<i32>, eight: vec4<i32>, p: u32 };\nfn pick_ep(ideal: vec4<i32>) -> Ep {\n let a = quantize_endpoint(ideal, 0u);\n let b = quantize_endpoint(ideal, 1u);\n if (dist2(b.eight, ideal) < dist2(a.eight, ideal)) { return Ep(b.seven, b.eight, 1u); }\n return Ep(a.seven, a.eight, 0u);\n}\n\n// One pass over the block: project every pixel onto the e0\u2192e1 line and\n// accumulate the least-squares normal-equation sums; solve for the refit\n// endpoints (in 8-bit space). Indices are not produced here \u2014 the caller\n// reprojects against the quantised refit endpoints anyway.\nstruct Fit { e0: vec4<i32>, e1: vec4<i32>, valid: bool };\nfn proj_fit(pixels: ptr<function, array<vec4<i32>, 16>>, e0: vec4<i32>, e1: vec4<i32>) -> Fit {\n var out: Fit;\n out.valid = false;\n let dir = vec4<f32>(e1 - e0);\n let dd = dot(dir, dir);\n if (dd == 0.0) { return out; }\n let e0f = vec4<f32>(e0);\n let inv = 15.0 / dd;\n var sAA: f32 = 0.0; var sBB: f32 = 0.0; var sAB: f32 = 0.0;\n var sAV: vec4<f32> = vec4<f32>(0.0); var sBV: vec4<f32> = vec4<f32>(0.0);\n var s_min = 15.0; var s_max = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let v = vec4<f32>((*pixels)[k]);\n let s = clamp(floor(dot(v - e0f, dir) * inv + 0.5), 0.0, 15.0);\n s_min = min(s_min, s); s_max = max(s_max, s);\n let b = s * (1.0 / 15.0); let a = 1.0 - b;\n sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;\n sAV = sAV + a * v; sBV = sBV + b * v;\n }\n // Rank-1 guard: if every pixel projects to ONE level the system is\n // singular \u2014 det and the numerators are pure float rounding noise and the\n // solve returns garbage endpoints. With \u22652 levels det \u2265 15/225 \u2248 0.067.\n if (s_min == s_max) { return out; }\n let det = sAA * sBB - sAB * sAB;\n if (abs(det) < 1e-3) { return out; }\n out.e0 = vec4<i32>(clamp(round((sBB * sAV - sAB * sBV) / det), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.e1 = vec4<i32>(clamp(round((sAA * sBV - sAB * sAV) / det), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.valid = true;\n return out;\n}\n\n// ============================ HIGH PATH ================================ //\n\nstruct Pair { a: vec4<i32>, b: vec4<i32> };\nfn farthest_pair(pixels: ptr<function, array<vec4<i32>, 16>>) -> Pair {\n var best_d: i32 = 0;\n var pa = (*pixels)[0];\n var pb = (*pixels)[1];\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let xi = (*pixels)[i];\n for (var j: u32 = i + 1u; j < 16u; j = j + 1u) {\n let d = dist2(xi, (*pixels)[j]);\n if (d > best_d) { best_d = d; pa = xi; pb = (*pixels)[j]; }\n }\n }\n return Pair(pa, pb);\n}\n\nfn build_palette_6(e0: vec4<i32>, e1: vec4<i32>, pal: ptr<function, array<vec4<i32>, 16>>) {\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n (*pal)[i] = interp4(e0, e1, i32(w4(i)));\n }\n}\n\nfn assign_all(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n pal: ptr<function, array<vec4<i32>, 16>>,\n out_idx: ptr<function, array<u32, 16>>,\n) -> i32 {\n var err: i32 = 0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let px = (*pixels)[k];\n var best_i: u32 = 0u;\n var best_d: i32 = 2147483647;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let d = dist2(px, (*pal)[i]);\n if (d < best_d) { best_d = d; best_i = i; }\n }\n (*out_idx)[k] = best_i;\n err = err + best_d;\n }\n return err;\n}\n\nstruct BestMode6 {\n e0_7: vec4<i32>, e1_7: vec4<i32>,\n p0: u32, p1: u32,\n indices: array<u32, 16>,\n err: i32,\n};\n\n// Exhaustive p-bit search (high path); commits to `*best` only on improvement.\nfn try_pbit_combos(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n ideal0: vec4<i32>,\n ideal1: vec4<i32>,\n best: ptr<function, BestMode6>,\n) {\n var local_best = (*best).err;\n var pal: array<vec4<i32>, 16>;\n var tmp: array<u32, 16>;\n for (var p0: u32 = 0u; p0 < 2u; p0 = p0 + 1u) {\n let q0 = quantize_endpoint(ideal0, p0);\n for (var p1: u32 = 0u; p1 < 2u; p1 = p1 + 1u) {\n let q1 = quantize_endpoint(ideal1, p1);\n build_palette_6(q0.eight, q1.eight, &pal);\n let err = assign_all(pixels, &pal, &tmp);\n if (err < local_best) {\n local_best = err;\n (*best).e0_7 = q0.seven;\n (*best).e1_7 = q1.seven;\n (*best).p0 = p0;\n (*best).p1 = p1;\n (*best).indices = tmp;\n (*best).err = err;\n }\n }\n }\n}\n\n// Exact-weight LSQ refit (high path); matches bc7_ref.ts `refitEndpointsMode6`.\nstruct RefitResult { e0: vec4<i32>, e1: vec4<i32>, valid: bool };\nfn refit_endpoints(\n pixels: ptr<function, array<vec4<i32>, 16>>,\n indices: ptr<function, array<u32, 16>>,\n) -> RefitResult {\n var sAA: f32 = 0.0;\n var sBB: f32 = 0.0;\n var sAB: f32 = 0.0;\n var sAV: vec4<f32> = vec4<f32>(0.0);\n var sBV: vec4<f32> = vec4<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let i = (*indices)[k];\n let a = f32(64u - w4(i)) / 64.0;\n let b = f32(w4(i)) / 64.0;\n let v = vec4<f32>((*pixels)[k]);\n sAA = sAA + a * a;\n sBB = sBB + b * b;\n sAB = sAB + a * b;\n sAV = sAV + a * v;\n sBV = sBV + b * v;\n }\n let det = sAA * sBB - sAB * sAB;\n var out: RefitResult;\n if (abs(det) < 1e-9) {\n out.valid = false;\n return out;\n }\n let e0f = (sBB * sAV - sAB * sBV) / det;\n let e1f = (sAA * sBV - sAB * sAV) / det;\n out.e0 = vec4<i32>(clamp(round(e0f), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.e1 = vec4<i32>(clamp(round(e1f), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.valid = true;\n return out;\n}\n\n// ------------------------------- Entry --------------------------------- //\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Load 16 RGBA pixels (8-bit integer domain) and the per-channel bbox.\n var pixels: array<vec4<i32>, 16>;\n var lo = vec4<i32>(255);\n var hi = vec4<i32>(0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let px = to8(textureLoad(src_tex, p, 0));\n pixels[i] = px;\n lo = min(lo, px);\n hi = max(hi, px);\n }\n\n // Both branches produce: 7-bit endpoints + p-bits, and the 16 4-bit indices\n // packed LSB-first into two nibble words (pixel k \u2192 bits 4k..4k+3).\n var e0_7: vec4<i32>;\n var e1_7: vec4<i32>;\n var p0: u32;\n var p1: u32;\n var ilo: u32 = 0u;\n var ihi: u32 = 0u;\n\n if (QUALITY_HIGH != 0u) {\n let fp = farthest_pair(&pixels);\n var best: BestMode6;\n best.err = 2147483647;\n try_pbit_combos(&pixels, fp.a, fp.b, &best);\n let refit = refit_endpoints(&pixels, &best.indices);\n if (refit.valid) {\n try_pbit_combos(&pixels, refit.e0, refit.e1, &best);\n }\n e0_7 = best.e0_7; e1_7 = best.e1_7; p0 = best.p0; p1 = best.p1;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n ilo = ilo | (best.indices[k] << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n ihi = ihi | (best.indices[k] << ((k - 8u) * 4u));\n }\n } else {\n // Seed the fused LSQ fit from the raw bbox, then quantise the refit\n // endpoints and reproject for the final indices.\n let r = proj_fit(&pixels, lo, hi);\n var ep0: Ep;\n var ep1: Ep;\n if (r.valid) { ep0 = pick_ep(r.e0); ep1 = pick_ep(r.e1); }\n else { ep0 = pick_ep(lo); ep1 = pick_ep(hi); }\n let dir = vec4<f32>(ep1.eight - ep0.eight);\n let dd = dot(dir, dir);\n if (dd > 0.0) {\n let e0f = vec4<f32>(ep0.eight);\n let inv = 15.0 / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 15.0);\n ilo = ilo | (u32(s) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(vec4<f32>(pixels[k]) - e0f, dir) * inv + 0.5), 0.0, 15.0);\n ihi = ihi | (u32(s) << ((k - 8u) * 4u));\n }\n }\n e0_7 = ep0.seven; e1_7 = ep1.seven; p0 = ep0.p; p1 = ep1.p;\n }\n\n // Anchor rule \u2014 pixel 0's index MSB must be 0. Swapping endpoints reflects\n // every index (i \u2192 15\u2212i), which on packed nibbles is a bitwise NOT.\n if ((ilo & 0x8u) != 0u) {\n let t7 = e0_7; e0_7 = e1_7; e1_7 = t7;\n let tp = p0; p0 = p1; p1 = tp;\n ilo = ~ilo; ihi = ~ihi;\n }\n\n // Straight-line mode-6 packing (see layout at the top of the file).\n let e0 = vec4<u32>(e0_7);\n let e1 = vec4<u32>(e1_7);\n let w0 = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);\n let w1 = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (p0 << 31u);\n let w2 = p1 | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n let w3 = ihi;\n\n let out = block_index * 4u;\n dst[out + 0u] = w0;\n dst[out + 1u] = w1;\n dst[out + 2u] = w2;\n dst[out + 3u] = w3;\n}\n";
636
636
 
637
637
  // src/bc7_fast_f16.wgsl
638
- var bc7_fast_f16_default = '// bc7 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).\n// Same algorithm family as the f32 fast path in bc7.wgsl (bbox seed \u2192\n// projection-based index assignment with a fused least-squares refit \u2192\n// reproject), tuned for throughput:\n//\n// \u2022 All projection / refit math in f16 ([0,1] domain, so dot products stay\n// well inside f16 range). ~2\xD7 ALU throughput on f16-capable GPUs.\n// \u2022 The LSQ seed pass projects against the RAW bbox endpoints \u2014 quantising\n// the seed first (pick_ep) costs two extra quantisation searches and\n// doesn\'t measurably change where the refit lands.\n// \u2022 Indices are packed into two u32 nibble words ON THE FLY during the\n// final projection pass \u2014 no array<u32,16> private array. The BC7 anchor\n// reflection (i \u2192 15\u2212i) is then just a bitwise NOT of both words.\n// \u2022 The 128-bit block is assembled with straight-line constant shifts\n// instead of a generic write_bits() helper (whose dynamic word indexing\n// defeats register promotion of the output array).\n//\n// The host selects this module only when the device reports shader-f16,\n// falling back to bc7.wgsl otherwise. "high" never uses this.\n//\n// MODE 6 BIT LAYOUT (LSB-first): see bc7.wgsl. Summary:\n// w0: mode(7 bits, 0x40) R0 R1 G0 G1[3:0]\n// w1: G1[6:4] B0 B1 A0 A1 P0\n// w2: P1, pixel0 index (3 bits), pixels 1..7 (4 bits each)\n// w3: pixels 8..15 (4 bits each)\nenable f16;\nstruct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };\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;\nalias h = f16;\nalias h4 = vec4<f16>;\n\n// Quantise an ideal endpoint (h4 in [0,1]) to 7-bit + p-bit, choosing the\n// p-bit with the lower quantisation error. `eight` is the decoded value the\n// hardware will interpolate with, back in [0,1].\nstruct Ep { seven: vec4<u32>, eight: h4, p: u32 };\nfn pick_ep(ideal01: h4) -> Ep {\n let ideal = ideal01 * h(255.0);\n let q0 = clamp(floor(ideal * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=0\n let e0 = q0 * h(2.0);\n let q1 = clamp(floor((ideal - h(1.0)) * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=1\n let e1 = q1 * h(2.0) + h(1.0);\n let d0 = e0 - ideal; let d1 = e1 - ideal;\n if (dot(d1, d1) < dot(d0, d0)) { return Ep(vec4<u32>(q1), e1 * h(1.0 / 255.0), 1u); }\n return Ep(vec4<u32>(q0), e0 * h(1.0 / 255.0), 0u);\n}\n\n// One pass over the block: project every pixel onto the e0\u2192e1 line and\n// accumulate the least-squares normal-equation sums; solve for the refit\n// endpoints. Indices are NOT produced here \u2014 the caller reprojects against\n// the quantised refit endpoints anyway.\nstruct Fit { e0: h4, e1: h4, valid: bool };\nfn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {\n var out: Fit;\n out.valid = false;\n let dir = e1 - e0;\n let dd = dot(dir, dir);\n if (dd == h(0.0)) { return out; }\n let inv = h(15.0) / dd;\n var sAA = h(0.0); var sBB = h(0.0); var sAB = h(0.0);\n var sAV = h4(0.0); var sBV = h4(0.0);\n var s_min = h(15.0); var s_max = h(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let v = (*pix)[k];\n let s = clamp(floor(dot(v - e0, dir) * inv + h(0.5)), h(0.0), h(15.0));\n s_min = min(s_min, s); s_max = max(s_max, s);\n let b = s * h(1.0 / 15.0); let a = h(1.0) - b;\n sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;\n sAV = sAV + a * v; sBV = sBV + b * v;\n }\n // Rank-1 guard: if every pixel projects to ONE level the system is\n // singular \u2014 det/numerators are pure f16 rounding noise and the solve\n // returns garbage endpoints. With \u22652 distinct levels\n // det = \u03A3_i<j (b_j \u2212 b_i)\xB2 \u2265 15/225 \u2248 0.067, so 0.02 is a safe floor.\n if (s_min == s_max) { return out; }\n let det = sAA * sBB - sAB * sAB;\n if (abs(det) < h(0.02)) { return out; }\n out.e0 = clamp((sBB * sAV - sAB * sBV) / det, h4(0.0), h4(1.0));\n out.e1 = clamp((sAA * sBV - sAB * sAV) / det, h4(0.0), h4(1.0));\n out.valid = true;\n return out;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }\n let bi = gid.y * params.blocks_x + gid.x;\n let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);\n let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var pix: array<h4, 16>;\n var lo = h4(1.0);\n var hi = h4(0.0);\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);\n let px = h4(textureLoad(src_tex, p, 0));\n pix[i] = px; lo = min(lo, px); hi = max(hi, px);\n }\n\n // Seed fit from the raw bbox, then quantise the refit endpoints.\n let r = proj_fit(&pix, lo, hi);\n var ep0: Ep;\n var ep1: Ep;\n if (r.valid) { ep0 = pick_ep(r.e0); ep1 = pick_ep(r.e1); }\n else { ep0 = pick_ep(lo); ep1 = pick_ep(hi); }\n\n // Final projection against the decoded endpoints, packing the 4-bit indices\n // into two nibble words as we go (pixel k \u2192 bits 4k..4k+3 of ilo/ihi).\n var ilo: u32 = 0u;\n var ihi: u32 = 0u;\n let dir = ep1.eight - ep0.eight;\n let dd = dot(dir, dir);\n if (dd > h(0.0)) {\n let inv = h(15.0) / dd;\n for (var k: u32 = 0u; k < 8u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - ep0.eight, dir) * inv + h(0.5)), h(0.0), h(15.0));\n ilo = ilo | (u32(s) << (k * 4u));\n }\n for (var k: u32 = 8u; k < 16u; k = k + 1u) {\n let s = clamp(floor(dot(pix[k] - ep0.eight, dir) * inv + h(0.5)), h(0.0), h(15.0));\n ihi = ihi | (u32(s) << ((k - 8u) * 4u));\n }\n }\n\n // Anchor rule \u2014 pixel 0\'s index MSB must be 0. Swapping endpoints reflects\n // every index (i \u2192 15\u2212i), which on packed nibbles is a bitwise NOT.\n if ((ilo & 0x8u) != 0u) {\n let t = ep0; ep0 = ep1; ep1 = t;\n ilo = ~ilo; ihi = ~ihi;\n }\n\n // Straight-line mode-6 packing (see layout above).\n let e0 = ep0.seven;\n let e1 = ep1.seven;\n let w0 = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);\n let w1 = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (ep0.p << 31u);\n let w2 = ep1.p | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);\n let w3 = ihi;\n\n let o = bi * 4u;\n dst[o] = w0; dst[o + 1u] = w1; dst[o + 2u] = w2; dst[o + 3u] = w3;\n}\n';
638
+ var bc7_fast_f16_default = `// bc7 "fast" encoder \u2014 f16 variant (requires the shader-f16 feature).
639
+ // Same algorithm family as the f32 fast path in bc7.wgsl (bbox seed \u2192
640
+ // projection-based index assignment with a fused least-squares refit \u2192
641
+ // reproject), tuned for throughput:
642
+ //
643
+ // \u2022 All projection / refit math in f16 ([0,1] domain). ~2\xD7 ALU throughput
644
+ // on f16-capable GPUs. The projection direction is pre-scaled by 32:
645
+ // a shallow block (endpoints ~1/255 apart) has dd = dot(dir,dir) \u2248 1.5e-5,
646
+ // where 15/dd \u2248 10\u2076 overflows f16 (max 65504) to +inf and the products
647
+ // inside the projection dot are subnormal \u2014 the indices and the LSQ refit
648
+ // feeding on them turn to garbage (visible as banding on smooth
649
+ // gradients). Scaling dir by 32 multiplies the dots by 32 and dd by 1024;
650
+ // s = dot\xB7(32\xB715/dd\u2083\u2082) is the same quantity with every intermediate in
651
+ // f16's normal range (worst case inv = 480/0.0157 \u2248 3.0e4 < 65504).
652
+ // \u2022 The LSQ seed pass projects against the RAW bbox endpoints \u2014 quantising
653
+ // the seed first (pick_ep) costs two extra quantisation searches and
654
+ // doesn't measurably change where the refit lands.
655
+ // \u2022 Indices are packed into two u32 nibble words ON THE FLY during the
656
+ // final projection pass \u2014 no array<u32,16> private array. The BC7 anchor
657
+ // reflection (i \u2192 15\u2212i) is then just a bitwise NOT of both words.
658
+ // \u2022 The 128-bit block is assembled with straight-line constant shifts
659
+ // instead of a generic write_bits() helper (whose dynamic word indexing
660
+ // defeats register promotion of the output array).
661
+ //
662
+ // The host selects this module only when the device reports shader-f16,
663
+ // falling back to bc7.wgsl otherwise. "high" never uses this.
664
+ //
665
+ // MODE 6 BIT LAYOUT (LSB-first): see bc7.wgsl. Summary:
666
+ // w0: mode(7 bits, 0x40) R0 R1 G0 G1[3:0]
667
+ // w1: G1[6:4] B0 B1 A0 A1 P0
668
+ // w2: P1, pixel0 index (3 bits), pixels 1..7 (4 bits each)
669
+ // w3: pixels 8..15 (4 bits each)
670
+ enable f16;
671
+ struct Params { blocks_x: u32, blocks_y: u32, width: u32, height: u32, };
672
+ @group(0) @binding(0) var src_tex: texture_2d<f32>;
673
+ @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
674
+ @group(0) @binding(2) var<uniform> params: Params;
675
+ alias h = f16;
676
+ alias h4 = vec4<f16>;
677
+
678
+ // Quantise an ideal endpoint (h4 in [0,1]) to 7-bit + p-bit, choosing the
679
+ // p-bit with the lower quantisation error. \`eight\` is the decoded value the
680
+ // hardware will interpolate with, back in [0,1].
681
+ struct Ep { seven: vec4<u32>, eight: h4, p: u32 };
682
+ fn pick_ep(ideal01: h4) -> Ep {
683
+ let ideal = ideal01 * h(255.0);
684
+ let q0 = clamp(floor(ideal * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=0
685
+ let e0 = q0 * h(2.0);
686
+ let q1 = clamp(floor((ideal - h(1.0)) * h(0.5) + h(0.5)), h4(0.0), h4(127.0)); // p=1
687
+ let e1 = q1 * h(2.0) + h(1.0);
688
+ let d0 = e0 - ideal; let d1 = e1 - ideal;
689
+ if (dot(d1, d1) < dot(d0, d0)) { return Ep(vec4<u32>(q1), e1 * h(1.0 / 255.0), 1u); }
690
+ return Ep(vec4<u32>(q0), e0 * h(1.0 / 255.0), 0u);
691
+ }
692
+
693
+ // One pass over the block: project every pixel onto the e0\u2192e1 line and
694
+ // accumulate the least-squares normal-equation sums; solve for the refit
695
+ // endpoints. Indices are NOT produced here \u2014 the caller reprojects against
696
+ // the quantised refit endpoints anyway.
697
+ //
698
+ // The value sums accumulate v \u2212 e0, not v: the basis is affine (a + b = 1),
699
+ // so fitting the shifted data and adding e0 back is the same fit, but the
700
+ // accumulators scale with the block's span instead of its absolute level \u2014
701
+ // on a shallow dark block, f16 rounding of absolute sums (ulp \u2248 0.12 of an
702
+ // 8-bit level per add) drifts the refit endpoints by \xB11 level.
703
+ struct Fit { e0: h4, e1: h4, valid: bool };
704
+ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
705
+ var out: Fit;
706
+ out.valid = false;
707
+ // dir pre-scaled by 32 to keep dd and the projection dots in f16's normal
708
+ // range (see header). Spans below ~0.7 of an 8-bit step (dd\u2083\u2082 < 0.008,
709
+ // possible only for non-8-bit sources) are treated as flat \u2014 encoding them
710
+ // flat is under half a level of error, while running the math on them risks
711
+ // inv overflowing to +inf.
712
+ let dir = (e1 - e0) * h(32.0);
713
+ let dd = dot(dir, dir);
714
+ if (dd < h(0.008)) { return out; }
715
+ let inv = h(480.0) / dd; // 32\xB715/dd\u2083\u2082 \u2261 15/dd
716
+ var sAA = h(0.0); var sBB = h(0.0); var sAB = h(0.0);
717
+ var sAV = h4(0.0); var sBV = h4(0.0);
718
+ var s_min = h(15.0); var s_max = h(0.0);
719
+ for (var k: u32 = 0u; k < 16u; k = k + 1u) {
720
+ let vr = (*pix)[k] - e0;
721
+ let s = clamp(floor(dot(vr, dir) * inv + h(0.5)), h(0.0), h(15.0));
722
+ s_min = min(s_min, s); s_max = max(s_max, s);
723
+ let b = s * h(1.0 / 15.0); let a = h(1.0) - b;
724
+ sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;
725
+ sAV = sAV + a * vr; sBV = sBV + b * vr;
726
+ }
727
+ // Rank-1 guard: if every pixel projects to ONE level the system is
728
+ // singular \u2014 det/numerators are pure f16 rounding noise and the solve
729
+ // returns garbage endpoints. With \u22652 distinct levels
730
+ // det = \u03A3_i<j (b_j \u2212 b_i)\xB2 \u2265 15/225 \u2248 0.067, so 0.02 is a safe floor.
731
+ if (s_min == s_max) { return out; }
732
+ let det = sAA * sBB - sAB * sAB;
733
+ if (abs(det) < h(0.02)) { return out; }
734
+ out.e0 = clamp(e0 + (sBB * sAV - sAB * sBV) / det, h4(0.0), h4(1.0));
735
+ out.e1 = clamp(e0 + (sAA * sBV - sAB * sAV) / det, h4(0.0), h4(1.0));
736
+ out.valid = true;
737
+ return out;
738
+ }
739
+
740
+ @compute @workgroup_size(8, 8, 1)
741
+ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
742
+ if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) { return; }
743
+ let bi = gid.y * params.blocks_x + gid.x;
744
+ let base = vec2<i32>(i32(gid.x) * 4, i32(gid.y) * 4);
745
+ let mx = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);
746
+
747
+ var pix: array<h4, 16>;
748
+ var lo = h4(1.0);
749
+ var hi = h4(0.0);
750
+ for (var i: u32 = 0u; i < 16u; i = i + 1u) {
751
+ let p = clamp(base + vec2<i32>(i32(i & 3u), i32(i >> 2u)), vec2<i32>(0), mx);
752
+ let px = h4(textureLoad(src_tex, p, 0));
753
+ pix[i] = px; lo = min(lo, px); hi = max(hi, px);
754
+ }
755
+
756
+ // Seed fit from the raw bbox, then quantise the refit endpoints.
757
+ let r = proj_fit(&pix, lo, hi);
758
+ var ep0: Ep;
759
+ var ep1: Ep;
760
+ if (r.valid) { ep0 = pick_ep(r.e0); ep1 = pick_ep(r.e1); }
761
+ else { ep0 = pick_ep(lo); ep1 = pick_ep(hi); }
762
+
763
+ // Final projection against the decoded endpoints, packing the 4-bit indices
764
+ // into two nibble words as we go (pixel k \u2192 bits 4k..4k+3 of ilo/ihi).
765
+ var ilo: u32 = 0u;
766
+ var ihi: u32 = 0u;
767
+ // Same \xD732 pre-scale as proj_fit; distinct quantised endpoints are \u22651/255
768
+ // apart, i.e. dd\u2083\u2082 \u2265 0.0157, so the flat-block threshold only catches
769
+ // truly identical endpoints.
770
+ let dir = (ep1.eight - ep0.eight) * h(32.0);
771
+ let dd = dot(dir, dir);
772
+ if (dd >= h(0.008)) {
773
+ let inv = h(480.0) / dd;
774
+ for (var k: u32 = 0u; k < 8u; k = k + 1u) {
775
+ let s = clamp(floor(dot(pix[k] - ep0.eight, dir) * inv + h(0.5)), h(0.0), h(15.0));
776
+ ilo = ilo | (u32(s) << (k * 4u));
777
+ }
778
+ for (var k: u32 = 8u; k < 16u; k = k + 1u) {
779
+ let s = clamp(floor(dot(pix[k] - ep0.eight, dir) * inv + h(0.5)), h(0.0), h(15.0));
780
+ ihi = ihi | (u32(s) << ((k - 8u) * 4u));
781
+ }
782
+ }
783
+
784
+ // Anchor rule \u2014 pixel 0's index MSB must be 0. Swapping endpoints reflects
785
+ // every index (i \u2192 15\u2212i), which on packed nibbles is a bitwise NOT.
786
+ if ((ilo & 0x8u) != 0u) {
787
+ let t = ep0; ep0 = ep1; ep1 = t;
788
+ ilo = ~ilo; ihi = ~ihi;
789
+ }
790
+
791
+ // Straight-line mode-6 packing (see layout above).
792
+ let e0 = ep0.seven;
793
+ let e1 = ep1.seven;
794
+ let w0 = 0x40u | (e0.x << 7u) | (e1.x << 14u) | (e0.y << 21u) | (e1.y << 28u);
795
+ let w1 = (e1.y >> 4u) | (e0.z << 3u) | (e1.z << 10u) | (e0.w << 17u) | (e1.w << 24u) | (ep0.p << 31u);
796
+ let w2 = ep1.p | ((ilo & 0x7u) << 1u) | (ilo & 0xFFFFFFF0u);
797
+ let w3 = ihi;
798
+
799
+ let o = bi * 4u;
800
+ dst[o] = w0; dst[o + 1u] = w1; dst[o + 2u] = w2; dst[o + 3u] = w3;
801
+ }
802
+ `;
639
803
 
640
804
  // src/BC7Encoder.ts
641
805
  var BC7Encoder = class extends Encoder {
@@ -673,7 +837,13 @@ var astc4x4_fast_f16_default = `// astc4x4 "fast" encoder \u2014 f16 variant (re
673
837
  // projection weight assignment with a fused least-squares refit \u2192
674
838
  // reproject), tuned for throughput:
675
839
  //
676
- // \u2022 All projection / refit math in f16 ([0,1] domain).
840
+ // \u2022 All projection / refit math in f16 ([0,1] domain). The projection
841
+ // direction is pre-scaled by 32 \u2014 a shallow block (endpoints ~1/255
842
+ // apart) has dd \u2248 1.5e-5, where 3/dd \u2248 2e5 overflows f16 (max 65504) to
843
+ // +inf and the projection dots go subnormal, turning weights and the LSQ
844
+ // refit to garbage (banding on smooth gradients). Scaling dir by 32 puts
845
+ // every intermediate in f16's normal range; s = dot\xB7(32\xB73/dd\u2083\u2082) is the
846
+ // same quantity (worst case inv = 96/0.0157 \u2248 6.1e3).
677
847
  // \u2022 The seed pass only accumulates the LSQ sums (no weight output) \u2014 the
678
848
  // final weights come from reprojecting against the refit endpoints.
679
849
  // \u2022 Endpoint ordering (the blue-contraction rule: sum(e0.rgb) must not
@@ -700,20 +870,27 @@ struct Fit { e0: h4, e1: h4, valid: bool };
700
870
  fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
701
871
  var out: Fit;
702
872
  out.valid = false;
703
- let dir = e1 - e0;
873
+ // dir pre-scaled by 32 to keep dd and the projection dots in f16's normal
874
+ // range (see header). Spans below ~0.7 of an 8-bit step (dd\u2083\u2082 < 0.008,
875
+ // possible only for non-8-bit sources) are treated as flat.
876
+ let dir = (e1 - e0) * h(32.0);
704
877
  let dd = dot(dir, dir);
705
- if (dd == h(0.0)) { return out; }
706
- let inv = h(3.0) / dd;
878
+ if (dd < h(0.008)) { return out; }
879
+ let inv = h(96.0) / dd; // 32\xB73/dd\u2083\u2082 \u2261 3/dd
707
880
  var sAA = h(0.0); var sBB = h(0.0); var sAB = h(0.0);
708
881
  var sAV = h4(0.0); var sBV = h4(0.0);
709
882
  var s_min = h(3.0); var s_max = h(0.0);
883
+ // Value sums accumulate v \u2212 e0 (basis is affine, a + b = 1, so the fit
884
+ // commutes with the shift): accumulators scale with the block span, keeping
885
+ // f16 rounding a fraction of the span instead of \xB11 level at high absolute
886
+ // values.
710
887
  for (var k: u32 = 0u; k < 16u; k = k + 1u) {
711
- let v = (*pix)[k];
712
- let s = clamp(floor(dot(v - e0, dir) * inv + h(0.5)), h(0.0), h(3.0));
888
+ let vr = (*pix)[k] - e0;
889
+ let s = clamp(floor(dot(vr, dir) * inv + h(0.5)), h(0.0), h(3.0));
713
890
  s_min = min(s_min, s); s_max = max(s_max, s);
714
891
  let b = s * h(1.0 / 3.0); let a = h(1.0) - b;
715
892
  sAA = sAA + a * a; sBB = sBB + b * b; sAB = sAB + a * b;
716
- sAV = sAV + a * v; sBV = sBV + b * v;
893
+ sAV = sAV + a * vr; sBV = sBV + b * vr;
717
894
  }
718
895
  // Rank-1 guard: if every pixel projects to ONE level the system is
719
896
  // singular \u2014 det/numerators are pure f16 rounding noise and the solve
@@ -722,8 +899,8 @@ fn proj_fit(pix: ptr<function, array<h4, 16>>, e0: h4, e1: h4) -> Fit {
722
899
  if (s_min == s_max) { return out; }
723
900
  let det = sAA * sBB - sAB * sAB;
724
901
  if (abs(det) < h(0.5)) { return out; }
725
- out.e0 = clamp((sBB * sAV - sAB * sBV) / det, h4(0.0), h4(1.0));
726
- out.e1 = clamp((sAA * sBV - sAB * sAV) / det, h4(0.0), h4(1.0));
902
+ out.e0 = clamp(e0 + (sBB * sAV - sAB * sBV) / det, h4(0.0), h4(1.0));
903
+ out.e1 = clamp(e0 + (sAA * sBV - sAB * sAV) / det, h4(0.0), h4(1.0));
727
904
  out.valid = true;
728
905
  return out;
729
906
  }
@@ -766,10 +943,12 @@ fn encode(@builtin(global_invocation_id) gid: vec3<u32>) {
766
943
  // Weight pass, packing on the fly: weight k's lsb at bit 31\u22122k of the last
767
944
  // word, msb at bit 30\u22122k.
768
945
  var w3: u32 = 0u;
769
- let dir = d1 - d0;
946
+ // Same \xD732 pre-scale as proj_fit; distinct 8-bit endpoints are \u22651/255
947
+ // apart (dd\u2083\u2082 \u2265 0.0157), so the threshold only catches identical ones.
948
+ let dir = (d1 - d0) * h(32.0);
770
949
  let dd = dot(dir, dir);
771
- if (dd > h(0.0)) {
772
- let inv = h(3.0) / dd;
950
+ if (dd >= h(0.008)) {
951
+ let inv = h(96.0) / dd;
773
952
  for (var k: u32 = 0u; k < 16u; k = k + 1u) {
774
953
  let s = u32(clamp(floor(dot(pix[k] - d0, dir) * inv + h(0.5)), h(0.0), h(3.0)));
775
954
  w3 = w3 | ((s & 1u) << (31u - 2u * k)) | (((s >> 1u) & 1u) << (30u - 2u * k));
@@ -1118,13 +1297,26 @@ function detectWebGLCapabilities(gl) {
1118
1297
  // src/webgl/selectWebGLFormat.ts
1119
1298
  var NONE = { format: null, encoderClass: null, astcNormalRemap: false };
1120
1299
  function selectWebGLFormat(caps, hint, options = {}) {
1121
- const { colorSpace = "srgb" } = options;
1300
+ const { colorSpace = "srgb", preferredFormat } = options;
1122
1301
  const srgb = colorSpace === "srgb";
1123
1302
  const astc = (astcNormalRemap) => ({
1124
1303
  format: srgb ? TextureFormat.ASTC_4x4_SRGB : TextureFormat.ASTC_4x4,
1125
1304
  encoderClass: ASTC4x4WebGLEncoder,
1126
1305
  astcNormalRemap
1127
1306
  });
1307
+ if (preferredFormat === "bc1") {
1308
+ if (hint !== "color") {
1309
+ console.warn(
1310
+ `[gputex] preferredFormat 'bc1' ignored for hint '${hint}' \u2014 BC1 has no real alpha channel and is unsuitable for normal maps.`
1311
+ );
1312
+ } else if (srgb ? caps.s3tcSrgb : caps.s3tc) {
1313
+ return {
1314
+ format: srgb ? TextureFormat.BC1_SRGB : TextureFormat.BC1,
1315
+ encoderClass: BC1WebGLEncoder,
1316
+ astcNormalRemap: false
1317
+ };
1318
+ }
1319
+ }
1128
1320
  if (hint === "normal") {
1129
1321
  if (caps.rgtc) return { format: TextureFormat.BC5, encoderClass: BC5WebGLEncoder, astcNormalRemap: false };
1130
1322
  if (caps.astc) return astc(true);
@@ -1151,9 +1343,22 @@ function selectWebGLFormat(caps, hint, options = {}) {
1151
1343
 
1152
1344
  // src/selectFormat.ts
1153
1345
  function selectFormat(adapter, hint, options = {}) {
1154
- const { colorSpace = "srgb" } = options;
1346
+ const { colorSpace = "srgb", preferredFormat } = options;
1155
1347
  const srgb = colorSpace === "srgb";
1156
1348
  const caps = detectCapabilities(adapter);
1349
+ if (preferredFormat === "bc1") {
1350
+ if (hint !== "color") {
1351
+ console.warn(
1352
+ `[gputex] preferredFormat 'bc1' ignored for hint '${hint}' \u2014 BC1 has no real alpha channel and is unsuitable for normal maps.`
1353
+ );
1354
+ } else if (caps.bc) {
1355
+ return {
1356
+ format: srgb ? TextureFormat.BC1_SRGB : TextureFormat.BC1,
1357
+ encoderClass: BC1Encoder,
1358
+ astcNormalRemap: false
1359
+ };
1360
+ }
1361
+ }
1157
1362
  if (caps.bc) {
1158
1363
  if (hint === "normal") {
1159
1364
  return { format: TextureFormat.BC5, encoderClass: BC5Encoder, astcNormalRemap: false };
@@ -1366,6 +1571,7 @@ function wrapUncompressed(bitmap, srgb, flipY) {
1366
1571
  async function compressTexture(source, options = {}) {
1367
1572
  const {
1368
1573
  hint = "color",
1574
+ preferredFormat,
1369
1575
  colorSpace = "srgb",
1370
1576
  flipY = true,
1371
1577
  mipmaps = false,
@@ -1401,7 +1607,7 @@ async function compressTexture(source, options = {}) {
1401
1607
  if (!("gpu" in navigator)) return null;
1402
1608
  const adapter = providedAdapter ?? await navigator.gpu.requestAdapter();
1403
1609
  if (!adapter) return null;
1404
- const selection = selectFormat(adapter, hint, { colorSpace });
1610
+ const selection = selectFormat(adapter, hint, { colorSpace, preferredFormat });
1405
1611
  if (!selection.format || !selection.encoderClass) return null;
1406
1612
  let encoder;
1407
1613
  if (providedDevice) {
@@ -1474,7 +1680,7 @@ async function compressTexture(source, options = {}) {
1474
1680
  const gl = getSharedWebGLContext();
1475
1681
  if (!gl) return null;
1476
1682
  const caps = detectWebGLCapabilities(gl);
1477
- const selection = selectWebGLFormat(caps, hint, { colorSpace });
1683
+ const selection = selectWebGLFormat(caps, hint, { colorSpace, preferredFormat });
1478
1684
  if (!selection.format || !selection.encoderClass) return null;
1479
1685
  const encoder = selection.encoderClass.create(gl);
1480
1686
  try {
@@ -1536,6 +1742,12 @@ import { Loader } from "three";
1536
1742
  var GputexLoader = class extends Loader {
1537
1743
  /** Format-selection hint. Default 'color'. */
1538
1744
  hint = "color";
1745
+ /**
1746
+ * Prefer a specific format (currently only 'bc1') when the device
1747
+ * supports it; normal selection otherwise. See
1748
+ * `CompressOptions.preferredFormat`.
1749
+ */
1750
+ preferredFormat;
1539
1751
  /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
1540
1752
  colorSpace = "srgb";
1541
1753
  /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
@@ -1567,6 +1779,7 @@ var GputexLoader = class extends Loader {
1567
1779
  this.manager.itemStart(url);
1568
1780
  compressTexture(url, {
1569
1781
  hint: this.hint,
1782
+ preferredFormat: this.preferredFormat,
1570
1783
  colorSpace: this.colorSpace,
1571
1784
  flipY: this.flipY,
1572
1785
  mipmaps: this.mipmaps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gputex",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "dist"