gputex 0.7.0 → 0.9.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
@@ -49,15 +49,16 @@ when unsupported, and both apply to `hint: 'color'` only.
49
49
 
50
50
  ## WebGL fallback
51
51
 
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.
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. Each fragment shader is a line-for-line port of the WebGPU f32 shader and produces the same bytes as it (verified on Apple M3); the default WebGPU path runs the f16 variants where `shader-f16` is available, which can differ from f32 on rounding ties only, so the resulting `CompressedTexture` looks the same under either renderer. The WebGL encoders also reuse their textures across encodes and skip re-uploading an `ImageBitmap` they already hold.
53
53
 
54
54
  The fallback chain is **WebGPU → WebGL2 → uncompressed RGBA8**. The `backend` field on the result (`'webgpu' | 'webgl' | 'none'`) tells you which path ran.
55
55
 
56
56
  Notes on the WebGL path:
57
57
 
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.
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), `WEBGL_compressed_texture_s3tc` (BC1), or `WEBGL_compressed_texture_etc` (ETC2). 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 RGB8 (`WEBGL_compressed_texture_etc`) is the `quality: 'low'` pick on devices without s3tc and the final opaque-colour fallback.
59
59
  - The `device` / `adapter` options apply to the WebGPU path only.
60
- - All encoding happens on one shared, off-screen WebGL2 context; nothing is drawn to a visible canvas.
60
+ - All encoding happens on one shared, off-screen WebGL2 context; nothing is drawn to a visible canvas. `compressTexture()` keeps one compiled encoder per format on it across calls (released by `releaseSharedGpuResources()`).
61
+ - `forceWebGL: true` (or `loader.forceWebGL = true`) takes this path on WebGPU-capable browsers too — handy for testing it. In the example app, add `?forcewebgl=1` to any page to encode and render through WebGL2.
61
62
 
62
63
  ## Usage
63
64
 
@@ -103,17 +104,19 @@ algebra of that scalar shift — table and index selection depend only on each
103
104
  texel's luma-sum difference from the base, exactly (modulo decode clamping) —
104
105
  so the whole 8-table × 4-modifier search collapses to a handful of scalar
105
106
  threshold tests against a two-candidate table shortlist, with subblock error
106
- constants and the flip preselect computed O(1) from quadrant sums. A gated
107
- base-colour refit and a closed-form least-squares fit of ETC2's planar mode
108
- (which rescues the smooth gradients ETC1-style blocks band on) complete the
109
- block, all driven by the same estimates. The rewrite took the GPU pass
110
- from 6.0 ms to ~0.2 ms at 2048² (30×, within ~0.2 dB of the exhaustive
111
- search on photographic content — only the base refit was traded for
112
- speed). Its f16 module is EXACT-VALUE: lumas, D values and thresholds
113
- are integers f16 represents exactly, while the sums-of-squares estimates
114
- stay f32 (they overflow f16), so the two modules produce byte-identical
115
- output — f16 buys register pressure on mobile GPUs, not different
116
- results.
107
+ constants and the flip preselect computed O(1) from quadrant sums (exactly
108
+ gray blocks, which give the preselect nothing to go on, score both flips on
109
+ a one-channel path). A closed-form least-squares fit of ETC2's planar mode
110
+ (which rescues the smooth gradients ETC1-style blocks band on) completes
111
+ the block, driven by the same estimates. There is no base-colour refit
112
+ (~0.2 dB on photographic content for ≥13% GPU). The kernel reads the source
113
+ through `textureGather` and keeps every per-texel quantity in registers
114
+ with constant indexing (numbers below). Its f16 module is EXACT-VALUE: lumas,
115
+ D values and thresholds are integers (or halves) f16 represents exactly,
116
+ while the sums and estimates stay f32 (they overflow f16), so the two
117
+ modules produce byte-identical output wherever the sampler's unorm
118
+ conversion is exact (verified on Apple) — f16 buys register space, not
119
+ different results.
117
120
 
118
121
  On the repo's test textures this lands within a few tenths of a dB of the
119
122
  per-block CPU reference encoders (`gputex/testing`) and above them on
@@ -310,6 +313,7 @@ const tex = buildCompressedTexture([bytes], TextureFormat.BC7_SRGB)
310
313
  | `cache` | `boolean` | `false` | Session-scoped in-memory cache; repeat calls skip decode + encode (see below) |
