gputex 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
- # GPUtex | On-the-fly GPU texture encoding
1
+ # gputex | 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 — 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
-
5
- ⚠️ 100% vibe-coded. The code is completely unreviewed and under-tested. Do not use for anything important.
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, BC1, or ETC2) ready for Three.js or React Three Fiber.
6
4
 
7
5
  🚀 Used in production on [Mana Blade](https://manablade.com).
8
6
 
@@ -28,32 +26,37 @@ bun add gputex
28
26
 
29
27
  ## Formats
30
28
 
31
- | Format | Bytes / 4x4 block | Use case |
32
- | ------------ | ----------------- | --------------------------------------------------------- |
33
- | **BC7** | 16 (8 bpp) | Color / RGBA on desktop (`texture-compression-bc`) |
34
- | **BC5** | 16 (8 bpp) | Normal maps — RG only (`texture-compression-bc`) |
35
- | **ASTC 4x4** | 16 (8 bpp) | Color / RGBA on mobile / iOS (`texture-compression-astc`) |
36
- | **BC1** | 8 (4 bpp) | Opaque color at half BC7's size (opt-in) |
37
-
38
- Format selection is automatic: BC7/BC5 on desktop, ASTC on mobile, uncompressed RGBA8 fallback otherwise.
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.
29
+ | Format | Bytes / 4x4 block | Use case |
30
+ | ------------- | ----------------- | ------------------------------------------------------------------------------- |
31
+ | **BC7** | 16 (8 bpp) | Color / RGBA on desktop (`texture-compression-bc`) |
32
+ | **BC5** | 16 (8 bpp) | Normal maps — RG only (`texture-compression-bc`) |
33
+ | **ASTC 4x4** | 16 (8 bpp) | Color / RGBA on mobile / iOS (`texture-compression-astc`) |
34
+ | **BC1** | 8 (4 bpp) | Opaque color at half BC7's size (`quality: 'low'`) |
35
+ | **ETC2 RGB8** | 8 (4 bpp) | Opaque color at half ASTC's size (`texture-compression-etc2`, `quality: 'low'`) |
36
+
37
+ Format selection is automatic: BC7/BC5 on desktop, ASTC on mobile, ETC2 as the
38
+ last-resort compressed format for opaque colour, uncompressed RGBA8 fallback
39
+ otherwise.
40
+
41
+ The 4-bpp formats are never picked by default — half the memory of BC7/ASTC
42
+ but visibly lower quality, a trade-off only the application can make. Opt in
43
+ with `quality: 'low'`: opaque colour textures then encode as BC1 on BC-capable
44
+ devices and as ETC2 RGB8 on ETC2-capable ones (most mobile GPUs), while
45
+ `'colorWithAlpha'` and `'normal'` hints keep the high-quality formats (the
46
+ 4-bpp formats can't carry them). Per-texture, `preferredFormat: 'bc1'` forces
47
+ BC1 on BC hardware the same way; both knobs fall back to the normal selection
48
+ when unsupported, and both apply to `hint: 'color'` only.
46
49
 
47
50
  ## WebGL fallback
48
51
 
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 two backends are not byte-identical (the WebGPU fast paths use projection assignment and f16 where available), but they implement the same algorithms at the same quality level and the resulting `CompressedTexture` looks the same under either renderer.
52
+ 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 two backends are not byte-identical (the WebGPU shaders use f16 where available), but they implement the same algorithms at the same quality level and the resulting `CompressedTexture` looks the same under either renderer.
50
53
 
51
54
  The fallback chain is **WebGPU → WebGL2 → uncompressed RGBA8**. The `backend` field on the result (`'webgpu' | 'webgl' | 'none'`) tells you which path ran.
52
55
 
53
56
  Notes on the WebGL path:
54
57
 
55
- - It needs the matching WebGL2 compressed-texture extension to be sampleable: `EXT_texture_compression_bptc` (BC7), `EXT_texture_compression_rgtc` (BC5), `WEBGL_compressed_texture_astc` (ASTC), or `WEBGL_compressed_texture_s3tc` (BC1). Selection mirrors the WebGPU side, with BC1 added as a broadly-available last resort for **opaque** colour when neither BPTC nor ASTC is present.
56
- - It always uses the **fast** encoders — the `quality: 'high'` option and the `device` / `adapter` options apply to the WebGPU path only.
58
+ - It needs the matching WebGL2 compressed-texture extension to be sampleable: `EXT_texture_compression_bptc` (BC7), `EXT_texture_compression_rgtc` (BC5), `WEBGL_compressed_texture_astc` (ASTC), or `WEBGL_compressed_texture_s3tc` (BC1). Selection mirrors the WebGPU side, with BC1 added as a broadly-available last resort for **opaque** colour when neither BPTC nor ASTC is present. ETC2 is WebGPU-only (no WebGL fragment encoder), so `quality: 'low'` on the WebGL tier can only deliver BC1.
59
+ - The `device` / `adapter` options apply to the WebGPU path only.
57
60
  - All encoding happens on one shared, off-screen WebGL2 context; nothing is drawn to a visible canvas.
58
61
 
59
62
  ## Usage
@@ -67,38 +70,51 @@ const { texture, format } = await compressTexture('/cobblestone.avif', {
67
70
  hint: 'color', // 'color' | 'colorWithAlpha' | 'normal'
68
71
  colorSpace: 'srgb',
69
72
  mipmaps: true,
70
- quality: 'fast', // 'fast' (default) | 'high'
71
73
  })
72
74
 
73
75
  material.map = texture
74
76
  ```
75
77
 
76
- #### Quality
77
-
78
- `quality` trades encode speed against compression accuracy:
79
-
80
- - **`'fast'` (default)** — a principal-axis endpoint seed (per-block
81
- covariance power-iteration — unlike a bbox diagonal it follows
82
- anti-correlated channels, worth **+2–4 dB on normal-map-like content**)
83
- plus projection-based index assignment (each pixel is projected onto the
84
- colinear endpoint line in O(1) instead of searching every palette entry),
85
- and the block bits packed with straight-line constant shifts. The formats
86
- with coarse 4-level palettes (BC1, ASTC) and BC5 add a least-squares
87
- endpoint refit accepted per block only when it lowers the error; BC7's
88
- 16-level mode-6 palette makes the refit redundant on a principal-axis seed
89
- (≤0.05 dB), so its fast path skips it and stays the cheapest per pixel. On
90
- GPUs that report the `shader-f16` feature the whole fast path (all four
91
- formats, BC1 included) runs in f16 — the f32 path is the automatic
92
- fallback. Net vs `'high'` on an Apple GPU: roughly **10–30× faster**
93
- depending on format, for a PSNR cost of **≤0.35 dB on the test cards**
94
- (BC7 within 0.1 dB of `'high'`; ASTC fast actually measures slightly above
95
- it) and up to a few dB on adversarial high-frequency noise, where any
96
- single-line seed trails `'high'`'s exhaustive search. See the benchmark
97
- table below.
98
- - **`'high'`** — exhaustive endpoint search (farthest-pair seed, full nearest
99
- search, p-bit search); matches the CPU reference encoders block-for-block
100
- (byte-identical on >96% of blocks; the rest are equal-error FP tie-breaks,
101
- enforced by the GPU test suite).
78
+ #### The encoding algorithm
79
+
80
+ There is a single encode mode, built to be both fast and high quality: a
81
+ principal-axis endpoint seed (per-block covariance power-iteration — unlike
82
+ a bbox diagonal it follows anti-correlated channels, worth **+2–4 dB on
83
+ normal-map-like content**) plus projection-based index assignment (each
84
+ pixel is projected onto the colinear endpoint line in O(1) instead of
85
+ searching every palette entry), and the block bits packed with
86
+ straight-line constant shifts. The formats with coarse 4-level palettes
87
+ (BC1, ASTC) add up to two least-squares endpoint refit rounds accepted per
88
+ block only when they lower the error, and BC5 one; BC7's 16-level mode-6
89
+ palette makes the refit redundant on a principal-axis seed (≤0.05 dB), so
90
+ it skips it and stays the cheapest per pixel. On GPUs that report the
91
+ `shader-f16` feature everything runs in f16 — the f32 shaders are the
92
+ automatic fallback.
93
+
94
+ ETC2 is the exception to the endpoint-line story: its blocks are per-subblock
95
+ base colours shifted by scalar modifier tables. The encoder exploits the
96
+ algebra of that scalar shift — table and index selection depend only on each
97
+ texel's luma-sum difference from the base, exactly (modulo decode clamping) —
98
+ so the whole 8-table × 4-modifier search collapses to a handful of scalar
99
+ threshold tests against a two-candidate table shortlist, with subblock error
100
+ constants and the flip preselect computed O(1) from quadrant sums. A gated
101
+ base-colour refit and a closed-form least-squares fit of ETC2's planar mode
102
+ (which rescues the smooth gradients ETC1-style blocks band on) complete the
103
+ block, all driven by the same estimates. The rewrite took the GPU pass
104
+ from 6.0 ms to ~0.2 ms at 2048² (30×, within ~0.2 dB of the exhaustive
105
+ search on photographic content — only the base refit was traded for
106
+ speed). Its f16 module is EXACT-VALUE: lumas, D values and thresholds
107
+ are integers f16 represents exactly, while the sums-of-squares estimates
108
+ stay f32 (they overflow f16), so the two modules produce byte-identical
109
+ output — f16 buys register pressure on mobile GPUs, not different
110
+ results.
111
+
112
+ On the repo's test cards this lands within **≤0.1 dB** of the exhaustive
113
+ per-block reference encoders (BC5 matches the reference exactly; ASTC and
114
+ BC1-on-normal-maps measure slightly above it), trailing only on adversarial
115
+ high-frequency noise, where any single-line seed loses to an exhaustive
116
+ search — while encoding an order of magnitude faster. See the benchmark
117
+ table below.
102
118
 
103
119
  #### SVG sources
104
120
 
@@ -224,6 +240,38 @@ const { data, width, height, paddedWidth, paddedHeight } = await encoder.encodeT
224
240
  encoder.destroy()
225
241
  ```
226
242
 
243
+ For mip chains, `encodeMipChainToBytes()` encodes every level in a **single
244
+ GPU submission** — one compute pass and one readback instead of a full
245
+ CPU↔GPU round trip per level (an 11-level 1024² chain is one `mapAsync`
246
+ wait instead of eleven):
247
+
248
+ ```ts
249
+ import { BC7Encoder, generateMipChain } from 'gputex'
250
+
251
+ const encoder = await BC7Encoder.create()
252
+ // level0 = { data: Uint8ClampedArray (RGBA8), width, height }
253
+ const { levels, encodeMs } = await encoder.encodeMipChainToBytes(generateMipChain(level0))
254
+ // levels[i] = { data, width, height, paddedWidth, paddedHeight }
255
+ ```
256
+
257
+ When the source is an image (not raw pixels), skip the CPU entirely:
258
+ `generateGpuMipChain()` uploads it once and box-filters the whole chain on
259
+ the GPU in one compute pass, and `encodeMipChainFromTexture()` encodes
260
+ straight from the texture's mip views — no `getImageData` readback, no JS
261
+ filter, no per-level uploads. This is what `compressTexture()` uses for
262
+ `mipmaps: true` (mipped BC7: 28 → 7.5 ms at 2048², 110 → 23 ms at 4096²),
263
+ and its box filter is integer-exact against the CPU one, so both paths emit
264
+ identical bytes:
265
+
266
+ ```ts
267
+ import { BC7Encoder, generateGpuMipChain } from 'gputex'
268
+
269
+ const encoder = await BC7Encoder.create()
270
+ const chainTex = await generateGpuMipChain(encoder.device, imageBitmap, { flipY: true })
271
+ const { levels, encodeMs } = await encoder.encodeMipChainFromTexture(chainTex)
272
+ chainTex.destroy()
273
+ ```
274
+
227
275
  To turn an encoder's output into a Three.js `CompressedTexture` directly, use the helpers in `gputex/three`:
228
276
 
229
277
  ```ts
@@ -244,15 +292,42 @@ const tex = buildCompressedTexture([bytes], TextureFormat.BC7_SRGB)
244
292
 
245
293
  ### `compressTexture` options
246
294
 
247
- | Option | Type | Default | Description |
248
- | ----------------- | ----------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
249
- | `hint` | `TextureHint` | `'color'` | `'color'`, `'colorWithAlpha'`, or `'normal'` |
250
- | `preferredFormat` | `'bc1'` | — | Prefer BC1 (half of BC7's size) when supported; normal selection otherwise. `hint: 'color'` only |
251
- | `colorSpace` | `'srgb' \| 'linear'` | `'srgb'` | Use the sRGB or linear variant of the chosen format |
252
- | `svgSize` | `number \| { width, height }` | intrinsic | Raster size for SVG sources: longest side (aspect preserved) or exact size |
253
- | `flipY` | `boolean` | `true` | Flip vertically (matches Three.js convention) |
254
- | `mipmaps` | `boolean` | `false` | Generate full mip chain down to 1x1 |
255
- | `device` | `GPUDevice` | — | Reuse an existing WebGPU device instead of creating one |
295
+ | Option | Type | Default | Description |
296
+ | ----------------- | ----------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------ |
297
+ | `hint` | `TextureHint` | `'color'` | `'color'`, `'colorWithAlpha'`, or `'normal'` |
298
+ | `quality` | `'high' \| 'low'` | `'high'` | `'low'` picks the 4-bpp formats (BC1 on desktop, ETC2 RGB8 on mobile) for opaque colour — half the memory, lower quality |
299
+ | `preferredFormat` | `'bc1'` | — | Prefer BC1 (half of BC7's size) when supported; normal selection otherwise. `hint: 'color'` only |
300
+ | `colorSpace` | `'srgb' \| 'linear'` | `'srgb'` | Use the sRGB or linear variant of the chosen format |
301
+ | `svgSize` | `number \| { width, height }` | intrinsic | Raster size for SVG sources: longest side (aspect preserved) or exact size |
302
+ | `flipY` | `boolean` | `true` | Flip vertically (matches Three.js convention) |
303
+ | `mipmaps` | `boolean` | `false` | Generate full mip chain down to 1x1 |
304
+ | `cache` | `boolean` | `false` | Session-scoped in-memory cache; repeat calls skip decode + encode (see below) |
305
+ | `cacheKey` | `string` | derived | Explicit cache identity (skips content hashing; makes pixel sources cacheable) |
306
+ | `device` | `GPUDevice` | — | Reuse an existing WebGPU device instead of creating one |
307
+
308
+ #### In-memory transcode cache
309
+
310
+ With `cache: true`, the compressed bytes are kept in a session-scoped
311
+ in-memory LRU keyed by source identity (URL, or a content hash for
312
+ Blobs/Files/data URLs) plus the selected format and encode options. Loading
313
+ the same texture again later in the session — say, two worlds sharing an
314
+ atlas — skips **both** the image decode and the encode, the two dominant
315
+ costs: a 4K PNG that takes ~220 ms to decode + encode comes back in ~30 ms
316
+ (content-hashed) or ~2 ms (URL-keyed). Nothing touches persistent storage;
317
+ the cache dies with the page. Total compressed payload is capped at 256 MiB
318
+ with LRU eviction — `setTranscodeCacheLimit(bytes)` tunes it (0 disables),
319
+ `clearTranscodeCache()` empties it (e.g. on world unload). Pixel sources
320
+ (ImageBitmap, canvas, ImageData) are only cached when you pass a `cacheKey`.
321
+
322
+ When neither `device` nor `adapter` is passed, `compressTexture()` shares one
323
+ WebGPU device and one encoder per format across calls: the first call pays the
324
+ adapter/device request and pipeline compile, subsequent calls skip straight to
325
+ the encode and reuse the encoder's cached GPU resources. The result's
326
+ `destroy()` only disposes that call's texture; call `releaseSharedGpuResources()`
327
+ (also exported from `gputex/three`) to tear down the shared device — the next
328
+ `compressTexture()` call transparently recreates it. With `mipmaps: true` the
329
+ whole chain is encoded in a single GPU submission (one compute pass, one
330
+ readback) rather than a round trip per level.
256
331
 
257
332
  ## Benchmarks
258
333
 
@@ -265,20 +340,26 @@ buffers, bind group) across encodes, so repeated encodes — including mip
265
340
  chains — skip per-call allocation: in an interleaved A/B this cuts BC7
266
341
  end-to-end wall time by ~10% at 512², ~20% at 1024–2048² and ~35% at 4096².
267
342
 
268
- | Format | Quality | Shader | GPU pass |
269
- | -------- | -------------- | ------ | ----------- |
270
- | BC1 | fast (default) | f16 | **0.26 ms** |
271
- | BC1 | fast | f32 | 0.46 ms |
272
- | BC5 | fast (default) | f16 | **0.26 ms** |
273
- | BC5 | fast | f32 | 0.26 ms |
274
- | BC7 | fast (default) | f16 | **0.26 ms** |
275
- | BC7 | fast | f32 | 0.59 ms |
276
- | ASTC 4×4 | fast (default) | f16 | **0.26 ms** |
277
- | ASTC 4×4 | fast | f32 | 0.56 ms |
278
- | BC1 | high | f32 | 2.7 ms |
279
- | BC5 | high | f32 | 1.0 ms |
280
- | BC7 | high | f32 | 10.4 ms |
281
- | ASTC 4×4 | high | f32 | 1.6 ms |
343
+ | Format | Shader | GPU pass |
344
+ | -------- | ------------- | ----------- |
345
+ | BC1 | f16 (default) | **0.26 ms** |
346
+ | BC1 | f32 | 0.46 ms |
347
+ | BC5 | f16 (default) | **0.26 ms** |
348
+ | BC5 | f32 | 0.26 ms |
349
+ | BC7 | f16 (default) | **0.26 ms** |
350
+ | BC7 | f32 | 0.59 ms |
351
+ | ASTC 4×4 | f16 (default) | **0.26 ms** |
352
+ | ASTC 4×4 | f32 | 0.56 ms |
353
+ | ETC2 | f16 + f32 | 0.20 ms |
354
+
355
+ The ETC2 figure is the interleaved `/ab` harness measurement (batched
356
+ dispatches, clock-stable). On a 100 GB/s part just reading the 2048² RGBA8
357
+ source costs ~0.15 ms, so the entire selection algorithm adds ~30% on top
358
+ of touching the bytes. Two faster variants live in git history and were
359
+ deliberately not shipped: a two-pass 2 B/px prepared source (encode pass
360
+ 0.115 ms, but the prep pass is also bandwidth-bound and cannot overlap, so
361
+ the per-texture total regressed) and an O(1) hedged table pick (−3% for
362
+ −0.5 dB — a poor trade against the scored search).
282
363
 
283
364
  Timestamps are quantised to 100 µs by Chrome and Apple GPU clock states swing
284
365
  timings by ~2×, so sub-millisecond figures are indicative (±0.1 ms); compare
@@ -297,39 +378,38 @@ cd example && bunx next dev # then open http://localhost:3000/test
297
378
  ```
298
379
 
299
380
  A second page, `/bench`, measures median end-to-end `encodeToBytes()` wall
300
- time per format across image sizes (256²–4096²) at fast quality — the
301
- numbers that matter for runtime streaming, where host overhead dominates
302
- small textures (results on `window.__GPUTEX_BENCH__`).
381
+ time per format across image sizes (256²–4096²) — the numbers that matter
382
+ for runtime streaming, where host overhead dominates small textures
383
+ (results on `window.__GPUTEX_BENCH__`).
303
384
 
304
385
  The page runs three groups against the live WebGPU device and renders
305
386
  PASS/FAIL tables (machine-readable copy on `window.__GPUTEX_TESTS__`):
306
387
 
307
- - **Correctness** — `quality: 'high'` output is compared block-by-block
308
- against the CPU reference encoders (`gputex/testing`), including a
309
- non-multiple-of-4 image for the clamp-to-edge padding path. Differing blocks
310
- must have equal decoded error (FP tie-break tolerance) and the aggregate
311
- PSNR delta must be ≤0.05 dB. Plus determinism checks (same input twice →
312
- identical bytes).
313
- - **Quality** — `'fast'` and `'high'` output is CPU-decoded and validated on
314
- the FULL 512² test cards (every quadrant stresses a different failure mode)
315
- with two gates, for both the f16 and (force-disabled-f16) f32 shaders:
316
- aggregate PSNR must beat per-format thresholds pinned ~0.15 dB under the
317
- measured baseline, and — because a handful of catastrophically wrong blocks
318
- barely moves aggregate PSNR — the worst _easy_ block (one that `'high'`
319
- encodes near-losslessly) must not exceed `'high'`'s error by more than a
320
- small per-format limit.
388
+ - **Correctness** — determinism (same input twice → identical bytes) and the
389
+ clamp-to-edge padding path: a non-multiple-of-4 image must land within a
390
+ couple of dB of the exhaustive CPU reference encode (`gputex/testing`) — a
391
+ padding bug craters it.
392
+ - **Quality** — GPU output is CPU-decoded and validated on the FULL 1024²
393
+ test cards (every tile stresses a different failure mode) with two gates,
394
+ for both the f16 and (force-disabled-f16) f32 shaders: aggregate PSNR must
395
+ beat per-format thresholds pinned ~0.15 dB under the measured baseline,
396
+ and — because a handful of catastrophically wrong blocks barely moves
397
+ aggregate PSNR — the worst _easy_ block (one the exhaustive CPU reference
398
+ encodes near-losslessly) must not exceed the reference's error by more
399
+ than a small per-format limit.
321
400
  - **Performance** — the benchmark table above: wall + GPU-pass time per
322
- format × quality × shader variant.
401
+ format × shader variant.
323
402
 
324
403
  The `gputex/testing` entry point exports the CPU reference
325
- encoders/decoders (`encodeBC7Mode6Block`, `decodeASTC4x4Block`, …) so any
404
+ encoders/decoders (`encodeBC7Mode6Block`, `decodeASTC4x4Block`, …) — the
405
+ exhaustive per-block yardstick the GPU shaders are gated against — so any
326
406
  consumer can run the same validation.
327
407
 
328
408
  ## Requirements
329
409
 
330
410
  - WebGPU (primary) **or** WebGL2 (fallback) — almost every current browser has at least one
331
411
  - A compressed-texture capability for compressed output:
332
- - WebGPU: `texture-compression-bc` (desktop) or `texture-compression-astc` (mobile)
412
+ - WebGPU: `texture-compression-bc` (desktop), `texture-compression-astc` (mobile), or `texture-compression-etc2` (mobile)
333
413
  - WebGL2: `EXT_texture_compression_bptc` / `_rgtc`, `WEBGL_compressed_texture_astc`, or `WEBGL_compressed_texture_s3tc`
334
414
  - Falls back to uncompressed RGBA8 when no compressed format is available on either backend
335
415
 
@@ -339,4 +419,4 @@ consumer can run the same validation.
339
419
 
340
420
  ## Acknowledgements
341
421
 
342
- The concept of encoding images on the GPU on the fly via compute shaders was first introduced by [spark.js](https://ludicon.com/sparkjs/). GPUtex is not derived from Spark. Its encoders have been implemented from scratch using official references, which have been ported to TypeScript, and then converted to WGSL and GLSL via AI. Spark was never mentioned or used as reference at any point of the implementation, and multiple reviews have found the implementations to be completely independent. For any serious production use of GPU-compressed textures, Spark is the recommended choice over GPUtex.
422
+ The concept of encoding images on the GPU on the fly via compute shaders was first introduced by [spark.js](https://ludicon.com/sparkjs/). gputex is not derived from Spark. Its encoders have been implemented from scratch using official references, which have been ported to TypeScript, and then converted to WGSL and GLSL via AI. Spark was never mentioned or used as reference at any point of the implementation, and multiple reviews have found the implementations to be completely independent. For any serious production use of GPU-compressed textures, Spark is the recommended choice over gputex.