gputex 0.4.0 → 0.6.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 +121 -31
- package/dist/index.d.ts +330 -29
- package/dist/index.js +1815 -522
- package/dist/testing.d.ts +52 -15
- package/dist/testing.js +935 -109
- package/dist/three.d.ts +19 -69
- package/dist/three.js +1743 -666
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
#
|
|
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
|
|
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,21 +26,26 @@ bun add gputex
|
|
|
28
26
|
|
|
29
27
|
## Formats
|
|
30
28
|
|
|
31
|
-
| Format
|
|
32
|
-
|
|
|
33
|
-
| **BC7**
|
|
34
|
-
| **BC5**
|
|
35
|
-
| **ASTC 4x4**
|
|
36
|
-
| **BC1**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
|
@@ -52,7 +55,7 @@ The fallback chain is **WebGPU → WebGL2 → uncompressed RGBA8**. The `backend
|
|
|
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.
|
|
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.
|
|
56
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
|
|
|
@@ -88,6 +91,24 @@ it skips it and stays the cheapest per pixel. On GPUs that report the
|
|
|
88
91
|
`shader-f16` feature everything runs in f16 — the f32 shaders are the
|
|
89
92
|
automatic fallback.
|
|
90
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
|
+
|
|
91
112
|
On the repo's test cards this lands within **≤0.1 dB** of the exhaustive
|
|
92
113
|
per-block reference encoders (BC5 matches the reference exactly; ASTC and
|
|
93
114
|
BC1-on-normal-maps measure slightly above it), trailing only on adversarial
|
|
@@ -219,6 +240,38 @@ const { data, width, height, paddedWidth, paddedHeight } = await encoder.encodeT
|
|
|
219
240
|
encoder.destroy()
|
|
220
241
|
```
|
|
221
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
|
+
|
|
222
275
|
To turn an encoder's output into a Three.js `CompressedTexture` directly, use the helpers in `gputex/three`:
|
|
223
276
|
|
|
224
277
|
```ts
|
|
@@ -239,15 +292,42 @@ const tex = buildCompressedTexture([bytes], TextureFormat.BC7_SRGB)
|
|
|
239
292
|
|
|
240
293
|
### `compressTexture` options
|
|
241
294
|
|
|
242
|
-
| Option | Type | Default | Description
|
|
243
|
-
| ----------------- | ----------------------------- | --------- |
|
|
244
|
-
| `hint` | `TextureHint` | `'color'` | `'color'`, `'colorWithAlpha'`, or `'normal'`
|
|
245
|
-
| `
|
|
246
|
-
| `
|
|
247
|
-
| `
|
|
248
|
-
| `
|
|
249
|
-
| `
|
|
250
|
-
| `
|
|
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.
|
|
251
331
|
|
|
252
332
|
## Benchmarks
|
|
253
333
|
|
|
@@ -270,6 +350,16 @@ end-to-end wall time by ~10% at 512², ~20% at 1024–2048² and ~35% at 4096².
|
|
|
270
350
|
| BC7 | f32 | 0.59 ms |
|
|
271
351
|
| ASTC 4×4 | f16 (default) | **0.26 ms** |
|
|
272
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).
|
|
273
363
|
|
|
274
364
|
Timestamps are quantised to 100 µs by Chrome and Apple GPU clock states swing
|
|
275
365
|
timings by ~2×, so sub-millisecond figures are indicative (±0.1 ms); compare
|
|
@@ -319,7 +409,7 @@ consumer can run the same validation.
|
|
|
319
409
|
|
|
320
410
|
- WebGPU (primary) **or** WebGL2 (fallback) — almost every current browser has at least one
|
|
321
411
|
- A compressed-texture capability for compressed output:
|
|
322
|
-
- WebGPU: `texture-compression-bc` (desktop) or `texture-compression-
|
|
412
|
+
- WebGPU: `texture-compression-bc` (desktop), `texture-compression-astc` (mobile), or `texture-compression-etc2` (mobile)
|
|
323
413
|
- WebGL2: `EXT_texture_compression_bptc` / `_rgtc`, `WEBGL_compressed_texture_astc`, or `WEBGL_compressed_texture_s3tc`
|
|
324
414
|
- Falls back to uncompressed RGBA8 when no compressed format is available on either backend
|
|
325
415
|
|
|
@@ -329,4 +419,4 @@ consumer can run the same validation.
|
|
|
329
419
|
|
|
330
420
|
## Acknowledgements
|
|
331
421
|
|
|
332
|
-
The concept of encoding images on the GPU on the fly via compute shaders was first introduced by [spark.js](https://ludicon.com/sparkjs/).
|
|
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.
|