gputex 0.4.0 → 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,21 +26,26 @@ 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
 
@@ -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
- | `preferredFormat` | `'bc1'` | — | Prefer BC1 (half of BC7's size) when supported; normal selection otherwise. `hint: 'color'` only |
246
- | `colorSpace` | `'srgb' \| 'linear'` | `'srgb'` | Use the sRGB or linear variant of the chosen format |
247
- | `svgSize` | `number \| { width, height }` | intrinsic | Raster size for SVG sources: longest side (aspect preserved) or exact size |
248
- | `flipY` | `boolean` | `true` | Flip vertically (matches Three.js convention) |
249
- | `mipmaps` | `boolean` | `false` | Generate full mip chain down to 1x1 |
250
- | `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.
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-astc` (mobile)
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/). 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.
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ declare const TextureFormat: {
6
6
  readonly BC7_SRGB: "BC7_SRGB";
7
7
  readonly ASTC_4x4: "ASTC_4x4";
8
8
  readonly ASTC_4x4_SRGB: "ASTC_4x4_SRGB";
9
+ readonly ETC2_RGB8: "ETC2_RGB8";
10
+ readonly ETC2_RGB8_SRGB: "ETC2_RGB8_SRGB";
9
11
  };
10
12
  type TextureFormat = (typeof TextureFormat)[keyof typeof TextureFormat];