311
314
  | `cacheKey` | `string` | derived | Explicit cache identity (skips content hashing; makes pixel sources cacheable) |
312
315
  | `device` | `GPUDevice` | — | Reuse an existing WebGPU device instead of creating one |
316
+ | `forceWebGL` | `boolean` | `false` | Skip WebGPU and encode on the WebGL2 fallback (testing); pair with `new WebGPURenderer({ forceWebGL: true })` |
313
317
 
314
318
  #### In-memory transcode cache
315
319
 
@@ -335,6 +339,34 @@ the encode and reuse the encoder's cached GPU resources. The result's
335
339
  whole chain is encoded in a single GPU submission (one compute pass, one
336
340
  readback) rather than a round trip per level.
337
341
 
342
+ #### Prewarming shaders
343
+
344
+ A cold shader cache (first visit, browser or driver update) costs ~120–160 ms
345
+ of pipeline compilation per WebGPU encoder, ~60–90 ms per WebGL2 program,
346
+ paid by the first texture that needs it. `prewarmCompressTexture()` compiles
347
+ ahead of time exactly what `compressTexture()` will use on this client for
348
+ the option sets you pass — the same capability-based selection (BC on
349
+ desktop, ASTC/ETC2 on mobile, the WebGL2 tier when WebGPU is missing or
350
+ `forceWebGL` is set), so nothing unused compiles:
351
+
352
+ ```ts
353
+ import { prewarmCompressTexture } from 'gputex'
354
+
355
+ // At app boot — pass your texture option presets as-is.
356
+ prewarmCompressTexture([{ hint: 'color', quality: 'low', mipmaps: true }, { hint: 'normal' }])
357
+ ```
358
+
359
+ It creates the shared device (or WebGL2 context) and the per-format encoders
360
+ later `compressTexture()` calls reuse, plus the mip-generation pipeline when
361
+ a target sets `mipmaps`, and resolves with the chosen backend/format per
362
+ target once everything compiled. Compilation runs off the main thread
363
+ (`createComputePipelineAsync`; `KHR_parallel_shader_compile` on WebGL2), and
364
+ it never rejects — compile errors surface on the first encode. Calls that
365
+ pass their own `device`/`adapter` build their own encoders and aren't
366
+ warmed. Independently of prewarming, every `compressTexture()` call starts
367
+ its encoder's compile before decoding the image, so compile and decode
368
+ overlap.
369
+
338
370
  ## Benchmarks
339
371
 
340
372
  Measured on an Apple Silicon GPU (`metal-3`, M3) in Chrome with the `/eval`
@@ -353,7 +385,8 @@ shader alone.
353
385
  | BC7 | f32 | 0.52 ms |
354
386
  | ASTC 4×4 | f16 (default) | **0.17 ms** |
355
387
  | ASTC 4×4 | f32 | 0.28 ms |
356
- | ETC2 | f16 + f32 | 0.20 ms |
388
+ | ETC2 | f16 (default) | **0.14 ms** |
389
+ | ETC2 | f32 | 0.15 ms |
357
390
 
358
391
  End-to-end `encodeToBytes()` wall time adds the upload and the readback.
359
392
  Each encoder caches its GPU resources (source texture, output/staging
@@ -366,13 +399,15 @@ readback this cuts wall time by 23–33% at 4096² and 5–25% at 2048²
366
399
  (bytes identical); a fresh 4096² encode is then dominated by the ~8 ms
367
400
  `copyExternalImageToTexture` upload.
368
401
 
369
- On a 100 GB/s part just reading the 2048² RGBA8 source costs ~0.15 ms, so
370
- BC5/BC7/ASTC/ETC2 sit within ~1.3× of simply touching the bytes; BC1's
371
- refit rounds keep it ALU-bound. Two faster ETC2 variants live in git
372
- history and were deliberately not shipped: a two-pass 2 B/px prepared source (encode pass
373
- 0.115 ms, but the prep pass is also bandwidth-bound and cannot overlap, so
374
- the per-texture total regressed) and an O(1) hedged table pick (−3% for
375
- −0.5 dB — a poor trade against the scored search).
402
+ On a 100 GB/s part just reading the 2048² RGBA8 source costs ~0.14 ms, so
403
+ BC5/BC7/ASTC sit within ~1.3× of simply touching the bytes and ETC2 at it;
404
+ BC1's refit rounds keep it ALU-bound. On real textures (which Apple's
405
+ lossless framebuffer compression makes cheaper to read) and at 1024², where
406
+ the source stays cached, the ETC2 kernel is ALU-exposed again: 0.035–0.04 ms
407
+ at 1024², 0.13–0.15 ms at 2048² and 0.49–0.57 ms at 4096² across the corpus.
408
+ A two-pass 2 B/px prepared-source ETC2 variant lives in git history and was
409
+ not shipped: its prep pass is also bandwidth-bound and cannot overlap, so
410
+ the per-texture total regressed.
376
411
 
