gputex 0.3.0 → 0.3.2

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
@@ -1,6 +1,6 @@
1
1
  # GPUtex | On-the-fly GPU texture encoding
2
2
 
3
- Runtime GPU texture compression via WebGPU compute shaders, with a WebGL2 fragment-shader fallback. Feed it a PNG/JPG/WebP/AVIF and get back a GPU-compressed texture (BC7, BC5, ASTC 4x4, or BC1) ready for Three.js or React Three Fiber.
3
+ Runtime GPU texture compression via WebGPU compute shaders, with a WebGL2 fragment-shader fallback. Feed it a PNG/JPG/WebP/AVIF — or an SVG, rasterised on the fly — and get back a GPU-compressed texture (BC7, BC5, ASTC 4x4, or BC1) ready for Three.js or React Three Fiber.
4
4
 
5
5
  ⚠️ 100% vibe-coded. The code is completely unreviewed and under-tested. Do not use for anything important.
6
6
 
@@ -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.
@@ -86,6 +93,32 @@ material.map = texture
86
93
  (byte-identical on >96% of blocks; the rest are equal-error FP tie-breaks,
87
94
  enforced by the GPU test suite).
88
95
 
96
+ #### SVG sources
97
+
98
+ SVGs work anywhere a raster image does — as a URL, a Blob/File, an inline
99
+ markup string (detected by a leading `<`), or an `<img>` element. The vector
100
+ is rasterised before encoding, at the SVG's intrinsic size by default
101
+ (absolute `width`/`height` attributes, else the `viewBox` dimensions). Use
102
+ `svgSize` to pick the raster size — the browser renders the vector directly
103
+ at that size, so upscaling stays crisp:
104
+
105
+ ```ts
106
+ // Longest side 1024, aspect ratio preserved:
107
+ const { texture } = await compressTexture('/logo.svg', { svgSize: 1024 })
108
+
109
+ // Exact size (aspect mismatches follow the SVG's preserveAspectRatio rules):
110
+ await compressTexture('/icon.svg', { svgSize: { width: 512, height: 512 } })
111
+
112
+ // Inline markup:
113
+ await compressTexture('<svg viewBox="0 0 32 32">…</svg>', { svgSize: 256 })
114
+ ```
115
+
116
+ An SVG with no `width`/`height` **and** no `viewBox` has no intrinsic size;
117
+ `svgSize` is required for those. Rasterisation needs a DOM `Image`, so SVG
118
+ sources are main-thread only. Non-Three.js users get the same rasteriser as
119
+ a standalone helper: `rasterizeSvg(source, { size })` from the core `gputex`
120
+ entry returns an `ImageBitmap` ready for `encodeToBytes()`.
121
+
89
122
  ### `GputexLoader` — Three.js Loader
90
123
 