11
13
  declare const WebGPUFeature: {
@@ -33,6 +35,31 @@ interface Capabilities {
33
35
  }
34
36
  declare function detectCapabilities(adapter: FeatureProvider): Capabilities;
35
37
 
38
+ /** One level of a mip chain. 4 bytes per pixel (RGBA8). */
39
+ interface MipLevel {
40
+ data: Uint8ClampedArray;
41
+ width: number;
42
+ height: number;
43
+ }
44
+ /**
45
+ * Produce the full mip chain from a level-0 image. The chain goes down
46
+ * to a 1×1 level — the standard OpenGL / WebGPU convention — so the
47
+ * caller gets `floor(log2(max(w, h))) + 1` levels total.
48
+ *
49
+ * Levels whose logical dimensions are below the encoder's 4×4 block
50
+ * grid are still produced here at their true logical size; padding up
51
+ * to a single block is the encoder's job, not ours.
52
+ */
53
+ declare function generateMipChain(level0: MipLevel): MipLevel[];
54
+ /**
55
+ * Pad a mip level up to a multiple of 4 in each dimension using clamp-
56
+ * to-edge sampling. Used before handing sub-4×4 levels to a block-
57
+ * compression encoder, which requires at least one full block per level.
58
+ *
59
+ * If the input is already block-aligned this returns the input unchanged.
60
+ */
61
+ declare function padToBlockMultiple(level: MipLevel): MipLevel;
62
+
36
63
  /**
37
64
  * Anything `GPUQueue.copyExternalImageToTexture` accepts. Matches the
38
65
  * WebGPU spec's CopyExternalImageSource set.
@@ -54,17 +81,21 @@ interface EncodeCallOptions {
54
81
  colorSpace?: 'srgb' | 'linear';
55
82
  }
56
83
  /**
57
- * Result of a raw bytes-only encode. This is the encoder's native output: the
58
- * compressed block bytes plus dimensions, with no Three.js (or any engine)
59
- * involvement. Feed `data` into whatever renderer's compressed-texture upload
60
- * you like, or use `buildCompressedTexture()` from `gputex/three`.
84
+ * One level of encoded output: the compressed block bytes plus logical and
85
+ * block-aligned dimensions. This is the encoder's native output shape, with
86
+ * no Three.js (or any engine) involvement — feed `data` into whatever
87
+ * renderer's compressed-texture upload you like, or use
88
+ * `buildCompressedTexture()` from `gputex/three`.
61
89
  */
62
- interface EncodeBytesResult {
90
+ interface EncodedLevelBytes {
63
91
  width: number;
64
92
  height: number;
65
93
  paddedWidth: number;
66
94
  paddedHeight: number;
67
95
  data: Uint8Array;
96
+ }
97
+ /** Result of a raw bytes-only single-image encode. */
98
+ interface EncodeBytesResult extends EncodedLevelBytes {
68
99
  encodeMs: number;
69
100
  /**
70
101
  * GPU-side compute-pass time in ms, measured with timestamp queries.
@@ -75,6 +106,16 @@ interface EncodeBytesResult {
75
106
  */
76
107
  gpuMs?: number;
77
108
  }
109
+ /** Result of a whole-chain encode — see `Encoder.encodeMipChainToBytes()`. */
110
+ interface EncodeMipChainResult {
111
+ /** Encoded levels in the order given; `levels[0]` is the base level. */
112
+ levels: EncodedLevelBytes[];
113
+ /** Wall-clock time for the whole chain: uploads → dispatches → readback. */
114
+ encodeMs: number;
115
+ /** GPU compute time for the whole chain's single compute pass (see
116
+ * `EncodeBytesResult.gpuMs` for caveats). */
117
+ gpuMs?: number;
118
+ }
78
119
  interface FormatVariant {
79
120
  colorSpace: 'srgb' | 'linear';
80
121
  }
@@ -116,16 +157,34 @@ declare abstract class Encoder {
116
157
  readonly adapter?: GPUAdapter;
117
158
  readonly ownsDevice: boolean;
118
159
  readonly disableF16: boolean;
119
- protected _pipeline: GPUComputePipeline;
160
+ protected _pipelineReady: Promise<GPUComputePipeline>;
161
+ protected _prepPipelineReady: Promise<GPUComputePipeline> | null;
120
162
  private _cachedSrcTex;
121
163
  private _cachedSrcW;
122
164
  private _cachedSrcH;
165
+ private _cachedSrcSource;
166
+ private _cachedSrcFlipY;
123
167
  private _cachedDst;
124
168
  private _cachedStaging;
125
169
  private _cachedParams;
126
170
  private _lastParams;
127
171
  private _cachedBindGroup;
172
+ private _cachedPrepPlanes;
173
+ private _cachedPrepBindGroup;
128
174
  private _resourcesBusy;
175
+ /** Set when the active shader declares a @binding(3) sampler (the BC5
176
+ * kernels read texels through textureGather + clamp-to-edge). */
177
+ private _usesSampler;
178
+ private _sampler;
179
+ private _chainSig;
180
+ private _chainTextures;
181
+ private _chainPrepPlanes;
182
+ private _chainPrepBindGroups;
183
+ private _chainParams;
184
+ private _chainBindGroups;
185
+ private _chainDst;
186
+ private _chainStaging;
187
+ private _chainBusy;
129
188
  constructor({ device, adapter, ownsDevice, disableF16 }: EncoderOptions);
130
189
  protected _buildPipeline(): void;
131
190
  destroy(): void;
@@ -133,10 +192,27 @@ declare abstract class Encoder {
133
192
  abstract get label(): string;
134
193
  /** 8 for BC1/BC4, 16 for BC5/BC7/ASTC 4×4. */
135
194
  abstract get bytesPerBlock(): number;
195
+ /**
196
+ * Pipeline-creation override constants for the active shader, or
197
+ * undefined for none. Subclasses expose quality/behaviour toggles this
198
+ * way so the OFF state is dead-coded by the shader compiler instead of
199
+ * branched at runtime.
200
+ */
201
+ protected pipelineConstants(): Record<string, number> | undefined;
136
202
  /** WGSL `@workgroup_size` dimensions. Default 8×8×1. */
137
203
  get workgroupSize(): readonly [number, number, number];
138
204
  /** Whether this format has an sRGB variant. Default true. */
139
205
  get supportsSrgb(): boolean;
206
+ /**
207
+ * Format of the single-shot source texture the encode pass samples.
208
+ * Encoders that read only a channel subset can narrow it to cut DRAM
209
+ * traffic on the bandwidth-bound compute pass (BC5 reads rg8unorm — half
210
+ * the bytes of rgba8). Must be valid as a copyExternalImageToTexture
211
+ * destination and renderable. Chain encodes keep rgba8unorm regardless
212
+ * (their inputs are RGBA mip levels / texture views); the WGSL is
213
+ * format-agnostic (`texture_2d<f32>`), so mixing is byte-identical.
214
+ */
215
+ protected get srcTextureFormat(): GPUTextureFormat;
140
216
  /**
141
217
  * Optional f16 WGSL variant. Used only when the device reports the
142
218
  * `shader-f16` feature; the format's f32 `wgslSource()` is the automatic
@@ -147,6 +223,21 @@ declare abstract class Encoder {
147
223
  protected get _useF16(): boolean;
148
224
  /** WGSL compute-shader source (f32; the fallback when f16 is unavailable). */
149
225
  abstract wgslSource(): string;
226
+ /** WGSL for the preparation pass, or null when the encoder reads the
227
+ * RGBA8 source directly. */
228
+ protected wgslPrepSource(): string | null;
229
+ /** Formats and sizes of the prepared planes for a padded source size. */
230
+ protected prepPlanes(paddedWidth: number, paddedHeight: number): {
231
+ format: GPUTextureFormat;
232
+ width: number;
233
+ height: number;
234
+ }[];
235
+ /** Workgroup counts for the prep dispatch. */
236
+ protected prepDispatch(blocksX: number, blocksY: number): [number, number];
237
+ /** Create the prepared-plane textures for one padded source size. */
238
+ private _createPrepPlanes;
239
+ /** Bind group for one prep dispatch. */
240
+ private _createPrepBindGroup;
150
241
  /** e.g. 'bc1-rgba-unorm-srgb'. */
151
242
  abstract gpuTextureFormat(opts: FormatVariant): GPUTextureFormat;
152
243
  /**
@@ -166,6 +257,55 @@ declare abstract class Encoder {
166
257
  flipY?: boolean;
167
258
  withGpuTime?: boolean;
168
259
  }): Promise<EncodeBytesResult>;
260
+ /**
261
+ * Encode a whole mip chain in ONE GPU submission. A per-level
262
+ * `encodeToBytes()` loop costs a full CPU↔GPU round trip per level (a
263
+ * 1024² chain is 11 levels → 11 `mapAsync` waits with the GPU idle in
264
+ * between); this path uploads every level, records one dispatch per level
265
+ * into a single compute pass, copies all outputs into one staging buffer
266
+ * and maps it once.
267
+ *
268
+ * `levels` are raw RGBA8 pixels in base-to-tail order; sizes don't have to
269
+ * halve level-to-level (each level is padded and clamped independently,
270
+ * exactly like `encodeToBytes`). Uploads use `writeTexture` — the direct
271
+ * raw-bytes path, which also sidesteps the broken
272
+ * `copyExternalImageToTexture` devices (see workarounds.ts). There is no
273
+ * flip option: bake any vertical flip into level 0 before generating the
274
+ * chain, as `compressTexture()` does.
275
+ */
276
+ encodeMipChainToBytes(levels: readonly MipLevel[], { withGpuTime }?: {
277
+ withGpuTime?: boolean;
278
+ }): Promise<EncodeMipChainResult>;
279
+ /**
280
+ * Encode every mip level of a GPU-resident texture in one submission —
281
+ * the zero-CPU-pixels counterpart of `encodeMipChainToBytes()`. Pair it
282
+ * with `generateGpuMipChain()` (gpuMipgen.ts): upload the image once,
283
+ * box-filter the chain on the GPU, then encode straight from the
284
+ * texture's mip views. Pixels never transit the CPU between the source
285
+ * image and the compressed-bytes readback.
286
+ *
287
+ * `srcTex` must be `rgba8unorm` with TEXTURE_BINDING usage; level 0's
288
+ * dimensions are taken from the texture and lower levels follow the
289
+ * standard floor-halving chain. Encoded output is identical to feeding
290
+ * the equivalent CPU chain to `encodeMipChainToBytes()`.
291
+ */
292
+ encodeMipChainFromTexture(srcTex: GPUTexture, { withGpuTime }?: {
293
+ withGpuTime?: boolean;
294
+ }): Promise<EncodeMipChainResult>;
295
+ /** Block-grid geometry + packed output offsets for a chain of levels.
296
+ * `byteSpan` is both the dst buffer size and the readback copy size (a
297
+ * multiple of 4: byteLen is a multiple of bytesPerBlock ≥ 8, offsets are
298
+ * CHAIN_ALIGN-ed). */
299
+ private _chainGeometry;
300
+ /** Shared chain-encode tail: one compute pass with a dispatch per level,
301
+ * one submit, one staging readback sliced into per-level byte arrays. */
302
+ private _submitChainAndRead;
303
+ /** Create the query set + resolve/staging buffers for one timed
304
+ * submission, or null when the device lacks 'timestamp-query'. */
305
+ private _createTiming;
306
+ /** Read back a timed submission's pass duration (ms) and destroy the
307
+ * timing objects. Timestamps are u64 nanoseconds. */
308
+ private _readTimingMs;
169
309
  }
170
310
 
171
311
  declare class BC1Encoder extends Encoder {
@@ -185,14 +325,26 @@ declare class BC5Encoder extends Encoder {
185
325
  get label(): string;
186
326
  get bytesPerBlock(): number;
187
327
  get supportsSrgb(): boolean;
328
+ protected get srcTextureFormat(): GPUTextureFormat;
188
329
  wgslSource(): string;
189
330
  wgslSourceFastF16(): string;
190
331
  gpuTextureFormat(): GPUTextureFormat;
191
332
  }
192
333
 
334
+ interface BC7EncoderOptions extends EncoderOptions {
335
+ /**
336
+ * Emit BC7 mode 4 on decorrelated blocks (normal maps, channel-packed
337
+ * atlases): a large quality win there, at up to ~1.5× encode time on
338
+ * exactly that content. See the header note for the measurements.
339
+ */
340
+ adaptiveMode4?: boolean;
341
+ }
193
342
  declare class BC7Encoder extends Encoder {
194
343
  static readonly requiredFeature: GPUFeatureName;
195
344
  static readonly textureFormats: readonly TextureFormat[];
345
+ private readonly adaptiveMode4;
346
+ constructor(opts: BC7EncoderOptions);
347
+ protected pipelineConstants(): Record<string, number> | undefined;
196
348
  get label(): string;
197
349
  get bytesPerBlock(): number;
198
350
  get supportsSrgb(): boolean;
@@ -212,6 +364,17 @@ declare class ASTC4x4Encoder extends Encoder {
212
364
  gpuTextureFormat({ colorSpace }: FormatVariant): GPUTextureFormat;
213
365
  }
214
366
 
367
+ declare class ETC2Encoder extends Encoder {
368
+ static readonly requiredFeature: GPUFeatureName;
369
+ static readonly textureFormats: readonly TextureFormat[];
370
+ get label(): string;
371
+ get bytesPerBlock(): number;
372
+ get supportsSrgb(): boolean;
373
+ wgslSource(): string;
374
+ wgslSourceFastF16(): string;
375
+ gpuTextureFormat({ colorSpace }: FormatVariant): GPUTextureFormat;
376
+ }
377
+
215
378
  /** Raw RGBA8 pixel data (e.g. a CPU-generated mip level). */
216
379
  interface RawPixelSource {
217
380
  data: ArrayBufferView;
@@ -358,16 +521,26 @@ type TextureHint = 'color' | 'colorWithAlpha' | 'normal';
358
521
  /**
359
522
  * Optional format preference — a wish, not a demand. Applied when the
360
523
  * device supports the format and the hint is compatible; otherwise
361
- * selection proceeds normally (BC7 → ASTC → null). Currently only
524
+ * selection proceeds normally (BC7 → ASTC → ETC2 → null). Currently only
362
525
  * 'bc1': half the memory of BC7 (0.5 vs 1 byte/pixel) for opaque
363
526
  * colour, at visibly lower quality on smooth content.
364
527
  */
365
528
  type PreferredFormat = 'bc1';
529
+ /**
530
+ * Memory/fidelity trade-off for opaque colour textures. 'high' (default)
531
+ * picks the 8-bpp formats (BC7 / ASTC 4×4); 'low' picks the 4-bpp ones
532
+ * (BC1 / ETC2 RGB8) when the adapter supports one. Hints that the 4-bpp
533
+ * formats can't carry ('colorWithAlpha', 'normal') ignore this and keep
534
+ * the 'high' selection.
535
+ */
536
+ type FormatQuality = 'high' | 'low';
366
537
  interface SelectFormatOptions {
367
538
  /** Pick the sRGB variant when the format has one. Default 'srgb'. */
368
539
  colorSpace?: 'srgb' | 'linear';
369
540
  /** Prefer a specific format when supported. See `PreferredFormat`. */
370
541
  preferredFormat?: PreferredFormat;
542
+ /** Memory/fidelity trade-off. See `FormatQuality`. Default 'high'. */
543
+ quality?: FormatQuality;
371
544
  }
372
545
  interface FormatSelection {
373
546
  /** null = no compressed path on this adapter; caller should fall back. */
@@ -393,30 +566,21 @@ interface WebGLFormatSelection {
393
566
  }
394
567
  declare function selectWebGLFormat(caps: WebGLCapabilities, hint: TextureHint, options?: SelectFormatOptions): WebGLFormatSelection;
395
568
 
396
- /** One level of a mip chain. 4 bytes per pixel (RGBA8). */
397
- interface MipLevel {
398
- data: Uint8ClampedArray;
399
- width: number;
400
- height: number;
401
- }
569
+ /** Matches mipgen's chain length: floor(log2(max(w, h))) + 1 levels. */
570
+ declare function gpuMipLevelCount(width: number, height: number): number;
402
571
  /**
403
- * Produce the full mip chain from a level-0 image. The chain goes down
404
- * to a 1×1 level — the standard OpenGL / WebGPU convention — so the
405
- * caller gets `floor(log2(max(w, h))) + 1` levels total.
572
+ * Upload `source` and generate its full mip chain on the GPU. Returns an
573
+ * `rgba8unorm` texture with `gpuMipLevelCount` levels whose dimensions
574
+ * follow the standard floor-halving chain; feed it to
575
+ * `Encoder.encodeMipChainFromTexture()`. The caller owns the texture —
576
+ * destroy it after encoding.
406
577
  *
407
- * Levels whose logical dimensions are below the encoder's 4×4 block
408
- * grid are still produced here at their true logical size; padding up
409
- * to a single block is the encoder's job, not ours.
578
+ * The returned promise resolves once the work is submitted (not completed);
579
+ * queue ordering makes the levels visible to any later submission.
410
580
  */
411
- declare function generateMipChain(level0: MipLevel): MipLevel[];
412
- /**
413
- * Pad a mip level up to a multiple of 4 in each dimension using clamp-
414
- * to-edge sampling. Used before handing sub-4×4 levels to a block-
415
- * compression encoder, which requires at least one full block per level.
416
- *
417
- * If the input is already block-aligned this returns the input unchanged.
418
- */
419
- declare function padToBlockMultiple(level: MipLevel): MipLevel;
581
+ declare function generateGpuMipChain(device: GPUDevice, source: ImageBitmap | ImageData | HTMLCanvasElement | OffscreenCanvas, { flipY }?: {
582
+ flipY?: boolean;
583
+ }): Promise<GPUTexture>;
420
584
 
421
585
  /**
422
586
  * Target raster size for an SVG source. A number scales the SVG so its
@@ -441,4 +605,4 @@ interface RasterizeSvgOptions {
441
605
  */
442
606
  declare function rasterizeSvg(source: string | Blob, options?: RasterizeSvgOptions): Promise<ImageBitmap>;
443
607
 
444
- export { ASTC4x4Encoder, ASTC4x4WebGLEncoder, BC1Encoder, BC1WebGLEncoder, BC5Encoder, BC5WebGLEncoder, BC7Encoder, BC7WebGLEncoder, type Capabilities, type EncodeBytesResult, type EncodeCallOptions, 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 };
608
+ export { ASTC4x4Encoder, ASTC4x4WebGLEncoder, BC1Encoder, BC1WebGLEncoder, BC5Encoder, BC5WebGLEncoder, BC7Encoder, type BC7EncoderOptions, BC7WebGLEncoder, type Capabilities, ETC2Encoder, type EncodeBytesResult, type EncodeCallOptions, type EncodeMipChainResult, type EncodedLevelBytes, Encoder, type EncoderConstructor, type EncoderImageSource, type EncoderOptions, type ExtensionProvider, type FeatureProvider, type FormatQuality, 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, generateGpuMipChain, generateMipChain, getSharedWebGLContext, gpuMipLevelCount, isWebGLAvailable, padToBlockMultiple, rasterizeSvg, selectFormat, selectWebGLFormat };