377
412
  Single-dispatch timestamps are coarse and Apple GPU clock states swing
378
413
  timings by up to ~2× across page loads, so compare variants only within a
@@ -436,7 +471,7 @@ consumer can run the same validation.
436
471
  - WebGPU (primary) **or** WebGL2 (fallback) — almost every current browser has at least one
437
472
  - A compressed-texture capability for compressed output:
438
473
  - WebGPU: `texture-compression-bc` (desktop), `texture-compression-astc` (mobile), or `texture-compression-etc2` (mobile)
439
- - WebGL2: `EXT_texture_compression_bptc` / `_rgtc`, `WEBGL_compressed_texture_astc`, or `WEBGL_compressed_texture_s3tc`
474
+ - WebGL2: `EXT_texture_compression_bptc` / `_rgtc`, `WEBGL_compressed_texture_astc`, `WEBGL_compressed_texture_s3tc`, or `WEBGL_compressed_texture_etc`
440
475
  - Falls back to uncompressed RGBA8 when no compressed format is available on either backend
441
476
 
442
477
  ## Device-specific workarounds
package/dist/index.d.ts CHANGED
@@ -188,6 +188,12 @@ declare abstract class Encoder {
188
188
  private _chainBusy;
189
189
  constructor({ device, adapter, ownsDevice, disableF16 }: EncoderOptions);
190
190
  protected _buildPipeline(): void;
191
+ /**
192
+ * Resolves once the encoder's compute pipeline(s) have compiled. Encodes
193
+ * await this themselves; call it to compile ahead of first use (see
194
+ * `prewarmCompressTexture()`). Rejects with the compile error, if any.
195
+ */
196
+ ready(): Promise<void>;
191
197
  destroy(): void;
192
198
  /** Short lowercase identifier used in GPU object labels and errors. */
193
199
  abstract get label(): string;
@@ -424,6 +430,17 @@ declare abstract class WebGLBlockEncoder {
424
430
  protected _uSrc: WebGLUniformLocation | null;
425
431
  protected _uSrcSize: WebGLUniformLocation | null;
426
432
  protected _uFlipY: WebGLUniformLocation | null;
433
+ private _parallel;
434
+ private _shaders;
435
+ private _linked;
436
+ private _srcTex;
437
+ private _srcW;
438
+ private _srcH;
439
+ private _srcBitmap;
440
+ private _outTex;
441
+ private _fbo;
442
+ private _outBX;
443
+ private _outBY;
427
444
  constructor({ gl }: WebGLEncoderOptions);
428
445
  /** Short lowercase identifier for labels / errors. */
429
446
  abstract get label(): string;
@@ -434,14 +451,27 @@ declare abstract class WebGLBlockEncoder {
434
451
  /** GLSL ES 3.00 fragment-shader source. */
435
452
  abstract fragSource(): string;
436
453
  protected _buildProgram(): void;
437
- /** Release the GL program + VAO. The shared context itself is left intact. */
454
+ /** Check the link (blocking until it completes), then look up uniforms. */
455
+ private _ensureLinked;
456
+ /**
457
+ * Resolves once the fragment program has compiled and linked, without
458
+ * blocking the main thread where the driver exposes
459
+ * KHR_parallel_shader_compile (elsewhere the final status check waits for
460
+ * the driver). Encodes check this themselves; call it to compile ahead of
461
+ * first use (see `prewarmCompressTexture()`). Rejects on a compile/link
462
+ * error.
463
+ */
464
+ ready(): Promise<void>;
465
+ /** Release the GL program, VAO and cached textures. The shared context itself is left intact. */
438
466
  destroy(): void;
439
467
  /**
440
- * Upload the source image to a freshly created RGBA8 texture bound on unit 0.
441
- * Raw pixel sources (ImageData / mip levels) go through the typed-array
442
- * overload; DOM sources (ImageBitmap / canvas / image) through the element
443
- * overload. No flip / premultiply / colour conversion — flipY is applied in
444
- * the shader so each mip level flips by its own height.
468
+ * Upload the source image to the cached RGBA8 texture (recreated when the
469
+ * image size changes), bound on unit 0. Raw pixel sources (ImageData / mip
470
+ * levels) go through the typed-array overload; DOM sources (ImageBitmap /
471
+ * canvas / image) through the element overload. No flip / premultiply /
472
+ * colour conversion — flipY is applied in the shader so each mip level
473
+ * flips by its own height. Re-encoding the ImageBitmap already held by the
474
+ * texture skips the upload.
445
475
  */
446
476
  protected _uploadSource(source: WebGLEncoderImageSource, width: number, height: number): WebGLTexture;
447
477
  /**
@@ -482,6 +512,13 @@ declare class ASTC4x4WebGLEncoder extends WebGLBlockEncoder {
482
512
  fragSource(): string;
483
513
  }
484
514
 
515
+ declare class ETC2WebGLEncoder extends WebGLBlockEncoder {
516
+ get label(): string;
517
+ get bytesPerBlock(): number;
518
+ get supportsSrgb(): boolean;
519
+ fragSource(): string;
520
+ }
521
+
485
522
  /**
486
523
  * Create a fresh WebGL2 context backed by an off-screen 1×1 canvas. Returns
487
524
  * null when neither `OffscreenCanvas` nor `document` is available (e.g. a
@@ -516,6 +553,8 @@ interface WebGLCapabilities {
516
553
  s3tcSrgb: boolean;
517
554
  /** WEBGL_compressed_texture_astc → ASTC 4×4 (and other footprints). */
518
555
  astc: boolean;
556
+ /** WEBGL_compressed_texture_etc → ETC2 RGB8 (and the other ETC2/EAC formats). */
557
+ etc: boolean;
519
558
  }
520
559
  declare function detectWebGLCapabilities(gl: ExtensionProvider): WebGLCapabilities;
521
560
 
@@ -641,8 +680,8 @@ interface CompressOptions {
641
680
  * device has one — BC1 on desktop-class GPUs, ETC2 RGB8 on mobile-class
642
681
  * ones — halving GPU memory at visibly lower quality on smooth content.
643
682
  * Ignored for `hint: 'colorWithAlpha'` and `hint: 'normal'` (the 4-bpp
644
- * formats can't carry them). On the WebGL fallback tier only BC1 is
645
- * available at 'low'.
683
+ * formats can't carry them). The WebGL fallback tier makes the same
684
+ * choice (BC1 where s3tc is exposed, else ETC2 RGB8).
646
685
  */
647
686
  quality?: FormatQuality;
648
687
  /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
@@ -664,6 +703,14 @@ interface CompressOptions {
664
703
  * never destroys it. */
665
704
  device?: GPUDevice;
666
705
  adapter?: GPUAdapter;
706
+ /**
707
+ * Skip the WebGPU tier and encode on the WebGL2 fallback even when WebGPU
708
+ * is available — for testing the fallback path on WebGPU-capable
709
+ * browsers (pair it with three's `new WebGPURenderer({ forceWebGL: true })`
710
+ * to render through WebGL2 as well). `device`/`adapter` are ignored.
711
+ * Default false.
712
+ */
713
+ forceWebGL?: boolean;
667
714
  /**
668
715
  * Keep the compressed bytes in a session-scoped in-memory LRU and reuse
669
716
  * them on repeat calls, skipping BOTH the image decode and the encode —
@@ -733,13 +780,42 @@ interface CompressResult {
733
780
  cacheHit: boolean;
734
781
  }
735
782
  /**
736
- * Destroy the WebGPU device and encoders that `compressTexture()` shares
737
- * across calls (created lazily when neither the `device` nor the `adapter`
738
- * option is passed). Safe to call at any time — in-flight encodes on the
739
- * shared device will fail, and the next `compressTexture()` call recreates
740
- * everything. No-op when nothing is cached.
783
+ * Destroy the WebGPU device and the WebGPU/WebGL2 encoders that
784
+ * `compressTexture()` shares across calls (the WebGPU ones are created
785
+ * lazily when neither the `device` nor the `adapter` option is passed).
786
+ * Safe to call at any time — in-flight encodes on the shared device will
787
+ * fail, and the next `compressTexture()` call recreates everything. No-op
788
+ * when nothing is cached.
741
789
  */
742
790
  declare function releaseSharedGpuResources(): void;
791
+ /** The `compressTexture()` options that decide which encoder runs. */
792
+ type PrewarmTarget = Pick<CompressOptions, 'hint' | 'quality' | 'preferredFormat' | 'colorSpace' | 'mipmaps' | 'forceWebGL'>;
793
+ interface PrewarmResult {
794
+ /** Per target, in order: the backend and format `compressTexture()` will use for it on this client. */
795
+ targets: {
796
+ backend: 'webgpu' | 'webgl' | 'none';
797
+ format: TextureFormat | null;
798
+ }[];
799
+ /** Wall time until every selected pipeline finished compiling. */
800
+ ms: number;
801
+ }
802
+ /**
803
+ * Compile, ahead of first use, exactly the shaders `compressTexture()` will
804
+ * need on this client for the given option sets — the same capability-based
805
+ * selection (BC on desktop, ASTC/ETC2 on mobile, the WebGL2 tier when
806
+ * WebGPU is missing or `forceWebGL` is set), so nothing unused compiles.
807
+ * Creates the shared WebGPU device (or WebGL2 context) and the per-format
808
+ * encoders that later `compressTexture()` calls reuse, plus the GPU
809
+ * mip-generation pipeline when a target sets `mipmaps`. Pass your texture
810
+ * option presets as-is; unrelated keys are ignored.
811
+ *
812
+ * Call it as early as possible (app boot); textures requested before it
813
+ * settles simply wait for the same in-flight compiles. Never rejects:
814
+ * compile errors surface on the first encode that needs the pipeline. Only
815
+ * the default shared path is warmed — calls passing their own `device` or
816
+ * `adapter` build their own encoders.
817
+ */
818
+ declare function prewarmCompressTexture(targets?: PrewarmTarget | readonly PrewarmTarget[]): Promise<PrewarmResult>;
743
819
  declare function compressTextureToBytes(source: CompressTextureSource, options?: CompressOptions): Promise<CompressResult>;
744
820
 
745
821
  /**
@@ -750,4 +826,4 @@ declare function setTranscodeCacheLimit(bytes: number): void;
750
826
  /** Drop every cached transcode. Textures already built from entries are unaffected. */
751
827
  declare function clearTranscodeCache(): void;
752
828
 
753
- export { ASTC4x4Encoder, ASTC4x4WebGLEncoder, BC1Encoder, BC1WebGLEncoder, BC5Encoder, BC5WebGLEncoder, BC7Encoder, type BC7EncoderOptions, BC7WebGLEncoder, type Capabilities, type CompressOptions, type CompressResult, type CompressTextureSource, 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, clearTranscodeCache, compressTextureToBytes, createWebGLContext, detectCapabilities, detectWebGLCapabilities, generateGpuMipChain, generateMipChain, getSharedWebGLContext, gpuMipLevelCount, isWebGLAvailable, padToBlockMultiple, rasterizeSvg, releaseSharedGpuResources, selectFormat, selectWebGLFormat, setTranscodeCacheLimit };
829
+ export { ASTC4x4Encoder, ASTC4x4WebGLEncoder, BC1Encoder, BC1WebGLEncoder, BC5Encoder, BC5WebGLEncoder, BC7Encoder, type BC7EncoderOptions, BC7WebGLEncoder, type Capabilities, type CompressOptions, type CompressResult, type CompressTextureSource, ETC2Encoder, ETC2WebGLEncoder, 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 PrewarmResult, type PrewarmTarget, 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, clearTranscodeCache, compressTextureToBytes, createWebGLContext, detectCapabilities, detectWebGLCapabilities, generateGpuMipChain, generateMipChain, getSharedWebGLContext, gpuMipLevelCount, isWebGLAvailable, padToBlockMultiple, prewarmCompressTexture, rasterizeSvg, releaseSharedGpuResources, selectFormat, selectWebGLFormat, setTranscodeCacheLimit };