91
124
  ```ts
@@ -204,13 +237,15 @@ const tex = buildCompressedTexture([bytes], TextureFormat.BC7_SRGB)
204
237
 
205
238
  ### `compressTexture` options
206
239
 
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 |
240
+ | Option | Type | Default | Description |
241
+ | ----------------- | ----------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
242
+ | `hint` | `TextureHint` | `'color'` | `'color'`, `'colorWithAlpha'`, or `'normal'` |
243
+ | `preferredFormat` | `'bc1'` | — | Prefer BC1 (half of BC7's size) when supported; normal selection otherwise. `hint: 'color'` only |
244
+ | `colorSpace` | `'srgb' \| 'linear'` | `'srgb'` | Use the sRGB or linear variant of the chosen format |
245
+ | `svgSize` | `number \| { width, height }` | intrinsic | Raster size for SVG sources: longest side (aspect preserved) or exact size |
246
+ | `flipY` | `boolean` | `true` | Flip vertically (matches Three.js convention) |
247
+ | `mipmaps` | `boolean` | `false` | Generate full mip chain down to 1x1 |
248
+ | `device` | `GPUDevice` | — | Reuse an existing WebGPU device instead of creating one |
214
249
 
215
250
  ## Benchmarks
216
251
 
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,27 @@ 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
+ /**
444
+ * Target raster size for an SVG source. A number scales the SVG so its
445
+ * longest side matches (aspect ratio preserved); an object rasterises at
446
+ * exactly that size. When omitted, the SVG's intrinsic size is used
447
+ * (absolute width/height attributes, else the viewBox dimensions).
448
+ */
449
+ type SvgRasterSize = number | {
450
+ width: number;
451
+ height: number;
452
+ };
453
+ interface RasterizeSvgOptions {
454
+ /** Target raster size. Default: the SVG's intrinsic size. */
455
+ size?: SvgRasterSize;
456
+ }
457
+ /**
458
+ * Rasterise an SVG (markup string or Blob/File) to an `ImageBitmap`.
459
+ *
460
+ * Used automatically by `compressTexture()` for SVG sources; exported for
461
+ * callers driving the core encoders directly — the returned bitmap is a
462
+ * valid `EncoderImageSource`. Main-thread only (needs `Image`).
463
+ */
464
+ declare function rasterizeSvg(source: string | Blob, options?: RasterizeSvgOptions): Promise<ImageBitmap>;
465
+
466
+ 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 RasterizeSvgOptions, type RawPixelSource, type SelectFormatOptions, type SvgRasterSize, TextureFormat, type TextureHint, WebGLBlockEncoder, type WebGLCapabilities, type WebGLEncodeBytesResult, type WebGLEncoderConstructor, type WebGLEncoderImageSource, type WebGLEncoderOptions, type WebGLFormatSelection, WebGPUFeature, createWebGLContext, detectCapabilities, detectWebGLCapabilities, generateMipChain, getSharedWebGLContext, isWebGLAvailable, padToBlockMultiple, rasterizeSvg, 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 };
@@ -1236,6 +1441,128 @@ function padToBlockMultiple(level) {
1236
1441
  }
1237
1442
  return { data: out, width: pw, height: ph };
1238
1443
  }
1444
+
1445
+ // src/svg.ts
1446
+ var ROOT_TAG_RE = /<svg(?=[\s/>])[^>]*>/;
1447
+ function getAttr(tag, name) {
1448
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
1449
+ return m ? m[1] ?? m[2] ?? "" : null;
1450
+ }
1451
+ function removeAttr(tag, name) {
1452
+ return tag.replace(new RegExp(`\\s${name}\\s*=\\s*(?:"[^"]*"|'[^']*')`, "g"), "");
1453
+ }
1454
+ function parseAbsoluteLength(value) {
1455
+ if (value == null) return null;
1456
+ const m = /^\s*\+?(\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/i.exec(value);
1457
+ if (!m) return null;
1458
+ const n = Number(m[1]);
1459
+ return n > 0 ? n : null;
1460
+ }
1461
+ function parseSvgDimensions(svgText) {
1462
+ const m = ROOT_TAG_RE.exec(svgText);
1463
+ if (!m) return null;
1464
+ const tag = m[0];
1465
+ const dims = {
1466
+ width: parseAbsoluteLength(getAttr(tag, "width")),
1467
+ height: parseAbsoluteLength(getAttr(tag, "height")),
1468
+ viewBoxWidth: null,
1469
+ viewBoxHeight: null
1470
+ };
1471
+ const viewBox = getAttr(tag, "viewBox");
1472
+ if (viewBox) {
1473
+ const parts = viewBox.trim().split(/[\s,]+/).map(Number);
1474
+ if (parts.length === 4 && parts.every(Number.isFinite) && parts[2] > 0 && parts[3] > 0) {
1475
+ dims.viewBoxWidth = parts[2];
1476
+ dims.viewBoxHeight = parts[3];
1477
+ }
1478
+ }
1479
+ return dims;
1480
+ }
1481
+ function resolveSvgRasterSize(dims, size) {
1482
+ if (size !== void 0 && typeof size === "object") {
1483
+ const width = Math.round(size.width);
1484
+ const height = Math.round(size.height);
1485
+ if (!(width >= 1) || !(height >= 1)) {
1486
+ throw new Error(`rasterizeSvg: svgSize must be \u22651\xD71 (got ${size.width}\xD7${size.height})`);
1487
+ }
1488
+ return { width, height };
1489
+ }
1490
+ let w = dims.width;
1491
+ let h = dims.height;
1492
+ if (dims.viewBoxWidth != null && dims.viewBoxHeight != null) {
1493
+ if (w == null && h != null) w = h * dims.viewBoxWidth / dims.viewBoxHeight;
1494
+ else if (h == null && w != null) h = w * dims.viewBoxHeight / dims.viewBoxWidth;
1495
+ else if (w == null && h == null) {
1496
+ w = dims.viewBoxWidth;
1497
+ h = dims.viewBoxHeight;
1498
+ }
1499
+ }
1500
+ if (typeof size === "number") {
1501
+ if (!(size >= 1)) {
1502
+ throw new Error(`rasterizeSvg: svgSize must be \u22651 (got ${size})`);
1503
+ }
1504
+ const aspect = w != null && h != null ? w / h : 1;
1505
+ return aspect >= 1 ? { width: Math.round(size), height: Math.max(1, Math.round(size / aspect)) } : { width: Math.max(1, Math.round(size * aspect)), height: Math.round(size) };
1506
+ }
1507
+ if (w == null || h == null) {
1508
+ throw new Error(
1509
+ "rasterizeSvg: the SVG has no intrinsic size (no absolute width/height attributes and no viewBox) \u2014 pass svgSize to choose a rasterisation size"
1510
+ );
1511
+ }
1512
+ return { width: Math.max(1, Math.round(w)), height: Math.max(1, Math.round(h)) };
1513
+ }
1514
+ function setSvgRootSize(svgText, width, height) {
1515
+ const m = ROOT_TAG_RE.exec(svgText);
1516
+ if (!m) {
1517
+ throw new Error("rasterizeSvg: no <svg> root element found in source");
1518
+ }
1519
+ let tag = m[0];
1520
+ const origWidth = parseAbsoluteLength(getAttr(tag, "width"));
1521
+ const origHeight = parseAbsoluteLength(getAttr(tag, "height"));
1522
+ const hasViewBox = getAttr(tag, "viewBox") != null;
1523
+ tag = removeAttr(removeAttr(tag, "width"), "height");
1524
+ let inject = ` width="${width}" height="${height}"`;
1525
+ if (!hasViewBox && origWidth != null && origHeight != null) {
1526
+ inject += ` viewBox="0 0 ${origWidth} ${origHeight}"`;
1527
+ }
1528
+ tag = `<svg${inject}${tag.slice("<svg".length)}`;
1529
+ return svgText.slice(0, m.index) + tag + svgText.slice(m.index + m[0].length);
1530
+ }
1531
+ async function rasterizeSvg(source, options = {}) {
1532
+ if (typeof Image === "undefined") {
1533
+ throw new Error(
1534
+ "rasterizeSvg: SVG rasterisation needs a DOM Image element and cannot run in this environment (e.g. a worker)"
1535
+ );
1536
+ }
1537
+ const svgText = typeof source === "string" ? source : await source.text();
1538
+ const dims = parseSvgDimensions(svgText);
1539
+ if (!dims) {
1540
+ throw new Error("rasterizeSvg: no <svg> root element found in source");
1541
+ }
1542
+ const { width, height } = resolveSvgRasterSize(dims, options.size);
1543
+ const sized = setSvgRootSize(svgText, width, height);
1544
+ const url = URL.createObjectURL(new Blob([sized], { type: "image/svg+xml;charset=utf-8" }));
1545
+ try {
1546
+ const img = new Image();
1547
+ img.decoding = "async";
1548
+ await new Promise((resolve, reject) => {
1549
+ img.onload = () => resolve();
1550
+ img.onerror = () => reject(new Error("rasterizeSvg: the browser failed to decode the SVG"));
1551
+ img.src = url;
1552
+ });
1553
+ await img.decode().catch(() => {
1554
+ });
1555
+ const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(width, height) : Object.assign(document.createElement("canvas"), { width, height });
1556
+ const ctx = canvas.getContext("2d");
1557
+ if (!ctx) {
1558
+ throw new Error("rasterizeSvg: no 2D context available");
1559
+ }
1560
+ ctx.drawImage(img, 0, 0, width, height);
1561
+ return await createImageBitmap(canvas, { colorSpaceConversion: "none", premultiplyAlpha: "none" });
1562
+ } finally {
1563
+ URL.revokeObjectURL(url);
1564
+ }
1565
+ }
1239
1566
  export {
1240
1567
  ASTC4x4Encoder,
1241
1568
  ASTC4x4WebGLEncoder,
@@ -1256,6 +1583,7 @@ export {
1256
1583
  getSharedWebGLContext,
1257
1584
  isWebGLAvailable,
1258
1585
  padToBlockMultiple,
1586
+ rasterizeSvg,
1259
1587
  selectFormat,
1260
1588
  selectWebGLFormat
1261
1589
  };
package/dist/three.d.ts CHANGED
@@ -1,18 +1,39 @@
1
- import { TextureHint, EncodeQuality, TextureFormat, Encoder, EncoderImageSource } from './index.js';
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';
1
+ import { TextureHint, PreferredFormat, SvgRasterSize, EncodeQuality, TextureFormat, Encoder, EncoderImageSource } from './index.js';
2
+ export { ASTC4x4Encoder, ASTC4x4WebGLEncoder, BC1Encoder, BC1WebGLEncoder, BC5Encoder, BC5WebGLEncoder, BC7Encoder, BC7WebGLEncoder, Capabilities, EncodeBytesResult, EncodeCallOptions, EncoderConstructor, EncoderOptions, ExtensionProvider, FeatureProvider, FormatSelection, FormatVariant, MipLevel, RasterizeSvgOptions, RawPixelSource, SelectFormatOptions, WebGLBlockEncoder, WebGLCapabilities, WebGLEncodeBytesResult, WebGLEncoderConstructor, WebGLEncoderImageSource, WebGLEncoderOptions, WebGLFormatSelection, WebGPUFeature, createWebGLContext, detectCapabilities, detectWebGLCapabilities, generateMipChain, getSharedWebGLContext, isWebGLAvailable, padToBlockMultiple, rasterizeSvg, selectFormat, selectWebGLFormat } from './index.js';
3
3
  import { Texture, CompressedTexture, Loader, CompressedPixelFormat } from 'three';
4
4
 
5
5
  /**
6
6
  * Everything `compressTexture()` can take as an image source. A superset
7
7
  * of `EncoderImageSource` (see Encoder.ts) that also accepts URL strings
8
8
  * and Blob / File objects — the common cases in a web app.
9
+ *
10
+ * SVG works through all of these: a URL to an `.svg` file, a string of
11
+ * inline SVG markup (detected by a leading `<`), an SVG Blob/File, or an
12
+ * HTMLImageElement whose src is SVG. Vector sources are rasterised to RGBA
13
+ * before encoding — see the `svgSize` option.
9
14
  */
10
15
  type CompressTextureSource = string | Blob | File | ImageBitmap | HTMLImageElement | HTMLCanvasElement | OffscreenCanvas | ImageData;
11
16
  interface CompressOptions {
12
17
  /** How the texture will be used. Drives format selection. Default 'color'. */
13
18
  hint?: TextureHint;
19
+ /**
20
+ * Prefer a specific format over the default choice when the device
21
+ * supports it; falls back to the normal selection (BC7 → ASTC → RGBA8)
22
+ * when it doesn't. Currently only 'bc1': half the memory of BC7 for
23
+ * opaque colour textures, at lower quality. Only honoured with
24
+ * `hint: 'color'` — BC1 can't carry real alpha or normal maps.
25
+ */
26
+ preferredFormat?: PreferredFormat;
14
27
  /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
15
28
  colorSpace?: 'srgb' | 'linear';
29
+ /**
30
+ * Rasterisation size for SVG sources. A number scales the SVG so its
31
+ * longest side matches (aspect ratio preserved); `{ width, height }`
32
+ * rasterises at exactly that size. Default: the SVG's intrinsic size
33
+ * (absolute width/height attributes, else the viewBox dimensions).
34
+ * Ignored for non-SVG sources.
35
+ */
36
+ svgSize?: SvgRasterSize;
16
37
  /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
17
38
  flipY?: boolean;
18
39
  /** Generate a full mip chain down to 1×1 on the CPU, encode every level. */
@@ -63,8 +84,20 @@ declare function compressTexture(source: CompressTextureSource, options?: Compre
63
84
  declare class GputexLoader extends Loader<Texture> {
64
85
  /** Format-selection hint. Default 'color'. */
65
86
  hint: TextureHint;
87
+ /**
88
+ * Prefer a specific format (currently only 'bc1') when the device
89
+ * supports it; normal selection otherwise. See
90
+ * `CompressOptions.preferredFormat`.
91
+ */
92
+ preferredFormat?: PreferredFormat;
66
93
  /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
67
94
  colorSpace: 'srgb' | 'linear';
95
+ /**
96
+ * Rasterisation size for SVG URLs — a number (longest side, aspect
97
+ * preserved) or exact `{ width, height }`. Default: the SVG's intrinsic
98
+ * size. See `CompressOptions.svgSize`.
99
+ */
100
+ svgSize?: SvgRasterSize;
68
101
  /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
69
102
  flipY: boolean;
70
103
  /** Generate + encode a full mip chain. Default false. */
@@ -138,4 +171,4 @@ interface EncodeToTextureOptions {
138
171
  */
139
172
  declare function encodeToTexture(encoder: Encoder, source: EncoderImageSource, { colorSpace, quality, flipY }?: EncodeToTextureOptions): Promise<EncodeResult>;
140
173
 
141
- export { type CompressOptions, type CompressResult, type CompressTextureSource, EncodeQuality, type EncodeResult, type EncodeToTextureOptions, Encoder, EncoderImageSource, GputexLoader, TextureFormat, TextureHint, buildCompressedTexture, compressTexture, encodeToTexture, threeFormatFor };
174
+ export { type CompressOptions, type CompressResult, type CompressTextureSource, EncodeQuality, type EncodeResult, type EncodeToTextureOptions, Encoder, EncoderImageSource, GputexLoader, PreferredFormat, SvgRasterSize, 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 };
@@ -1241,6 +1446,140 @@ function padToBlockMultiple(level) {
1241
1446
  return { data: out, width: pw, height: ph };
1242
1447
  }
1243
1448
 
1449
+ // src/svg.ts
1450
+ function isSvgMarkup(source) {
1451
+ return source.trimStart().startsWith("<");
1452
+ }
1453
+ function hasSvgExtension(url) {
1454
+ return /\.svg$/i.test(url.split(/[?#]/, 1)[0]);
1455
+ }
1456
+ function isSvgBlob(blob) {
1457
+ if (blob.type) {
1458
+ return blob.type.split(";", 1)[0].trim().toLowerCase() === "image/svg+xml";
1459
+ }
1460
+ return typeof File !== "undefined" && blob instanceof File && hasSvgExtension(blob.name);
1461
+ }
1462
+ var ROOT_TAG_RE = /<svg(?=[\s/>])[^>]*>/;
1463
+ function getAttr(tag, name) {
1464
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
1465
+ return m ? m[1] ?? m[2] ?? "" : null;
1466
+ }
1467
+ function removeAttr(tag, name) {
1468
+ return tag.replace(new RegExp(`\\s${name}\\s*=\\s*(?:"[^"]*"|'[^']*')`, "g"), "");
1469
+ }
1470
+ function parseAbsoluteLength(value) {
1471
+ if (value == null) return null;
1472
+ const m = /^\s*\+?(\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/i.exec(value);
1473
+ if (!m) return null;
1474
+ const n = Number(m[1]);
1475
+ return n > 0 ? n : null;
1476
+ }
1477
+ function parseSvgDimensions(svgText) {
1478
+ const m = ROOT_TAG_RE.exec(svgText);
1479
+ if (!m) return null;
1480
+ const tag = m[0];
1481
+ const dims = {
1482
+ width: parseAbsoluteLength(getAttr(tag, "width")),
1483
+ height: parseAbsoluteLength(getAttr(tag, "height")),
1484
+ viewBoxWidth: null,
1485
+ viewBoxHeight: null
1486
+ };
1487
+ const viewBox = getAttr(tag, "viewBox");
1488
+ if (viewBox) {
1489
+ const parts = viewBox.trim().split(/[\s,]+/).map(Number);
1490
+ if (parts.length === 4 && parts.every(Number.isFinite) && parts[2] > 0 && parts[3] > 0) {
1491
+ dims.viewBoxWidth = parts[2];
1492
+ dims.viewBoxHeight = parts[3];
1493
+ }
1494
+ }
1495
+ return dims;
1496
+ }
1497
+ function resolveSvgRasterSize(dims, size) {
1498
+ if (size !== void 0 && typeof size === "object") {
1499
+ const width = Math.round(size.width);
1500
+ const height = Math.round(size.height);
1501
+ if (!(width >= 1) || !(height >= 1)) {
1502
+ throw new Error(`rasterizeSvg: svgSize must be \u22651\xD71 (got ${size.width}\xD7${size.height})`);
1503
+ }
1504
+ return { width, height };
1505
+ }
1506
+ let w = dims.width;
1507
+ let h = dims.height;
1508
+ if (dims.viewBoxWidth != null && dims.viewBoxHeight != null) {
1509
+ if (w == null && h != null) w = h * dims.viewBoxWidth / dims.viewBoxHeight;
1510
+ else if (h == null && w != null) h = w * dims.viewBoxHeight / dims.viewBoxWidth;
1511
+ else if (w == null && h == null) {
1512
+ w = dims.viewBoxWidth;
1513
+ h = dims.viewBoxHeight;
1514
+ }
1515
+ }
1516
+ if (typeof size === "number") {
1517
+ if (!(size >= 1)) {
1518
+ throw new Error(`rasterizeSvg: svgSize must be \u22651 (got ${size})`);
1519
+ }
1520
+ const aspect = w != null && h != null ? w / h : 1;
1521
+ return aspect >= 1 ? { width: Math.round(size), height: Math.max(1, Math.round(size / aspect)) } : { width: Math.max(1, Math.round(size * aspect)), height: Math.round(size) };
1522
+ }
1523
+ if (w == null || h == null) {
1524
+ throw new Error(
1525
+ "rasterizeSvg: the SVG has no intrinsic size (no absolute width/height attributes and no viewBox) \u2014 pass svgSize to choose a rasterisation size"
1526
+ );
1527
+ }
1528
+ return { width: Math.max(1, Math.round(w)), height: Math.max(1, Math.round(h)) };
1529
+ }
1530
+ function setSvgRootSize(svgText, width, height) {
1531
+ const m = ROOT_TAG_RE.exec(svgText);
1532
+ if (!m) {
1533
+ throw new Error("rasterizeSvg: no <svg> root element found in source");
1534
+ }
1535
+ let tag = m[0];
1536
+ const origWidth = parseAbsoluteLength(getAttr(tag, "width"));
1537
+ const origHeight = parseAbsoluteLength(getAttr(tag, "height"));
1538
+ const hasViewBox = getAttr(tag, "viewBox") != null;
1539
+ tag = removeAttr(removeAttr(tag, "width"), "height");
1540
+ let inject = ` width="${width}" height="${height}"`;
1541
+ if (!hasViewBox && origWidth != null && origHeight != null) {
1542
+ inject += ` viewBox="0 0 ${origWidth} ${origHeight}"`;
1543
+ }
1544
+ tag = `<svg${inject}${tag.slice("<svg".length)}`;
1545
+ return svgText.slice(0, m.index) + tag + svgText.slice(m.index + m[0].length);
1546
+ }
1547
+ async function rasterizeSvg(source, options = {}) {
1548
+ if (typeof Image === "undefined") {
1549
+ throw new Error(
1550
+ "rasterizeSvg: SVG rasterisation needs a DOM Image element and cannot run in this environment (e.g. a worker)"
1551
+ );
1552
+ }
1553
+ const svgText = typeof source === "string" ? source : await source.text();
1554
+ const dims = parseSvgDimensions(svgText);
1555
+ if (!dims) {
1556
+ throw new Error("rasterizeSvg: no <svg> root element found in source");
1557
+ }
1558
+ const { width, height } = resolveSvgRasterSize(dims, options.size);
1559
+ const sized = setSvgRootSize(svgText, width, height);
1560
+ const url = URL.createObjectURL(new Blob([sized], { type: "image/svg+xml;charset=utf-8" }));
1561
+ try {
1562
+ const img = new Image();
1563
+ img.decoding = "async";
1564
+ await new Promise((resolve, reject) => {
1565
+ img.onload = () => resolve();
1566
+ img.onerror = () => reject(new Error("rasterizeSvg: the browser failed to decode the SVG"));
1567
+ img.src = url;
1568
+ });
1569
+ await img.decode().catch(() => {
1570
+ });
1571
+ const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(width, height) : Object.assign(document.createElement("canvas"), { width, height });
1572
+ const ctx = canvas.getContext("2d");
1573
+ if (!ctx) {
1574
+ throw new Error("rasterizeSvg: no 2D context available");
1575
+ }
1576
+ ctx.drawImage(img, 0, 0, width, height);
1577
+ return await createImageBitmap(canvas, { colorSpaceConversion: "none", premultiplyAlpha: "none" });
1578
+ } finally {
1579
+ URL.revokeObjectURL(url);
1580
+ }
1581
+ }
1582
+
1244
1583
  // src/three/compressTexture.ts
1245
1584
  import { LinearFilter as LinearFilter2, LinearSRGBColorSpace as LinearSRGBColorSpace2, RepeatWrapping as RepeatWrapping2, SRGBColorSpace as SRGBColorSpace2, Texture } from "three";
1246
1585
 
@@ -1313,27 +1652,49 @@ async function encodeToTexture(encoder, source, { colorSpace = "srgb", quality =
1313
1652
  }
1314
1653
 
1315
1654
  // src/three/compressTexture.ts
1316
- async function sourceToBitmap(source) {
1655
+ async function sourceToBitmap(source, svgSize) {
1317
1656
  const opts = {
1318
1657
  colorSpaceConversion: "none",
1319
1658
  premultiplyAlpha: "none"
1320
1659
  };
1321
1660
  if (typeof source === "string") {
1661
+ if (isSvgMarkup(source)) {
1662
+ return rasterizeSvg(source, { size: svgSize });
1663
+ }
1322
1664
  const resp = await fetch(source);
1323
1665
  if (!resp.ok) {
1324
1666
  throw new Error(`compressTexture: fetch ${source} failed (${resp.status})`);
1325
1667
  }
1326
1668
  const blob = await resp.blob();
1669
+ if (isSvgBlob(blob) || !isImageMimeType(blob.type) && hasSvgExtension(source)) {
1670
+ return rasterizeSvg(blob, { size: svgSize });
1671
+ }
1327
1672
  return createImageBitmap(blob, opts);
1328
1673
  }
1329
1674
  if (source instanceof Blob) {
1675
+ if (isSvgBlob(source)) {
1676
+ return rasterizeSvg(source, { size: svgSize });
1677
+ }
1330
1678
  return createImageBitmap(source, opts);
1331
1679
  }
1332
1680
  if (source instanceof ImageBitmap) {
1333
1681
  return source;
1334
1682
  }
1683
+ if (typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement) {
1684
+ const src = source.currentSrc || source.src;
1685
+ if (src && (hasSvgExtension(src) || /^data:image\/svg\+xml/i.test(src))) {
1686
+ const resp = await fetch(src);
1687
+ if (!resp.ok) {
1688
+ throw new Error(`compressTexture: fetch ${src} failed (${resp.status})`);
1689
+ }
1690
+ return rasterizeSvg(await resp.blob(), { size: svgSize });
1691
+ }
1692
+ }
1335
1693
  return createImageBitmap(source, opts);
1336
1694
  }
1695
+ function isImageMimeType(type) {
1696
+ return /^image\//i.test(type) && !/svg/i.test(type);
1697
+ }
1337
1698
  function bitmapToMipLevel(bitmap, flipY) {
1338
1699
  const w = bitmap.width, h = bitmap.height;
1339
1700
  const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(w, h) : Object.assign(document.createElement("canvas"), { width: w, height: h });
@@ -1366,7 +1727,9 @@ function wrapUncompressed(bitmap, srgb, flipY) {
1366
1727
  async function compressTexture(source, options = {}) {
1367
1728
  const {
1368
1729
  hint = "color",
1730
+ preferredFormat,
1369
1731
  colorSpace = "srgb",
1732
+ svgSize,
1370
1733
  flipY = true,
1371
1734
  mipmaps = false,
1372
1735
  quality = "fast",
@@ -1374,7 +1737,7 @@ async function compressTexture(source, options = {}) {
1374
1737
  adapter: providedAdapter
1375
1738
  } = options;
1376
1739
  const srgb = colorSpace === "srgb";
1377
- const bitmap = await sourceToBitmap(source);
1740
+ const bitmap = await sourceToBitmap(source, svgSize);
1378
1741
  const viaWebGPU = await encodeViaWebGPU();
1379
1742
  if (viaWebGPU) return viaWebGPU;
1380
1743
  const viaWebGL = encodeViaWebGL();
@@ -1401,7 +1764,7 @@ async function compressTexture(source, options = {}) {
1401
1764
  if (!("gpu" in navigator)) return null;
1402
1765
  const adapter = providedAdapter ?? await navigator.gpu.requestAdapter();
1403
1766
  if (!adapter) return null;
1404
- const selection = selectFormat(adapter, hint, { colorSpace });
1767
+ const selection = selectFormat(adapter, hint, { colorSpace, preferredFormat });
1405
1768
  if (!selection.format || !selection.encoderClass) return null;
1406
1769
  let encoder;
1407
1770
  if (providedDevice) {
@@ -1474,7 +1837,7 @@ async function compressTexture(source, options = {}) {
1474
1837
  const gl = getSharedWebGLContext();
1475
1838
  if (!gl) return null;
1476
1839
  const caps = detectWebGLCapabilities(gl);
1477
- const selection = selectWebGLFormat(caps, hint, { colorSpace });
1840
+ const selection = selectWebGLFormat(caps, hint, { colorSpace, preferredFormat });
1478
1841
  if (!selection.format || !selection.encoderClass) return null;
1479
1842
  const encoder = selection.encoderClass.create(gl);
1480
1843
  try {
@@ -1536,8 +1899,20 @@ import { Loader } from "three";
1536
1899
  var GputexLoader = class extends Loader {
1537
1900
  /** Format-selection hint. Default 'color'. */
1538
1901
  hint = "color";
1902
+ /**
1903
+ * Prefer a specific format (currently only 'bc1') when the device
1904
+ * supports it; normal selection otherwise. See
1905
+ * `CompressOptions.preferredFormat`.
1906
+ */
1907
+ preferredFormat;
1539
1908
  /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
1540
1909
  colorSpace = "srgb";
1910
+ /**
1911
+ * Rasterisation size for SVG URLs — a number (longest side, aspect
1912
+ * preserved) or exact `{ width, height }`. Default: the SVG's intrinsic
1913
+ * size. See `CompressOptions.svgSize`.
1914
+ */
1915
+ svgSize;
1541
1916
  /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
1542
1917
  flipY = true;
1543
1918
  /** Generate + encode a full mip chain. Default false. */
@@ -1567,7 +1942,9 @@ var GputexLoader = class extends Loader {
1567
1942
  this.manager.itemStart(url);
1568
1943
  compressTexture(url, {
1569
1944
  hint: this.hint,
1945
+ preferredFormat: this.preferredFormat,
1570
1946
  colorSpace: this.colorSpace,
1947
+ svgSize: this.svgSize,
1571
1948
  flipY: this.flipY,
1572
1949
  mipmaps: this.mipmaps,
1573
1950
  quality: this.quality,
@@ -1623,6 +2000,7 @@ export {
1623
2000
  getSharedWebGLContext,
1624
2001
  isWebGLAvailable,
1625
2002
  padToBlockMultiple,
2003
+ rasterizeSvg,
1626
2004
  selectFormat,
1627
2005
  selectWebGLFormat,
1628
2006
  threeFormatFor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gputex",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "license": "MIT",
5
5
  "files": [
6
6
  "dist"