gputex 0.0.3

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 ADDED
@@ -0,0 +1,163 @@
1
+ # GPUtex
2
+
3
+ Runtime GPU texture compression via WebGPU compute shaders. Feed it a PNG/JPG/WebP/AVIF and get back a GPU-compressed texture (BC7, BC5, ASTC 4x4, or BC1) ready for Three.js or React Three Fiber.
4
+
5
+ ⚠️: This is 100% vibe-coded. The code is completely unreviewed, under-tested, it will probably crash on many devices, and this library is very likely to go unmaintained. Do not use for anything important.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install gputex
11
+ # or
12
+ pnpm add gputex
13
+ # or
14
+ bun add gputex
15
+ ```
16
+
17
+ `three` is a peer dependency (`>=0.170`).
18
+
19
+ ## Formats
20
+
21
+ | Format | Bytes / 4x4 block | Use case |
22
+ | ------------ | ----------------- | --------------------------------------------------------- |
23
+ | **BC7** | 16 (8 bpp) | Color / RGBA on desktop (`texture-compression-bc`) |
24
+ | **BC5** | 16 (8 bpp) | Normal maps — RG only (`texture-compression-bc`) |
25
+ | **ASTC 4x4** | 16 (8 bpp) | Color / RGBA on mobile / iOS (`texture-compression-astc`) |
26
+ | **BC1** | 8 (4 bpp) | Legacy (never auto-selected) |
27
+
28
+ Format selection is automatic: BC7/BC5 on desktop, ASTC on mobile, uncompressed RGBA8 fallback otherwise.
29
+
30
+ ## Usage
31
+
32
+ ### `compressTexture` — direct API
33
+
34
+ ```ts
35
+ import { compressTexture } from 'gputex'
36
+
37
+ const { texture, format } = await compressTexture('/cobblestone.avif', {
38
+ hint: 'color', // 'color' | 'colorWithAlpha' | 'normal'
39
+ colorSpace: 'srgb',
40
+ mipmaps: true,
41
+ })
42
+
43
+ material.map = texture
44
+ ```
45
+
46
+ ### `WebGPUCompressedTextureLoader` — Three.js Loader
47
+
48
+ ```ts
49
+ import { WebGPUCompressedTextureLoader } from 'gputex'
50
+
51
+ const loader = new WebGPUCompressedTextureLoader()
52
+ loader.hint = 'normal'
53
+ loader.mipmaps = true
54
+ const normalMap = await loader.loadAsync('/brick_normal.png')
55
+ material.normalMap = normalMap
56
+ ```
57
+
58
+ ### React Three Fiber
59
+
60
+ The `WebGPUCompressedTextureLoader` works with R3F's `useLoader`:
61
+
62
+ ```tsx
63
+ import { useLoader } from '@react-three/fiber'
64
+ import { WebGPUCompressedTextureLoader } from 'gputex'
65
+
66
+ function Scene() {
67
+ const texture = useLoader(WebGPUCompressedTextureLoader, '/cobblestone.avif', loader => {
68
+ loader.hint = 'color'
69
+ loader.colorSpace = 'srgb'
70
+ loader.mipmaps = true
71
+ })
72
+
73
+ return (
74
+ <mesh>
75
+ <sphereGeometry args={[1, 64, 32]} />
76
+ <meshStandardMaterial map={texture} />
77
+ </mesh>
78
+ )
79
+ }
80
+ ```
81
+
82
+ For a reusable hook with metadata access:
83
+
84
+ ```tsx
85
+ import { useLayoutEffect } from 'react'
86
+ import { useLoader } from '@react-three/fiber'
87
+ import { WebGPUCompressedTextureLoader } from 'gputex'
88
+ import type { TextureHint } from 'gputex'
89
+
90
+ function useGputex(url: string, options?: { hint?: TextureHint; colorSpace?: 'srgb' | 'linear'; mipmaps?: boolean }) {
91
+ const texture = useLoader(WebGPUCompressedTextureLoader, url, loader => {
92
+ if (options?.hint !== undefined) loader.hint = options.hint
93
+ if (options?.colorSpace !== undefined) loader.colorSpace = options.colorSpace
94
+ if (options?.mipmaps !== undefined) loader.mipmaps = options.mipmaps
95
+ })
96
+
97
+ return texture
98
+ }
99
+
100
+ // Preload textures outside of components
101
+ useGputex.preload = (
102
+ url: string,
103
+ options?: { hint?: TextureHint; colorSpace?: 'srgb' | 'linear'; mipmaps?: boolean },
104
+ ) => {
105
+ useLoader.preload(WebGPUCompressedTextureLoader, url, loader => {
106
+ if (options?.hint !== undefined) loader.hint = options.hint
107
+ if (options?.colorSpace !== undefined) loader.colorSpace = options.colorSpace
108
+ if (options?.mipmaps !== undefined) loader.mipmaps = options.mipmaps
109
+ })
110
+ }
111
+ ```
112
+
113
+ Usage:
114
+
115
+ ```tsx
116
+ // Preload outside the component tree
117
+ useGputex.preload('/cobblestone.avif', { hint: 'color', colorSpace: 'srgb', mipmaps: true })
118
+
119
+ function Scene() {
120
+ const texture = useGputex('/cobblestone.avif', { hint: 'color', colorSpace: 'srgb', mipmaps: true })
121
+
122
+ return (
123
+ <mesh>
124
+ <sphereGeometry args={[1, 64, 32]} />
125
+ <meshStandardMaterial map={texture} />
126
+ </mesh>
127
+ )
128
+ }
129
+ ```
130
+
131
+ ### Low-level encoders
132
+
133
+ Individual encoder classes are exported for direct control:
134
+
135
+ ```ts
136
+ import { BC7Encoder, BC5Encoder, ASTC4x4Encoder, BC1Encoder } from 'gputex'
137
+
138
+ const encoder = await BC7Encoder.create()
139
+ const { data, width, height } = await encoder.encodeToBytes(imageBitmap)
140
+ encoder.destroy()
141
+ ```
142
+
143
+ ## Options
144
+
145
+ ### `compressTexture` options
146
+
147
+ | Option | Type | Default | Description |
148
+ | ------------ | -------------------- | --------- | ------------------------------------------------------- |
149
+ | `hint` | `TextureHint` | `'color'` | `'color'`, `'colorWithAlpha'`, or `'normal'` |
150
+ | `colorSpace` | `'srgb' \| 'linear'` | `'srgb'` | Use the sRGB or linear variant of the chosen format |
151
+ | `flipY` | `boolean` | `true` | Flip vertically (matches Three.js convention) |
152
+ | `mipmaps` | `boolean` | `false` | Generate full mip chain down to 1x1 |
153
+ | `device` | `GPUDevice` | — | Reuse an existing WebGPU device instead of creating one |
154
+
155
+ ## Requirements
156
+
157
+ - A browser with WebGPU support
158
+ - `texture-compression-bc` (desktop) or `texture-compression-astc` (mobile) for compressed output
159
+ - Falls back to uncompressed RGBA8 when neither is available
160
+
161
+ ## Acknowledgements
162
+
163
+ The concept of encoding images on the GPU on the fly via compute shaders was first introduced by [spark.js](https://ludicon.com/sparkjs/), which is a much more robust solution for users who can afford its license. GPUtex is not derived from Spark and its encoders have been implemented from scratch using official references, which have been ported to TypeScript, and then converted to WGSL via AI. For any serious production use of GPU-compressed textures, Spark is the recommended choice over GPUtex.
@@ -0,0 +1,332 @@
1
+ import { CompressedPixelFormat, CompressedTexture, Texture, Loader } from 'three';
2
+
3
+ declare const TextureFormat: {
4
+ readonly BC1: "BC1";
5
+ readonly BC1_SRGB: "BC1_SRGB";
6
+ readonly BC5: "BC5";
7
+ readonly BC7: "BC7";
8
+ readonly BC7_SRGB: "BC7_SRGB";
9
+ readonly ASTC_4x4: "ASTC_4x4";
10
+ readonly ASTC_4x4_SRGB: "ASTC_4x4_SRGB";
11
+ };
12
+ type TextureFormat = (typeof TextureFormat)[keyof typeof TextureFormat];
13
+ declare const WebGPUFeature: {
14
+ readonly BC: "texture-compression-bc";
15
+ readonly ASTC: "texture-compression-astc";
16
+ readonly ETC2: "texture-compression-etc2";
17
+ };
18
+ type WebGPUFeature = (typeof WebGPUFeature)[keyof typeof WebGPUFeature];
19
+
20
+ /**
21
+ * Minimal structural type for the adapter argument: only `.features.has()`
22
+ * is touched. Lets tests pass a `{ features: new Set(...) }` stub without
23
+ * constructing a full `GPUAdapter`.
24
+ */
25
+ interface FeatureProvider {
26
+ features: {
27
+ has(name: string): boolean;
28
+ };
29
+ }
30
+ interface Capabilities {
31
+ bc: boolean;
32
+ astc: boolean;
33
+ etc2: boolean;
34
+ supportedFormats: TextureFormat[];
35
+ }
36
+ declare function detectCapabilities(adapter: FeatureProvider): Capabilities;
37
+
38
+ /**
39
+ * Anything `GPUQueue.copyExternalImageToTexture` accepts. Matches the
40
+ * WebGPU spec's CopyExternalImageSource set.
41
+ */
42
+ type EncoderImageSource = ImageBitmap | ImageData | HTMLImageElement | HTMLVideoElement | HTMLCanvasElement | OffscreenCanvas | VideoFrame;
43
+ interface EncoderOptions {
44
+ device: GPUDevice;
45
+ adapter?: GPUAdapter;
46
+ ownsDevice?: boolean;
47
+ }
48
+ interface EncodeCallOptions {
49
+ /** Tags the output color space. Forced 'linear' for encoders with supportsSrgb=false. */
50
+ colorSpace?: 'srgb' | 'linear';
51
+ }
52
+ interface EncodeResult {
53
+ width: number;
54
+ height: number;
55
+ paddedWidth: number;
56
+ paddedHeight: number;
57
+ data: Uint8Array;
58
+ texture: CompressedTexture;
59
+ encodeMs: number;
60
+ }
61
+ /**
62
+ * Result of a raw bytes-only encode (no wrapping in a `CompressedTexture`).
63
+ * Used by the mipped encode path to bundle multiple levels into a single
64
+ * `CompressedTexture` at the end.
65
+ */
66
+ interface EncodeBytesResult {
67
+ width: number;
68
+ height: number;
69
+ paddedWidth: number;
70
+ paddedHeight: number;
71
+ data: Uint8Array;
72
+ encodeMs: number;
73
+ }
74
+ interface FormatVariant {
75
+ colorSpace: 'srgb' | 'linear';
76
+ }
77
+ /**
78
+ * Constructor shape for concrete encoder subclasses; used by the polymorphic
79
+ * `Encoder.create()` so the static method's return type narrows to the
80
+ * subclass when you call e.g. `BC1Encoder.create()`.
81
+ *
82
+ * `create` is included so generic code holding an `EncoderConstructor`
83
+ * (like `compressTexture()`'s selected-format branch) can still call
84
+ * `.create()` without a widening cast.
85
+ */
86
+ type EncoderConstructor<T extends Encoder = Encoder> = {
87
+ new (opts: EncoderOptions): T;
88
+ requiredFeature: GPUFeatureName | null;
89
+ create(): Promise<T>;
90
+ };
91
+ declare abstract class Encoder {
92
+ /**
93
+ * Subclasses set this to the WebGPU feature string the output texture
94
+ * needs for sampling ('texture-compression-bc' / 'texture-compression-astc').
95
+ * `null` means no feature is required (e.g. a pure-storage debug pipeline).
96
+ */
97
+ static readonly requiredFeature: GPUFeatureName | null;
98
+ /**
99
+ * Create an encoder that owns its own WebGPU device. Requests the
100
+ * subclass's `requiredFeature` if the adapter reports it — missing the
101
+ * feature is non-fatal at encode time (the storage buffer is still
102
+ * written), it only prevents the resulting CompressedTexture from being
103
+ * sampled.
104
+ *
105
+ * The `this: EncoderConstructor<T>` annotation lets `BC1Encoder.create()`
106
+ * return `Promise<BC1Encoder>` instead of `Promise<Encoder>`.
107
+ */
108
+ static create<T extends Encoder>(this: EncoderConstructor<T>): Promise<T>;
109
+ readonly device: GPUDevice;
110
+ readonly adapter?: GPUAdapter;
111
+ readonly ownsDevice: boolean;
112
+ protected _module: GPUShaderModule;
113
+ protected _pipeline: GPUComputePipeline;
114
+ constructor({ device, adapter, ownsDevice }: EncoderOptions);
115
+ protected _buildPipeline(): void;
116
+ destroy(): void;
117
+ /** Short lowercase identifier used in GPU object labels and errors. */
118
+ abstract get label(): string;
119
+ /** 8 for BC1/BC4, 16 for BC5/BC7/ASTC 4×4. */
120
+ abstract get bytesPerBlock(): number;
121
+ /** WGSL `@workgroup_size` dimensions. Default 8×8×1. */
122
+ get workgroupSize(): readonly [number, number, number];
123
+ /** Whether this format has an sRGB variant. Default true. */
124
+ get supportsSrgb(): boolean;
125
+ /** WGSL compute-shader source. */
126
+ abstract wgslSource(): string;
127
+ /** e.g. 'bc1-rgba-unorm-srgb'. */
128
+ abstract gpuTextureFormat(opts: FormatVariant): GPUTextureFormat;
129
+ /** Three.js `CompressedPixelFormat` constant for `CompressedTexture`. */
130
+ abstract threeTextureFormat(opts: FormatVariant): CompressedPixelFormat;
131
+ /**
132
+ * True if the device reports the feature the output texture needs.
133
+ * The encoder itself only writes to a storage buffer, so this is about
134
+ * whether the result can actually be sampled.
135
+ */
136
+ get supportsSampling(): boolean;
137
+ encode(source: EncoderImageSource, { colorSpace }?: EncodeCallOptions): Promise<EncodeResult>;
138
+ /**
139
+ * Encode one image source to raw compressed bytes, skipping the
140
+ * `CompressedTexture` wrap. Used by the public `encode()` above and by
141
+ * the mipped encode path in `compressTexture()` so N mip levels end up
142
+ * in a single `CompressedTexture` instead of N throwaway wrappers.
143
+ *
144
+ * Public (not protected) because `compressTexture()` calls it across the
145
+ * encoder boundary. Still safe to call from outside — it just does
146
+ * less work than `encode()` and the caller assembles the texture.
147
+ */
148
+ encodeToBytes(source: EncoderImageSource, { flipY }?: {
149
+ flipY?: boolean;
150
+ }): Promise<EncodeBytesResult>;
151
+ /**
152
+ * Assemble a `CompressedTexture` from pre-encoded mip levels. Called
153
+ * by `compressTexture()` after it has run each level through
154
+ * `encodeToBytes()`. Centralised here so the single-level and mipped
155
+ * paths share the same format / colour-space / wrap settings.
156
+ *
157
+ * `levels[0]` is the base level; its padded dimensions become the
158
+ * texture's overall size. Filter setup assumes at least 2 levels →
159
+ * trilinear; 1 level → bilinear.
160
+ */
161
+ buildMippedTexture(levels: readonly EncodeBytesResult[], { colorSpace }?: EncodeCallOptions): CompressedTexture;
162
+ }
163
+
164
+ declare class BC1Encoder extends Encoder {
165
+ static readonly requiredFeature: GPUFeatureName;
166
+ static readonly textureFormats: readonly TextureFormat[];
167
+ get label(): string;
168
+ get bytesPerBlock(): number;
169
+ get supportsSrgb(): boolean;
170
+ wgslSource(): string;
171
+ gpuTextureFormat({ colorSpace }: FormatVariant): GPUTextureFormat;
172
+ threeTextureFormat(): CompressedPixelFormat;
173
+ }
174
+
175
+ declare class BC5Encoder extends Encoder {
176
+ static readonly requiredFeature: GPUFeatureName;
177
+ static readonly textureFormats: readonly TextureFormat[];
178
+ get label(): string;
179
+ get bytesPerBlock(): number;
180
+ get supportsSrgb(): boolean;
181
+ wgslSource(): string;
182
+ gpuTextureFormat(): GPUTextureFormat;
183
+ threeTextureFormat(): CompressedPixelFormat;
184
+ }
185
+
186
+ declare class BC7Encoder extends Encoder {
187
+ static readonly requiredFeature: GPUFeatureName;
188
+ static readonly textureFormats: readonly TextureFormat[];
189
+ get label(): string;
190
+ get bytesPerBlock(): number;
191
+ get supportsSrgb(): boolean;
192
+ wgslSource(): string;
193
+ gpuTextureFormat({ colorSpace }: FormatVariant): GPUTextureFormat;
194
+ threeTextureFormat(): CompressedPixelFormat;
195
+ }
196
+
197
+ declare class ASTC4x4Encoder extends Encoder {
198
+ static readonly requiredFeature: GPUFeatureName;
199
+ static readonly textureFormats: readonly TextureFormat[];
200
+ get label(): string;
201
+ get bytesPerBlock(): number;
202
+ get supportsSrgb(): boolean;
203
+ wgslSource(): string;
204
+ gpuTextureFormat({ colorSpace }: FormatVariant): GPUTextureFormat;
205
+ threeTextureFormat(): CompressedPixelFormat;
206
+ }
207
+
208
+ /**
209
+ * How the texture will be used in the renderer. Drives format choice.
210
+ * • 'color' — RGB albedo-like data (alpha optional / ignored).
211
+ * • 'colorWithAlpha' — 4-channel RGBA with meaningful alpha.
212
+ * • 'normal' — tangent-space normal map (R=x, G=y, z reconstructed).
213
+ */
214
+ type TextureHint = 'color' | 'colorWithAlpha' | 'normal';
215
+ interface SelectFormatOptions {
216
+ /** Pick the sRGB variant when the format has one. Default 'srgb'. */
217
+ colorSpace?: 'srgb' | 'linear';
218
+ }
219
+ interface FormatSelection {
220
+ /** null = no compressed path on this adapter; caller should fall back. */
221
+ format: TextureFormat | null;
222
+ /** null when `format` is null. */
223
+ encoderClass: EncoderConstructor | null;
224
+ /**
225
+ * True when the chosen path is ASTC *and* the intended use is 'normal'.
226
+ * ASTC has no 2-channel mode, so the caller must pre-swizzle the
227
+ * normal map and apply a matching view swizzle in the shader.
228
+ */
229
+ astcNormalRemap: boolean;
230
+ }
231
+ declare function selectFormat(adapter: FeatureProvider, hint: TextureHint, options?: SelectFormatOptions): FormatSelection;
232
+
233
+ /**
234
+ * Everything `compressTexture()` can take as an image source. A superset
235
+ * of `EncoderImageSource` (see Encoder.ts) that also accepts URL strings
236
+ * and Blob / File objects — the common cases in a web app.
237
+ */
238
+ type CompressTextureSource = string | Blob | File | ImageBitmap | HTMLImageElement | HTMLCanvasElement | OffscreenCanvas | ImageData;
239
+ interface CompressOptions {
240
+ /** How the texture will be used. Drives format selection. Default 'color'. */
241
+ hint?: TextureHint;
242
+ /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
243
+ colorSpace?: 'srgb' | 'linear';
244
+ /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
245
+ flipY?: boolean;
246
+ /** Generate a full mip chain down to 1×1 on the CPU, encode every level. */
247
+ mipmaps?: boolean;
248
+ /** Reuse an existing device (e.g. Three.js's renderer device) instead
249
+ * of creating a new one. When provided, the encoder never destroys it. */
250
+ device?: GPUDevice;
251
+ adapter?: GPUAdapter;
252
+ }
253
+ interface CompressResult {
254
+ /** CompressedTexture on the compressed path; Texture on RGBA8 fallback. */
255
+ texture: Texture | CompressedTexture;
256
+ /** The compressed format selected, or null when we fell back to RGBA8. */
257
+ format: TextureFormat | null;
258
+ /** True iff we returned an uncompressed Texture because no encoder fit. */
259
+ fallbackUncompressed: boolean;
260
+ /**
261
+ * True iff the chosen format is ASTC and the hint was 'normal'. The
262
+ * caller must apply the (R, W) → (x, y) swizzle in the material — ASTC
263
+ * has no 2-channel mode, so normal maps ride the RGBA path.
264
+ */
265
+ astcNormalRemap: boolean;
266
+ width: number;
267
+ height: number;
268
+ mipLevels: number;
269
+ /** Wall-clock time of GPU encoding, summed across mip levels. */
270
+ encodeMs: number;
271
+ /** Release the encoder's internal GPU resources. No-op if `device` was
272
+ * passed in by the caller. */
273
+ destroy(): void;
274
+ }
275
+ declare function compressTexture(source: CompressTextureSource, options?: CompressOptions): Promise<CompressResult>;
276
+
277
+ declare class WebGPUCompressedTextureLoader extends Loader<Texture> {
278
+ /** Format-selection hint. Default 'color'. */
279
+ hint: TextureHint;
280
+ /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
281
+ colorSpace: 'srgb' | 'linear';
282
+ /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
283
+ flipY: boolean;
284
+ /** Generate + encode a full mip chain. Default false. */
285
+ mipmaps: boolean;
286
+ /**
287
+ * Optional pre-existing WebGPU device. Reusing the renderer's device
288
+ * avoids spinning up a second WebGPU context for encoding.
289
+ */
290
+ device?: GPUDevice;
291
+ adapter?: GPUAdapter;
292
+ /**
293
+ * Most recent full encode result. Useful when the caller wants format
294
+ * / mipLevels / astcNormalRemap metadata without threading a separate
295
+ * callback through `load()`. Cleared when a new load starts.
296
+ */
297
+ lastResult: CompressResult | null;
298
+ /**
299
+ * THREE.Loader contract: returns void, drives callbacks. `loadAsync`
300
+ * (inherited from the base class) wraps this with Promise semantics.
301
+ * Errors routed through `manager.itemError` so the LoadingManager's
302
+ * aggregate state stays accurate.
303
+ */
304
+ load(url: string, onLoad?: (texture: Texture) => void, _onProgress?: (event: ProgressEvent) => void, onError?: (err: unknown) => void): void;
305
+ }
306
+
307
+ /** One level of a mip chain. 4 bytes per pixel (RGBA8). */
308
+ interface MipLevel {
309
+ data: Uint8ClampedArray;
310
+ width: number;
311
+ height: number;
312
+ }
313
+ /**
314
+ * Produce the full mip chain from a level-0 image. The chain goes down
315
+ * to a 1×1 level — the standard OpenGL / WebGPU convention — so the
316
+ * caller gets `floor(log2(max(w, h))) + 1` levels total.
317
+ *
318
+ * Levels whose logical dimensions are below the encoder's 4×4 block
319
+ * grid are still produced here at their true logical size; padding up
320
+ * to a single block is the encoder's job, not ours.
321
+ */
322
+ declare function generateMipChain(level0: MipLevel): MipLevel[];
323
+ /**
324
+ * Pad a mip level up to a multiple of 4 in each dimension using clamp-
325
+ * to-edge sampling. Used before handing sub-4×4 levels to a block-
326
+ * compression encoder, which requires at least one full block per level.
327
+ *
328
+ * If the input is already block-aligned this returns the input unchanged.
329
+ */
330
+ declare function padToBlockMultiple(level: MipLevel): MipLevel;
331
+
332
+ export { ASTC4x4Encoder, BC1Encoder, BC5Encoder, BC7Encoder, type Capabilities, type CompressOptions, type CompressResult, type CompressTextureSource, type EncodeBytesResult, type EncodeCallOptions, type EncodeResult, Encoder, type EncoderConstructor, type EncoderImageSource, type EncoderOptions, type FeatureProvider, type FormatSelection, type FormatVariant, type MipLevel, type SelectFormatOptions, TextureFormat, type TextureHint, WebGPUCompressedTextureLoader, WebGPUFeature, compressTexture, detectCapabilities, generateMipChain, padToBlockMultiple, selectFormat };
package/dist/index.js ADDED
@@ -0,0 +1,739 @@
1
+ // src/TextureFormat.ts
2
+ var TextureFormat = {
3
+ BC1: "BC1",
4
+ BC1_SRGB: "BC1_SRGB",
5
+ BC5: "BC5",
6
+ BC7: "BC7",
7
+ BC7_SRGB: "BC7_SRGB",
8
+ ASTC_4x4: "ASTC_4x4",
9
+ ASTC_4x4_SRGB: "ASTC_4x4_SRGB"
10
+ };
11
+ var WebGPUFeature = {
12
+ BC: "texture-compression-bc",
13
+ ASTC: "texture-compression-astc",
14
+ ETC2: "texture-compression-etc2"
15
+ };
16
+
17
+ // src/capabilities.ts
18
+ var FORMATS_BY_FEATURE = {
19
+ [WebGPUFeature.BC]: [
20
+ TextureFormat.BC1,
21
+ TextureFormat.BC1_SRGB,
22
+ TextureFormat.BC5,
23
+ TextureFormat.BC7,
24
+ TextureFormat.BC7_SRGB
25
+ ],
26
+ [WebGPUFeature.ASTC]: [TextureFormat.ASTC_4x4, TextureFormat.ASTC_4x4_SRGB],
27
+ // ETC2 has no encoder yet — explicit empty keeps exhaustiveness check.
28
+ [WebGPUFeature.ETC2]: []
29
+ };
30
+ function detectCapabilities(adapter) {
31
+ if (!adapter || !adapter.features || typeof adapter.features.has !== "function") {
32
+ throw new TypeError("detectCapabilities: adapter.features (Set-like) is required");
33
+ }
34
+ const has = (f) => adapter.features.has(f);
35
+ const bc = has(WebGPUFeature.BC);
36
+ const astc = has(WebGPUFeature.ASTC);
37
+ const etc2 = has(WebGPUFeature.ETC2);
38
+ const supportedFormats = [];
39
+ if (bc) supportedFormats.push(...FORMATS_BY_FEATURE[WebGPUFeature.BC]);
40
+ if (astc) supportedFormats.push(...FORMATS_BY_FEATURE[WebGPUFeature.ASTC]);
41
+ return { bc, astc, etc2, supportedFormats };
42
+ }
43
+
44
+ // src/Encoder.ts
45
+ import {
46
+ CompressedTexture,
47
+ LinearFilter,
48
+ LinearMipmapLinearFilter,
49
+ LinearSRGBColorSpace,
50
+ SRGBColorSpace,
51
+ RepeatWrapping
52
+ } from "three";
53
+ var Encoder = class {
54
+ /**
55
+ * Subclasses set this to the WebGPU feature string the output texture
56
+ * needs for sampling ('texture-compression-bc' / 'texture-compression-astc').
57
+ * `null` means no feature is required (e.g. a pure-storage debug pipeline).
58
+ */
59
+ static requiredFeature = null;
60
+ /**
61
+ * Create an encoder that owns its own WebGPU device. Requests the
62
+ * subclass's `requiredFeature` if the adapter reports it — missing the
63
+ * feature is non-fatal at encode time (the storage buffer is still
64
+ * written), it only prevents the resulting CompressedTexture from being
65
+ * sampled.
66
+ *
67
+ * The `this: EncoderConstructor<T>` annotation lets `BC1Encoder.create()`
68
+ * return `Promise<BC1Encoder>` instead of `Promise<Encoder>`.
69
+ */
70
+ static async create() {
71
+ if (!("gpu" in navigator)) {
72
+ throw new Error("WebGPU not available in this browser");
73
+ }
74
+ const adapter = await navigator.gpu.requestAdapter();
75
+ if (!adapter) throw new Error("No WebGPU adapter");
76
+ const requiredFeatures = [];
77
+ if (this.requiredFeature && adapter.features.has(this.requiredFeature)) {
78
+ requiredFeatures.push(this.requiredFeature);
79
+ }
80
+ const device = await adapter.requestDevice({ requiredFeatures });
81
+ return new this({ device, adapter, ownsDevice: true });
82
+ }
83
+ device;
84
+ adapter;
85
+ ownsDevice;
86
+ // `!:` because these are set in `_buildPipeline()` which the constructor
87
+ // calls; TypeScript's flow analysis doesn't see through method calls.
88
+ _module;
89
+ _pipeline;
90
+ constructor({ device, adapter, ownsDevice = false }) {
91
+ this.device = device;
92
+ this.adapter = adapter;
93
+ this.ownsDevice = ownsDevice;
94
+ this._buildPipeline();
95
+ }
96
+ _buildPipeline() {
97
+ const device = this.device;
98
+ const code = this.wgslSource();
99
+ this._module = device.createShaderModule({
100
+ label: `${this.label}-encoder`,
101
+ code
102
+ });
103
+ this._pipeline = device.createComputePipeline({
104
+ label: `${this.label}-encoder-pipeline`,
105
+ layout: "auto",
106
+ compute: { module: this._module, entryPoint: "encode" }
107
+ });
108
+ }
109
+ destroy() {
110
+ if (this.ownsDevice) this.device.destroy();
111
+ }
112
+ /** WGSL `@workgroup_size` dimensions. Default 8×8×1. */
113
+ get workgroupSize() {
114
+ return [8, 8, 1];
115
+ }
116
+ /** Whether this format has an sRGB variant. Default true. */
117
+ get supportsSrgb() {
118
+ return true;
119
+ }
120
+ /**
121
+ * True if the device reports the feature the output texture needs.
122
+ * The encoder itself only writes to a storage buffer, so this is about
123
+ * whether the result can actually be sampled.
124
+ */
125
+ get supportsSampling() {
126
+ const feat = this.constructor.requiredFeature;
127
+ return !feat || this.device.features.has(feat);
128
+ }
129
+ // ------------------------------------------------------------------ //
130
+ // Shared encode() — pad, upload, dispatch, readback, wrap. //
131
+ // ------------------------------------------------------------------ //
132
+ async encode(source, { colorSpace = "srgb" } = {}) {
133
+ const effectiveSrgb = colorSpace === "srgb" && this.supportsSrgb;
134
+ const bytes = await this.encodeToBytes(source);
135
+ const threeFormat = this.threeTextureFormat({ colorSpace: effectiveSrgb ? "srgb" : "linear" });
136
+ const mip = {
137
+ data: bytes.data,
138
+ width: bytes.paddedWidth,
139
+ height: bytes.paddedHeight
140
+ };
141
+ const texture = new CompressedTexture([mip], bytes.paddedWidth, bytes.paddedHeight, threeFormat);
142
+ texture.colorSpace = effectiveSrgb ? SRGBColorSpace : LinearSRGBColorSpace;
143
+ texture.magFilter = LinearFilter;
144
+ texture.minFilter = LinearFilter;
145
+ texture.generateMipmaps = false;
146
+ texture.wrapS = texture.wrapT = RepeatWrapping;
147
+ texture.needsUpdate = true;
148
+ texture.userData.logicalWidth = bytes.width;
149
+ texture.userData.logicalHeight = bytes.height;
150
+ return {
151
+ width: bytes.width,
152
+ height: bytes.height,
153
+ paddedWidth: bytes.paddedWidth,
154
+ paddedHeight: bytes.paddedHeight,
155
+ data: bytes.data,
156
+ texture,
157
+ encodeMs: bytes.encodeMs
158
+ };
159
+ }
160
+ /**
161
+ * Encode one image source to raw compressed bytes, skipping the
162
+ * `CompressedTexture` wrap. Used by the public `encode()` above and by
163
+ * the mipped encode path in `compressTexture()` so N mip levels end up
164
+ * in a single `CompressedTexture` instead of N throwaway wrappers.
165
+ *
166
+ * Public (not protected) because `compressTexture()` calls it across the
167
+ * encoder boundary. Still safe to call from outside — it just does
168
+ * less work than `encode()` and the caller assembles the texture.
169
+ */
170
+ async encodeToBytes(source, { flipY = false } = {}) {
171
+ const device = this.device;
172
+ const width = source.width;
173
+ const height = source.height;
174
+ if (!width || !height) {
175
+ throw new Error(`${this.label}Encoder: source has no dimensions`);
176
+ }
177
+ const paddedWidth = width + 3 & ~3;
178
+ const paddedHeight = height + 3 & ~3;
179
+ const blocksX = paddedWidth >> 2;
180
+ const blocksY = paddedHeight >> 2;
181
+ const blockCount = blocksX * blocksY;
182
+ const outByteLen = blockCount * this.bytesPerBlock;
183
+ const srcTex = device.createTexture({
184
+ label: `${this.label}-src`,
185
+ size: [paddedWidth, paddedHeight, 1],
186
+ format: "rgba8unorm",
187
+ usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
188
+ });
189
+ device.queue.copyExternalImageToTexture({ source, flipY }, { texture: srcTex }, [width, height, 1]);
190
+ const dstBuffer = device.createBuffer({
191
+ label: `${this.label}-dst`,
192
+ size: outByteLen,
193
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
194
+ });
195
+ const paramsBuffer = device.createBuffer({
196
+ label: `${this.label}-params`,
197
+ size: 16,
198
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
199
+ });
200
+ device.queue.writeBuffer(paramsBuffer, 0, new Uint32Array([blocksX, blocksY, paddedWidth, paddedHeight]));
201
+ const bindGroup = device.createBindGroup({
202
+ label: `${this.label}-bg`,
203
+ layout: this._pipeline.getBindGroupLayout(0),
204
+ entries: [
205
+ { binding: 0, resource: srcTex.createView() },
206
+ { binding: 1, resource: { buffer: dstBuffer } },
207
+ { binding: 2, resource: { buffer: paramsBuffer } }
208
+ ]
209
+ });
210
+ const [wgX, wgY] = this.workgroupSize;
211
+ const t0 = performance.now();
212
+ const enc = device.createCommandEncoder({ label: `${this.label}-encode` });
213
+ const pass = enc.beginComputePass();
214
+ pass.setPipeline(this._pipeline);
215
+ pass.setBindGroup(0, bindGroup);
216
+ pass.dispatchWorkgroups(Math.ceil(blocksX / wgX), Math.ceil(blocksY / wgY), 1);
217
+ pass.end();
218
+ const staging = device.createBuffer({
219
+ label: `${this.label}-staging`,
220
+ size: outByteLen,
221
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
222
+ });
223
+ enc.copyBufferToBuffer(dstBuffer, 0, staging, 0, outByteLen);
224
+ device.queue.submit([enc.finish()]);
225
+ await staging.mapAsync(GPUMapMode.READ);
226
+ const data = new Uint8Array(staging.getMappedRange().slice(0));
227
+ staging.unmap();
228
+ const encodeMs = performance.now() - t0;
229
+ srcTex.destroy();
230
+ dstBuffer.destroy();
231
+ staging.destroy();
232
+ paramsBuffer.destroy();
233
+ return { width, height, paddedWidth, paddedHeight, data, encodeMs };
234
+ }
235
+ /**
236
+ * Assemble a `CompressedTexture` from pre-encoded mip levels. Called
237
+ * by `compressTexture()` after it has run each level through
238
+ * `encodeToBytes()`. Centralised here so the single-level and mipped
239
+ * paths share the same format / colour-space / wrap settings.
240
+ *
241
+ * `levels[0]` is the base level; its padded dimensions become the
242
+ * texture's overall size. Filter setup assumes at least 2 levels →
243
+ * trilinear; 1 level → bilinear.
244
+ */
245
+ buildMippedTexture(levels, { colorSpace = "srgb" } = {}) {
246
+ if (levels.length === 0) {
247
+ throw new Error(`${this.label}Encoder.buildMippedTexture: no levels provided`);
248
+ }
249
+ const effectiveSrgb = colorSpace === "srgb" && this.supportsSrgb;
250
+ const threeFormat = this.threeTextureFormat({ colorSpace: effectiveSrgb ? "srgb" : "linear" });
251
+ const mipmaps = levels.map((l) => ({
252
+ data: l.data,
253
+ width: l.paddedWidth,
254
+ height: l.paddedHeight
255
+ }));
256
+ const base = levels[0];
257
+ const texture = new CompressedTexture(mipmaps, base.paddedWidth, base.paddedHeight, threeFormat);
258
+ texture.colorSpace = effectiveSrgb ? SRGBColorSpace : LinearSRGBColorSpace;
259
+ texture.magFilter = LinearFilter;
260
+ texture.minFilter = levels.length > 1 ? LinearMipmapLinearFilter : LinearFilter;
261
+ texture.generateMipmaps = false;
262
+ texture.wrapS = texture.wrapT = RepeatWrapping;
263
+ texture.needsUpdate = true;
264
+ texture.userData.logicalWidth = base.width;
265
+ texture.userData.logicalHeight = base.height;
266
+ texture.userData.mipLevels = levels.length;
267
+ return texture;
268
+ }
269
+ };
270
+
271
+ // src/BC1Encoder.ts
272
+ import { RGBA_S3TC_DXT1_Format } from "three";
273
+
274
+ // src/bc1.wgsl
275
+ var bc1_default = "// BC1 (DXT1) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into an 8-byte BC1 block\n// written as 2 x u32 into the destination storage buffer.\n//\n// BC1 block layout (little-endian):\n// u32[0]: color0 (low 16) | color1 (high 16) both in RGB565\n// u32[1]: 16 x 2-bit indices, pixel 0 = bits 0..1, pixel 15 = bits 30..31\n//\n// When color0 > color1 (numeric 16-bit), the 4-color mode is used:\n// idx 0 -> color0\n// idx 1 -> color1\n// idx 2 -> (2*color0 + color1) / 3\n// idx 3 -> ( color0 + 2*color1) / 3\n// We always force the 4-color mode here.\n//\n// Algorithm:\n// 1. Compute the bounding box (min/max RGB) of the block.\n// 2. Inset slightly to account for endpoint quantization rounding;\n// this is a well-known heuristic from rygorous/stb_dxt that\n// improves quality cheaply.\n// 3. Quantize endpoints to RGB565 and ensure color0 > color1.\n// 4. Reconstruct the 4-color palette in floating point and assign\n// the closest palette entry to each pixel (full L2 search).\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\nfn to565(c: vec3<f32>) -> u32 {\n // Round-to-nearest quantization into 5-6-5.\n let r = u32(clamp(floor(c.r * 31.0 + 0.5), 0.0, 31.0));\n let g = u32(clamp(floor(c.g * 63.0 + 0.5), 0.0, 63.0));\n let b = u32(clamp(floor(c.b * 31.0 + 0.5), 0.0, 31.0));\n return (r << 11u) | (g << 5u) | b;\n}\n\nfn from565(c: u32) -> vec3<f32> {\n let r = f32((c >> 11u) & 31u);\n let g = f32((c >> 5u) & 63u);\n let b = f32( c & 31u);\n // Expand to 8-bit then normalize, matching typical BC1 decoder behavior.\n let r8 = (r * 527.0 + 23.0) / 256.0; // = round(r * 255 / 31)\n let g8 = (g * 259.0 + 33.0) / 256.0; // = round(g * 255 / 63)\n let b8 = (b * 527.0 + 23.0) / 256.0;\n return vec3<f32>(r8, g8, b8) / 255.0;\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n var pixels: array<vec3<f32>, 16>;\n var bb_min = vec3<f32>(1.0, 1.0, 1.0);\n var bb_max = vec3<f32>(0.0, 0.0, 0.0);\n\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n // Clamp to edge for non-multiple-of-4 textures.\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = textureLoad(src_tex, p, 0).rgb;\n pixels[i] = c;\n bb_min = min(bb_min, c);\n bb_max = max(bb_max, c);\n }\n\n // Inset the bounding box. The magic constant 1/16 approximates half\n // the width of an RGB565 quantization cell; insetting by that much\n // moves the endpoints toward each other so the quantized 4-color\n // palette covers the real data range more tightly.\n let inset = (bb_max - bb_min) / 16.0;\n var hi = clamp(bb_max - inset, vec3<f32>(0.0), vec3<f32>(1.0));\n var lo = clamp(bb_min + inset, vec3<f32>(0.0), vec3<f32>(1.0));\n\n var c0 = to565(hi);\n var c1 = to565(lo);\n\n // 4-color mode requires c0 > c1. If equal (flat block), we still use\n // 4-color mode by nudging c1 down when possible; if c1 == 0 the block\n // is truly black so all indices stay 0 and the decoded value is 0.\n if (c0 == c1) {\n if (c1 > 0u) {\n c1 = c1 - 1u;\n } else {\n c0 = c0 + 1u;\n }\n } else if (c0 < c1) {\n let tmp = c0;\n c0 = c1;\n c1 = tmp;\n }\n\n // Build the palette in the decoded colour space so index selection\n // matches what the hardware decoder will produce.\n let p0 = from565(c0);\n let p1 = from565(c1);\n let p2 = (2.0 * p0 + p1) * (1.0 / 3.0);\n let p3 = (p0 + 2.0 * p1) * (1.0 / 3.0);\n\n var indices: u32 = 0u;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let c = pixels[i];\n let d0 = dot(c - p0, c - p0);\n let d1 = dot(c - p1, c - p1);\n let d2 = dot(c - p2, c - p2);\n let d3 = dot(c - p3, c - p3);\n\n var best_d: f32 = d0;\n var best_i: u32 = 0u;\n if (d1 < best_d) { best_d = d1; best_i = 1u; }\n if (d2 < best_d) { best_d = d2; best_i = 2u; }\n if (d3 < best_d) { best_d = d3; best_i = 3u; }\n\n indices = indices | (best_i << (i * 2u));\n }\n\n let out = block_index * 2u;\n dst[out] = c0 | (c1 << 16u);\n dst[out + 1u] = indices;\n}\n";
276
+
277
+ // src/BC1Encoder.ts
278
+ var BC1Encoder = class extends Encoder {
279
+ static requiredFeature = WebGPUFeature.BC;
280
+ static textureFormats = [TextureFormat.BC1, TextureFormat.BC1_SRGB];
281
+ get label() {
282
+ return "bc1";
283
+ }
284
+ get bytesPerBlock() {
285
+ return 8;
286
+ }
287
+ get supportsSrgb() {
288
+ return true;
289
+ }
290
+ wgslSource() {
291
+ return bc1_default;
292
+ }
293
+ gpuTextureFormat({ colorSpace }) {
294
+ return colorSpace === "srgb" ? "bc1-rgba-unorm-srgb" : "bc1-rgba-unorm";
295
+ }
296
+ threeTextureFormat() {
297
+ return RGBA_S3TC_DXT1_Format;
298
+ }
299
+ };
300
+
301
+ // src/BC5Encoder.ts
302
+ import { RED_GREEN_RGTC2_Format } from "three";
303
+
304
+ // src/bc5.wgsl
305
+ var bc5_default = "// BC5 (RGTC2) compute shader encoder.\n//\n// Each invocation encodes one 4x4 pixel block into a 16-byte BC5 block\n// written as 4 x u32 into the destination storage buffer.\n//\n// BC5 = two BC4 blocks concatenated:\n// block bytes 0..7 : BC4 of R channel (normal.x for tangent-space normals)\n// block bytes 8..15 : BC4 of G channel (normal.y)\n//\n// Each BC4 half-block (8 bytes):\n// byte 0 : red0 (8-bit endpoint)\n// byte 1 : red1 (8-bit endpoint)\n// bytes 2..7 : 16 \xD7 3-bit indices, LSB-first, pixel 0 at bit 0\n//\n// We always produce the 6-interpolation mode (red0 > red1). See\n// `bc4_ref.js` for the reasoning and the CPU reference this shader is\n// ported from \u2014 the algorithm and edge cases mirror it line-for-line.\n//\n// Pipeline per channel:\n// 1. Load 16 single-channel values, find min/max \u2192 initial endpoints.\n// 2. Quantize to 8-bit. Nudge apart if equal (forces 6-interp mode).\n// 3. Build palette, assign each texel its nearest entry (full L2).\n// 4. One-pass least-squares refinement: solve the 2\xD72 normal equations\n// for the (r0, r1) that minimizes \u03A3(palette[i_k] \u2212 v_k)\xB2. Accept\n// only if quantized endpoints still satisfy r0 > r1 AND total\n// squared error decreased.\n// 5. Pack 2 endpoint bytes + 48 bits of indices into the 8-byte block.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\n// 6-interpolation-mode palette weights. palette[j] = W0_6[j]*r0 + W1_6[j]*r1.\n// Expressed as a switch so we don't rely on module-scope const arrays.\nfn w0_6(j: u32) -> f32 {\n switch j {\n case 0u: { return 1.0; }\n case 1u: { return 0.0; }\n case 2u: { return 6.0 / 7.0; }\n case 3u: { return 5.0 / 7.0; }\n case 4u: { return 4.0 / 7.0; }\n case 5u: { return 3.0 / 7.0; }\n case 6u: { return 2.0 / 7.0; }\n default: { return 1.0 / 7.0; } // case 7u\n }\n}\n\nfn w1_6(j: u32) -> f32 {\n switch j {\n case 0u: { return 0.0; }\n case 1u: { return 1.0; }\n case 2u: { return 1.0 / 7.0; }\n case 3u: { return 2.0 / 7.0; }\n case 4u: { return 3.0 / 7.0; }\n case 5u: { return 4.0 / 7.0; }\n case 6u: { return 5.0 / 7.0; }\n default: { return 6.0 / 7.0; } // case 7u\n }\n}\n\nfn quantize8(v: f32) -> u32 {\n // Round-to-nearest, clamp to [0, 255]. floor(x + 0.5) is the same\n // rounding rule the CPU reference uses.\n return u32(clamp(floor(v * 255.0 + 0.5), 0.0, 255.0));\n}\n\n// Nearest-palette-index search over the 8-entry palette, returning the\n// index and squared error. `palette` is stored in function memory so we\n// pass by pointer.\nfn nearest_index(v: f32, palette: ptr<function, array<f32, 8>>) -> vec2<f32> {\n // x = best index (encoded as f32), y = best squared error.\n var best_j: u32 = 0u;\n var best_d: f32 = 1e20;\n for (var j: u32 = 0u; j < 8u; j = j + 1u) {\n let d = (*palette)[j] - v;\n let d2 = d * d;\n if (d2 < best_d) {\n best_d = d2;\n best_j = j;\n }\n }\n return vec2<f32>(f32(best_j), best_d);\n}\n\n// Encode 16 single-channel values into an 8-byte BC4 block, packed as\n// two little-endian u32s (u32[0] = bytes 0..3, u32[1] = bytes 4..7).\nfn encode_bc4(values: ptr<function, array<f32, 16>>) -> vec2<u32> {\n // ---------------- 1. Initial endpoints: bbox of input ----------------\n var vmin: f32 = 1.0;\n var vmax: f32 = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n vmin = min(vmin, (*values)[k]);\n vmax = max(vmax, (*values)[k]);\n }\n var r0: u32 = quantize8(vmax);\n var r1: u32 = quantize8(vmin);\n // Force 6-interp mode: red0 > red1 strictly.\n if (r0 == r1) {\n if (r1 > 0u) { r1 = r1 - 1u; }\n else { r0 = r0 + 1u; }\n }\n\n // ---------------- 2. Initial palette + indices + error --------------\n var palette: array<f32, 8>;\n let r0f = f32(r0) / 255.0;\n let r1f = f32(r1) / 255.0;\n for (var j: u32 = 0u; j < 8u; j = j + 1u) {\n palette[j] = w0_6(j) * r0f + w1_6(j) * r1f;\n }\n var indices: array<u32, 16>;\n var err: f32 = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let sel = nearest_index((*values)[k], &palette);\n indices[k] = u32(sel.x);\n err = err + sel.y;\n }\n\n var best_r0 = r0;\n var best_r1 = r1;\n var best_indices = indices;\n var best_err = err;\n\n // ---------------- 3. Refinement: least-squares on (r0, r1) ----------\n // Normal equations for palette[j] = a_j * r0 + b_j * r1:\n // [\u03A3AA \u03A3AB] [r0] [\u03A3AV]\n // [\u03A3AB \u03A3BB] [r1] = [\u03A3BV]\n var sAA: f32 = 0.0; var sBB: f32 = 0.0; var sAB: f32 = 0.0;\n var sAV: f32 = 0.0; var sBV: f32 = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let a = w0_6(indices[k]);\n let b = w1_6(indices[k]);\n let v = (*values)[k];\n sAA = sAA + a * a;\n sBB = sBB + b * b;\n sAB = sAB + a * b;\n sAV = sAV + a * v;\n sBV = sBV + b * v;\n }\n let det = sAA * sBB - sAB * sAB;\n // Degenerate system \u2192 skip refinement.\n if (abs(det) > 1e-9) {\n let new_r0 = clamp((sBB * sAV - sAB * sBV) / det, 0.0, 1.0);\n let new_r1 = clamp((sAA * sBV - sAB * sAV) / det, 0.0, 1.0);\n let qR0 = quantize8(new_r0);\n let qR1 = quantize8(new_r1);\n // Only accept refinements that stay in 6-interp mode. A refinement\n // that flips or equalizes the endpoints would change decode mode.\n if (qR0 > qR1) {\n var pal2: array<f32, 8>;\n let r0f2 = f32(qR0) / 255.0;\n let r1f2 = f32(qR1) / 255.0;\n for (var j: u32 = 0u; j < 8u; j = j + 1u) {\n pal2[j] = w0_6(j) * r0f2 + w1_6(j) * r1f2;\n }\n var idx2: array<u32, 16>;\n var err2: f32 = 0.0;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let sel = nearest_index((*values)[k], &pal2);\n idx2[k] = u32(sel.x);\n err2 = err2 + sel.y;\n }\n if (err2 < best_err) {\n best_r0 = qR0;\n best_r1 = qR1;\n best_indices = idx2;\n best_err = err2;\n }\n }\n }\n\n // ---------------- 4. Pack 48-bit index field + 2 endpoint bytes -----\n // The 48-bit index field spans block bytes 2..7. Split into idx_lo\n // (low 32 bits of the field) and idx_hi (high 16 bits). An index at\n // bit position 3k straddles the 32-bit boundary iff 3k < 32 < 3k+3\n // (only k = 10, 11 straddle: bits 30..32 and 33..35; actually k=10\n // is bits 30..32, k=11 is 33..35 \u2014 so k=10 straddles). We handle\n // straddles by writing to both halves.\n var idx_lo: u32 = 0u;\n var idx_hi: u32 = 0u;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let bit = 3u * k;\n let v = best_indices[k] & 7u;\n if (bit + 3u <= 32u) {\n idx_lo = idx_lo | (v << bit);\n } else if (bit >= 32u) {\n idx_hi = idx_hi | (v << (bit - 32u));\n } else {\n // Straddle: low part into idx_lo's top, high part into idx_hi's bottom.\n idx_lo = idx_lo | (v << bit);\n idx_hi = idx_hi | (v >> (32u - bit));\n }\n }\n\n // Final u32s, both little-endian:\n // u32[0] bytes = red0, red1, idx_lo[7:0], idx_lo[15:8]\n // u32[1] bytes = idx_lo[23:16], idx_lo[31:24], idx_hi[7:0], idx_hi[15:8]\n let out_lo = best_r0 | (best_r1 << 8u) | ((idx_lo & 0xFFFFu) << 16u);\n let out_hi = (idx_lo >> 16u) | (idx_hi << 16u);\n\n return vec2<u32>(out_lo, out_hi);\n}\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // Load 4\xD74 RG values, splitting into per-channel arrays so each can\n // be handed to encode_bc4 independently.\n var r_values: array<f32, 16>;\n var g_values: array<f32, 16>;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n // Clamp to edge for non-multiple-of-4 input sizes.\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = textureLoad(src_tex, p, 0);\n r_values[i] = c.r;\n g_values[i] = c.g;\n }\n\n let r_block = encode_bc4(&r_values);\n let g_block = encode_bc4(&g_values);\n\n // BC5 block = R half (bytes 0..7) || G half (bytes 8..15) = 4 u32s.\n let out = block_index * 4u;\n dst[out + 0u] = r_block.x;\n dst[out + 1u] = r_block.y;\n dst[out + 2u] = g_block.x;\n dst[out + 3u] = g_block.y;\n}\n";
306
+
307
+ // src/BC5Encoder.ts
308
+ var BC5Encoder = class extends Encoder {
309
+ static requiredFeature = WebGPUFeature.BC;
310
+ static textureFormats = [TextureFormat.BC5];
311
+ get label() {
312
+ return "bc5";
313
+ }
314
+ get bytesPerBlock() {
315
+ return 16;
316
+ }
317
+ get supportsSrgb() {
318
+ return false;
319
+ }
320
+ wgslSource() {
321
+ return bc5_default;
322
+ }
323
+ gpuTextureFormat() {
324
+ return "bc5-rg-unorm";
325
+ }
326
+ threeTextureFormat() {
327
+ return RED_GREEN_RGTC2_Format;
328
+ }
329
+ };
330
+
331
+ // src/BC7Encoder.ts
332
+ import { RGBA_BPTC_Format } from "three";
333
+
334
+ // src/bc7.wgsl
335
+ var bc7_default = "// BC7 (BPTC) mode 6 compute shader encoder.\n//\n// One invocation per 4\xD74 block. Emits 16 bytes = 4 u32s into the storage\n// buffer at `dst[block_index * 4 .. + 3]`.\n//\n// This shader mirrors `bc7_ref.ts` function-by-function; see that file for\n// the end-to-end algorithm rationale and the full mode 6 bitstream layout\n// (summarised below).\n//\n// MODE 6 LAYOUT (LSB-first, bit 0 = byte 0's bit 0)\n// bits 0..6 mode field (0b0000001 \u2014 only bit 6 is 1)\n// bits 7..13 R0 (7-bit)\n// bits 14..20 R1\n// bits 21..27 G0\n// bits 28..34 G1 \u2190 straddles the word 0 / word 1 boundary\n// bits 35..41 B0\n// bits 42..48 B1\n// bits 49..55 A0\n// bits 56..62 A1\n// bit 63 P0 (shared p-bit for endpoint 0)\n// bit 64 P1\n// bits 65..67 pixel 0 index (3 bits; anchor, MSB implicit 0)\n// bits 68..71 pixel 1 index (4 bits)\n// ...\n// bits 124..127 pixel 15 index\n//\n// Effective 8-bit endpoint channel = (7_bit_value << 1) | p_bit.\n// Palette[i] = ((64 \u2212 W4[i]) \xD7 e0_8 + W4[i] \xD7 e1_8 + 32) >> 6, integer.\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\n// Mode 6 interpolation weights (\xD7 1/64), fixed by the spec. Same table as\n// the CPU reference (`W4` in bc7_ref.ts).\nfn w4(i: u32) -> u32 {\n switch i {\n case 0u: { return 0u; }\n case 1u: { return 4u; }\n case 2u: { return 9u; }\n case 3u: { return 13u; }\n case 4u: { return 17u; }\n case 5u: { return 21u; }\n case 6u: { return 26u; }\n case 7u: { return 30u; }\n case 8u: { return 34u; }\n case 9u: { return 38u; }\n case 10u: { return 43u; }\n case 11u: { return 47u; }\n case 12u: { return 51u; }\n case 13u: { return 55u; }\n case 14u: { return 60u; }\n default: { return 64u; } // case 15u\n }\n}\n\n// Hardware-exact integer interpolation.\nfn interp8(e0: u32, e1: u32, w: u32) -> u32 {\n return ((64u - w) * e0 + w * e1 + 32u) >> 6u;\n}\n\n// f32-normalised [0,1] \u2192 clamped 8-bit.\nfn to8(v: f32) -> u32 {\n return u32(clamp(floor(v * 255.0 + 0.5), 0.0, 255.0));\n}\n\n// -------------------------- Farthest-pair seed -------------------------- //\n\nstruct PairResult { i0: u32, i1: u32 };\n\n// 4-channel L2 distance squared, u32 domain (bounded by 4 \xD7 255\xB2 = 260 100).\nfn pixel_dist_sq(a: vec4<u32>, b: vec4<u32>) -> u32 {\n let d = vec4<i32>(a) - vec4<i32>(b);\n let d2 = d * d;\n return u32(d2.x + d2.y + d2.z + d2.w);\n}\n\n// O(N\xB2) = 120 comparisons. See bc7_ref.ts `farthestPair` for why bbox\n// corners aren't safe initial endpoints when channels vary in different\n// directions along the data line.\nfn farthest_pair(pixels: ptr<function, array<vec4<u32>, 16>>) -> PairResult {\n var best_d: u32 = 0u;\n var best_i: u32 = 0u;\n var best_j: u32 = 1u;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n for (var j: u32 = i + 1u; j < 16u; j = j + 1u) {\n let d = pixel_dist_sq((*pixels)[i], (*pixels)[j]);\n if (d > best_d) { best_d = d; best_i = i; best_j = j; }\n }\n }\n return PairResult(best_i, best_j);\n}\n\n// -------------------------- Palette + assignment ------------------------ //\n\n// Build the 16-entry RGBA palette from 8-bit endpoints.\nfn build_palette_6(e0: vec4<u32>, e1: vec4<u32>, pal: ptr<function, array<vec4<u32>, 16>>) {\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let w = w4(i);\n (*pal)[i] = vec4<u32>(\n interp8(e0.x, e1.x, w),\n interp8(e0.y, e1.y, w),\n interp8(e0.z, e1.z, w),\n interp8(e0.w, e1.w, w),\n );\n }\n}\n\n// Nearest-palette-entry search for one pixel. Full 16-entry L2 search.\nfn nearest_index_6(pixel: vec4<u32>, pal: ptr<function, array<vec4<u32>, 16>>) -> vec2<u32> {\n var best_i: u32 = 0u;\n var best_d: u32 = 0xFFFFFFFFu;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let d = pixel_dist_sq(pixel, (*pal)[i]);\n if (d < best_d) { best_d = d; best_i = i; }\n }\n // x = best index, y = its squared error.\n return vec2<u32>(best_i, best_d);\n}\n\n// Assign all 16 pixels to nearest palette entries, accumulate total error.\nstruct AssignResult { indices: array<u32, 16>, err: u32 };\n\nfn assign_all(\n pixels: ptr<function, array<vec4<u32>, 16>>,\n pal: ptr<function, array<vec4<u32>, 16>>,\n) -> AssignResult {\n var out: AssignResult;\n out.err = 0u;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let sel = nearest_index_6((*pixels)[k], pal);\n out.indices[k] = sel.x;\n out.err = out.err + sel.y;\n }\n return out;\n}\n\n// -------------------------- Endpoint quantisation ----------------------- //\n\n// Quantize one 8-bit ideal channel to (7-bit value, reconstructed 8-bit)\n// under a fixed p-bit. Matches the CPU reference.\nfn quantize_ch(ideal8: u32, p: u32) -> vec2<u32> {\n // q7 = round((ideal8 \u2212 p) / 2), clamp to [0, 127].\n let q = u32(clamp(\n floor((f32(ideal8) - f32(p)) / 2.0 + 0.5),\n 0.0, 127.0,\n ));\n let eff = (q << 1u) | p;\n return vec2<u32>(q, eff);\n}\n\nstruct QuantPair { seven: vec4<u32>, eight: vec4<u32> };\n\nfn quantize_endpoint(ideal8: vec4<u32>, p: u32) -> QuantPair {\n let r = quantize_ch(ideal8.x, p);\n let g = quantize_ch(ideal8.y, p);\n let b = quantize_ch(ideal8.z, p);\n let a = quantize_ch(ideal8.w, p);\n return QuantPair(\n vec4<u32>(r.x, g.x, b.x, a.x),\n vec4<u32>(r.y, g.y, b.y, a.y),\n );\n}\n\n// Try all four p-bit combos (p0, p1) \u2208 {0,1}\xB2 and return the best\n// quantised-endpoint-plus-indices triple.\nstruct BestMode6 {\n e0_7: vec4<u32>, e1_7: vec4<u32>,\n p0: u32, p1: u32,\n indices: array<u32, 16>,\n err: u32,\n};\n\nfn try_pbit_combos(\n pixels: ptr<function, array<vec4<u32>, 16>>,\n ideal0: vec4<u32>,\n ideal1: vec4<u32>,\n) -> BestMode6 {\n var best: BestMode6;\n best.err = 0xFFFFFFFFu;\n for (var p0: u32 = 0u; p0 < 2u; p0 = p0 + 1u) {\n let q0 = quantize_endpoint(ideal0, p0);\n for (var p1: u32 = 0u; p1 < 2u; p1 = p1 + 1u) {\n let q1 = quantize_endpoint(ideal1, p1);\n var pal: array<vec4<u32>, 16>;\n build_palette_6(q0.eight, q1.eight, &pal);\n let assigned = assign_all(pixels, &pal);\n if (assigned.err < best.err) {\n best.e0_7 = q0.seven;\n best.e1_7 = q1.seven;\n best.p0 = p0;\n best.p1 = p1;\n best.indices = assigned.indices;\n best.err = assigned.err;\n }\n }\n }\n return best;\n}\n\n// ---------------------- Least-squares endpoint refit -------------------- //\n\n// Channel-independent LSQ fit of (e0, e1) given current indices. Normal\n// equations: see bc7_ref.ts `refitEndpointsMode6`. Returns 8-bit ideal\n// endpoints (before p-bit quantisation). `valid` = false for a degenerate\n// system (all texels on one palette entry).\nstruct RefitResult { e0: vec4<u32>, e1: vec4<u32>, valid: bool };\n\nfn refit_endpoints(\n pixels: ptr<function, array<vec4<u32>, 16>>,\n indices: ptr<function, array<u32, 16>>,\n) -> RefitResult {\n var sAA: f32 = 0.0;\n var sBB: f32 = 0.0;\n var sAB: f32 = 0.0;\n var sAV: vec4<f32> = vec4<f32>(0.0);\n var sBV: vec4<f32> = vec4<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let i = (*indices)[k];\n let a = f32(64u - w4(i)) / 64.0;\n let b = f32(w4(i)) / 64.0;\n let v = vec4<f32>((*pixels)[k]);\n sAA = sAA + a * a;\n sBB = sBB + b * b;\n sAB = sAB + a * b;\n sAV = sAV + a * v;\n sBV = sBV + b * v;\n }\n let det = sAA * sBB - sAB * sAB;\n var out: RefitResult;\n if (abs(det) < 1e-9) {\n out.valid = false;\n return out;\n }\n let e0f = (sBB * sAV - sAB * sBV) / det;\n let e1f = (sAA * sBV - sAB * sAV) / det;\n out.e0 = vec4<u32>(clamp(round(e0f), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.e1 = vec4<u32>(clamp(round(e1f), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.valid = true;\n return out;\n}\n\n// -------------------------- Bit-packing helper -------------------------- //\n\n// Write `n_bits` LSBs of `value` at bit position `pos` in a 128-bit field\n// split across 4 u32s. Straddles the word boundary when necessary.\nfn write_bits(block: ptr<function, array<u32, 4>>, pos: u32, n_bits: u32, value: u32) {\n let v = value & ((1u << n_bits) - 1u);\n let word_lo = pos / 32u;\n let bit_lo = pos % 32u;\n let bits_in_lo = min(n_bits, 32u - bit_lo);\n\n let mask_lo = ((1u << bits_in_lo) - 1u) << bit_lo;\n (*block)[word_lo] = ((*block)[word_lo] & ~mask_lo) | ((v << bit_lo) & mask_lo);\n\n if (bits_in_lo < n_bits) {\n let bits_in_hi = n_bits - bits_in_lo;\n let mask_hi = (1u << bits_in_hi) - 1u;\n let val_hi = v >> bits_in_lo;\n (*block)[word_lo + 1u] = ((*block)[word_lo + 1u] & ~mask_hi) | (val_hi & mask_hi);\n }\n}\n\n// ------------------------------- Entry --------------------------------- //\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // 1. Load 16 RGBA pixels in 8-bit integer domain.\n var pixels: array<vec4<u32>, 16>;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = textureLoad(src_tex, p, 0);\n pixels[i] = vec4<u32>(to8(c.r), to8(c.g), to8(c.b), to8(c.a));\n }\n\n // 2. Farthest-pair \u2192 initial endpoints.\n let fp = farthest_pair(&pixels);\n let ideal0_init = pixels[fp.i0];\n let ideal1_init = pixels[fp.i1];\n\n // 3. First p-bit search over the farthest-pair seed.\n var best = try_pbit_combos(&pixels, ideal0_init, ideal1_init);\n\n // 4. One-pass LSQ refit + second p-bit search; accept if error decreases.\n let refit = refit_endpoints(&pixels, &best.indices);\n if (refit.valid) {\n let cand = try_pbit_combos(&pixels, refit.e0, refit.e1);\n if (cand.err < best.err) {\n best = cand;\n }\n }\n\n // 5. Anchor rule \u2014 pixel 0's index MSB must be 0. If not, swap endpoints\n // and reflect every index (new_i = 15 \u2212 old_i). The decoded palette\n // reverses, so the reconstructed image is unchanged.\n if ((best.indices[0] & 0x8u) != 0u) {\n let tmp7 = best.e0_7; best.e0_7 = best.e1_7; best.e1_7 = tmp7;\n let tmpP = best.p0; best.p0 = best.p1; best.p1 = tmpP;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n best.indices[k] = 15u - best.indices[k];\n }\n }\n\n // 6. Pack into 128 bits = 4 u32s.\n var block: array<u32, 4>;\n block[0] = 0u; block[1] = 0u; block[2] = 0u; block[3] = 0u;\n\n var pos: u32 = 0u;\n // Mode 6: six zero bits followed by a 1 (LSB-first).\n write_bits(&block, pos, 7u, 0x40u); pos = pos + 7u;\n // Endpoints: R0, R1, G0, G1, B0, B1, A0, A1 \u2014 7 bits each.\n write_bits(&block, pos, 7u, best.e0_7.x); pos = pos + 7u;\n write_bits(&block, pos, 7u, best.e1_7.x); pos = pos + 7u;\n write_bits(&block, pos, 7u, best.e0_7.y); pos = pos + 7u;\n write_bits(&block, pos, 7u, best.e1_7.y); pos = pos + 7u;\n write_bits(&block, pos, 7u, best.e0_7.z); pos = pos + 7u;\n write_bits(&block, pos, 7u, best.e1_7.z); pos = pos + 7u;\n write_bits(&block, pos, 7u, best.e0_7.w); pos = pos + 7u;\n write_bits(&block, pos, 7u, best.e1_7.w); pos = pos + 7u;\n // P-bits.\n write_bits(&block, pos, 1u, best.p0); pos = pos + 1u;\n write_bits(&block, pos, 1u, best.p1); pos = pos + 1u;\n // Pixel 0: 3-bit anchor (MSB implicit 0).\n write_bits(&block, pos, 3u, best.indices[0] & 0x7u); pos = pos + 3u;\n // Pixels 1..15: 4 bits each.\n for (var k: u32 = 1u; k < 16u; k = k + 1u) {\n write_bits(&block, pos, 4u, best.indices[k] & 0xFu);\n pos = pos + 4u;\n }\n\n // 7. Store.\n let out = block_index * 4u;\n dst[out + 0u] = block[0];\n dst[out + 1u] = block[1];\n dst[out + 2u] = block[2];\n dst[out + 3u] = block[3];\n}\n";
336
+
337
+ // src/BC7Encoder.ts
338
+ var BC7Encoder = class extends Encoder {
339
+ static requiredFeature = WebGPUFeature.BC;
340
+ static textureFormats = [TextureFormat.BC7, TextureFormat.BC7_SRGB];
341
+ get label() {
342
+ return "bc7";
343
+ }
344
+ get bytesPerBlock() {
345
+ return 16;
346
+ }
347
+ get supportsSrgb() {
348
+ return true;
349
+ }
350
+ wgslSource() {
351
+ return bc7_default;
352
+ }
353
+ gpuTextureFormat({ colorSpace }) {
354
+ return colorSpace === "srgb" ? "bc7-rgba-unorm-srgb" : "bc7-rgba-unorm";
355
+ }
356
+ threeTextureFormat() {
357
+ return RGBA_BPTC_Format;
358
+ }
359
+ };
360
+
361
+ // src/ASTC4x4Encoder.ts
362
+ import { RGBA_ASTC_4x4_Format } from "three";
363
+
364
+ // src/astc4x4.wgsl
365
+ var astc4x4_default = "// ASTC 4\xD74 LDR compute shader encoder.\n//\n// One invocation per 4\xD74 block. Emits 16 bytes = 4 u32s into the storage\n// buffer at `dst[block_index * 4 .. + 3]`.\n//\n// This shader mirrors `astc4x4_ref.ts` function-by-function; see that\n// file for the end-to-end algorithm rationale and the full block layout\n// / block-mode derivation. A short recap follows.\n//\n// RESTRICTED SUBSET (both CPU ref and this shader):\n// \u2022 Single partition, no dual-plane\n// \u2022 CEM 12 (LDR RGBA, direct)\n// \u2022 Weight grid 4\xD74 (no upsampling), 2-bit weights (QUANT_4)\n// \u2022 8-bit endpoints (QUANT_256 \u2014 bit-replication is a no-op)\n//\n// BLOCK LAYOUT (128 bits, LSB-first)\n// bits [10:0] block mode = 0x042\n// bits [12:11] partition count \u2212 1 = 0\n// bits [16:13] CEM = 12\n// bits [80:17] endpoints: R0 R1 G0 G1 B0 B1 A0 A1 (8-bit each)\n// bits [95:81] unused (zero padding)\n// bits [127:96] 16 \xD7 2-bit weights; for weight k \u2208 [0,15]:\n// block_bit(127 \u2212 2k) = weight_k[0] (LSB)\n// block_bit(126 \u2212 2k) = weight_k[1] (MSB)\n//\n// ENDPOINT ORDERING: after fitting, if sum(e0.rgb) > sum(e1.rgb) we swap\n// endpoints and reflect indices (w' = 3 \u2212 w). This keeps the decoder out\n// of the blue-contraction branch (see CPU ref file header for the full\n// decoder behaviour).\n\nstruct Params {\n blocks_x: u32,\n blocks_y: u32,\n width: u32,\n height: u32,\n};\n\n@group(0) @binding(0) var src_tex: texture_2d<f32>;\n@group(0) @binding(1) var<storage, read_write> dst: array<u32>;\n@group(0) @binding(2) var<uniform> params: Params;\n\n// QUANT_4 weight unquantisation: q \u2208 [0,3] \u2192 unq \u2208 [0, 21, 43, 64].\n// Switch keeps us off a module-scope const array (some backends reject\n// those inside function-call bodies).\nfn weight_unq(i: u32) -> u32 {\n switch i {\n case 0u: { return 0u; }\n case 1u: { return 21u; }\n case 2u: { return 43u; }\n default: { return 64u; } // case 3u\n }\n}\n\n// Hardware-exact integer interpolation. Identical to BC7's; matches the\n// CPU reference bit-for-bit.\nfn interp8(e0: u32, e1: u32, w: u32) -> u32 {\n return ((64u - w) * e0 + w * e1 + 32u) >> 6u;\n}\n\n// Normalised [0, 1] \u2192 clamped 8-bit. Same rounding rule (floor(v + 0.5))\n// as the CPU reference's Math.round.\nfn to8(v: f32) -> u32 {\n return u32(clamp(floor(v * 255.0 + 0.5), 0.0, 255.0));\n}\n\n// 4-channel L2 distance squared in u32 domain. Bounded by 4 \xB7 255\xB2 = 260,100.\nfn pixel_dist_sq(a: vec4<u32>, b: vec4<u32>) -> u32 {\n let d = vec4<i32>(a) - vec4<i32>(b);\n let d2 = d * d;\n return u32(d2.x + d2.y + d2.z + d2.w);\n}\n\n// -------------------------- Farthest-pair seed -------------------------- //\n\nstruct PairResult { i0: u32, i1: u32 };\n\n// O(N\xB2) = 120 comparisons. Same rationale as BC7's `farthest_pair`:\n// bounding-box corners aren't safe initial endpoints when channels vary\n// in different directions along the data line.\nfn farthest_pair(pixels: ptr<function, array<vec4<u32>, 16>>) -> PairResult {\n var best_d: u32 = 0u;\n var best_i: u32 = 0u;\n var best_j: u32 = 1u;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n for (var j: u32 = i + 1u; j < 16u; j = j + 1u) {\n let d = pixel_dist_sq((*pixels)[i], (*pixels)[j]);\n if (d > best_d) { best_d = d; best_i = i; best_j = j; }\n }\n }\n return PairResult(best_i, best_j);\n}\n\n// -------------------------- Palette + assignment ------------------------ //\n\n// Build the 4-entry RGBA palette from 8-bit endpoints. Uses the same\n// integer interpolation formula as decode, so assignments made against\n// this palette match the hardware round-trip.\nfn build_palette(\n e0: vec4<u32>, e1: vec4<u32>,\n pal: ptr<function, array<vec4<u32>, 4>>,\n) {\n for (var i: u32 = 0u; i < 4u; i = i + 1u) {\n let w = weight_unq(i);\n (*pal)[i] = vec4<u32>(\n interp8(e0.x, e1.x, w),\n interp8(e0.y, e1.y, w),\n interp8(e0.z, e1.z, w),\n interp8(e0.w, e1.w, w),\n );\n }\n}\n\n// Nearest palette entry for a single RGBA pixel. Full 4-way L2 search.\n// Returns (best_index, its squared error).\nfn nearest_index(pixel: vec4<u32>, pal: ptr<function, array<vec4<u32>, 4>>) -> vec2<u32> {\n var best_i: u32 = 0u;\n var best_d: u32 = 0xFFFFFFFFu;\n for (var i: u32 = 0u; i < 4u; i = i + 1u) {\n let d = pixel_dist_sq(pixel, (*pal)[i]);\n if (d < best_d) { best_d = d; best_i = i; }\n }\n return vec2<u32>(best_i, best_d);\n}\n\n// Assign all 16 texels to nearest palette entries; accumulate squared error.\nstruct AssignResult { indices: array<u32, 16>, err: u32 };\n\nfn assign_all(\n pixels: ptr<function, array<vec4<u32>, 16>>,\n pal: ptr<function, array<vec4<u32>, 4>>,\n) -> AssignResult {\n var out: AssignResult;\n out.err = 0u;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let sel = nearest_index((*pixels)[k], pal);\n out.indices[k] = sel.x;\n out.err = out.err + sel.y;\n }\n return out;\n}\n\n// ---------------------- Least-squares endpoint refit -------------------- //\n\n// Given current indices, solve the per-channel 2\xD72 normal equations for\n// (e0, e1). See the CPU reference's `refitEndpoints` for the derivation.\n// `valid = false` signals a degenerate system (all texels on one palette\n// entry) and the caller keeps the farthest-pair seed.\nstruct RefitResult { e0: vec4<u32>, e1: vec4<u32>, valid: bool };\n\nfn refit_endpoints(\n pixels: ptr<function, array<vec4<u32>, 16>>,\n indices: ptr<function, array<u32, 16>>,\n) -> RefitResult {\n var sAA: f32 = 0.0;\n var sBB: f32 = 0.0;\n var sAB: f32 = 0.0;\n var sAV: vec4<f32> = vec4<f32>(0.0);\n var sBV: vec4<f32> = vec4<f32>(0.0);\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let unq = weight_unq((*indices)[k]);\n let a = f32(64u - unq) / 64.0;\n let b = f32(unq) / 64.0;\n let v = vec4<f32>((*pixels)[k]);\n sAA = sAA + a * a;\n sBB = sBB + b * b;\n sAB = sAB + a * b;\n sAV = sAV + a * v;\n sBV = sBV + b * v;\n }\n let det = sAA * sBB - sAB * sAB;\n var out: RefitResult;\n if (abs(det) < 1e-9) {\n out.valid = false;\n return out;\n }\n let e0f = (sBB * sAV - sAB * sBV) / det;\n let e1f = (sAA * sBV - sAB * sAV) / det;\n out.e0 = vec4<u32>(clamp(round(e0f), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.e1 = vec4<u32>(clamp(round(e1f), vec4<f32>(0.0), vec4<f32>(255.0)));\n out.valid = true;\n return out;\n}\n\n// -------------------------- Bit-packing helper -------------------------- //\n\n// Write `n_bits` LSBs of `value` at bit position `pos` of a 128-bit field\n// represented as `array<u32, 4>`. Handles word-boundary straddles.\n// Lifted from the BC7 shader verbatim; the layout contract is identical.\nfn write_bits(block: ptr<function, array<u32, 4>>, pos: u32, n_bits: u32, value: u32) {\n let v = value & ((1u << n_bits) - 1u);\n let word_lo = pos / 32u;\n let bit_lo = pos % 32u;\n let bits_in_lo = min(n_bits, 32u - bit_lo);\n\n let mask_lo = ((1u << bits_in_lo) - 1u) << bit_lo;\n (*block)[word_lo] = ((*block)[word_lo] & ~mask_lo) | ((v << bit_lo) & mask_lo);\n\n if (bits_in_lo < n_bits) {\n let bits_in_hi = n_bits - bits_in_lo;\n let mask_hi = (1u << bits_in_hi) - 1u;\n let val_hi = v >> bits_in_lo;\n (*block)[word_lo + 1u] = ((*block)[word_lo + 1u] & ~mask_hi) | (val_hi & mask_hi);\n }\n}\n\n// ------------------------------- Entry ---------------------------------- //\n\n@compute @workgroup_size(8, 8, 1)\nfn encode(@builtin(global_invocation_id) gid: vec3<u32>) {\n if (gid.x >= params.blocks_x || gid.y >= params.blocks_y) {\n return;\n }\n\n let bx = gid.x;\n let by = gid.y;\n let block_index = by * params.blocks_x + bx;\n\n let base = vec2<i32>(i32(bx) * 4, i32(by) * 4);\n let max_xy = vec2<i32>(i32(params.width) - 1, i32(params.height) - 1);\n\n // 1. Load 16 RGBA texels in 8-bit integer domain.\n var pixels: array<vec4<u32>, 16>;\n for (var i: u32 = 0u; i < 16u; i = i + 1u) {\n let lx = i32(i & 3u);\n let ly = i32(i >> 2u);\n // Clamp to edge for non-multiple-of-4 input sizes.\n let p = clamp(base + vec2<i32>(lx, ly), vec2<i32>(0, 0), max_xy);\n let c = textureLoad(src_tex, p, 0);\n pixels[i] = vec4<u32>(to8(c.r), to8(c.g), to8(c.b), to8(c.a));\n }\n\n // 2. Farthest-pair seed \u2192 initial endpoints.\n let fp = farthest_pair(&pixels);\n var e0 = pixels[fp.i0];\n var e1 = pixels[fp.i1];\n\n // 3. Initial assignment against the seed endpoints.\n var pal: array<vec4<u32>, 4>;\n build_palette(e0, e1, &pal);\n var best = assign_all(&pixels, &pal);\n\n // 4. One LSQ refit pass. Accept only if the squared error strictly\n // decreases \u2014 matches the CPU reference.\n let refit = refit_endpoints(&pixels, &best.indices);\n if (refit.valid) {\n var pal2: array<vec4<u32>, 4>;\n build_palette(refit.e0, refit.e1, &pal2);\n let cand = assign_all(&pixels, &pal2);\n if (cand.err < best.err) {\n e0 = refit.e0;\n e1 = refit.e1;\n best = cand;\n }\n }\n\n // 5. Endpoint ordering so the decoder doesn't apply blue contraction.\n // Strict '>' avoids a gratuitous swap on ties.\n let s0 = e0.x + e0.y + e0.z;\n let s1 = e1.x + e1.y + e1.z;\n if (s0 > s1) {\n let tmp = e0; e0 = e1; e1 = tmp;\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n // w' = 3 \u2212 w reflects the palette; decoded colour unchanged.\n best.indices[k] = 3u - best.indices[k];\n }\n }\n\n // 6. Pack 128 bits.\n var block: array<u32, 4>;\n block[0] = 0u; block[1] = 0u; block[2] = 0u; block[3] = 0u;\n\n // Config header.\n write_bits(&block, 0u, 11u, 0x042u); // block mode: 4\xD74 grid, QUANT_4 weights\n write_bits(&block, 11u, 2u, 0u); // partition count \u2212 1\n write_bits(&block, 13u, 4u, 12u); // CEM 12: LDR RGBA direct\n\n // Endpoints in the CEM 12 value order: R0 R1 G0 G1 B0 B1 A0 A1.\n write_bits(&block, 17u + 0u * 8u, 8u, e0.x);\n write_bits(&block, 17u + 1u * 8u, 8u, e1.x);\n write_bits(&block, 17u + 2u * 8u, 8u, e0.y);\n write_bits(&block, 17u + 3u * 8u, 8u, e1.y);\n write_bits(&block, 17u + 4u * 8u, 8u, e0.z);\n write_bits(&block, 17u + 5u * 8u, 8u, e1.z);\n write_bits(&block, 17u + 6u * 8u, 8u, e0.w);\n write_bits(&block, 17u + 7u * 8u, 8u, e1.w);\n\n // Weights at the top of the block. Two 1-bit writes per weight keeps\n // the LSB-at-127 convention visible at every call site; the cost over\n // a batched write is negligible next to the full encode.\n for (var k: u32 = 0u; k < 16u; k = k + 1u) {\n let w = best.indices[k] & 0x3u;\n write_bits(&block, 127u - 2u * k, 1u, w & 1u);\n write_bits(&block, 126u - 2u * k, 1u, (w >> 1u) & 1u);\n }\n\n // 7. Store as 4 u32s.\n let out = block_index * 4u;\n dst[out + 0u] = block[0];\n dst[out + 1u] = block[1];\n dst[out + 2u] = block[2];\n dst[out + 3u] = block[3];\n}\n";
366
+
367
+ // src/ASTC4x4Encoder.ts
368
+ var ASTC4x4Encoder = class extends Encoder {
369
+ static requiredFeature = WebGPUFeature.ASTC;
370
+ static textureFormats = [TextureFormat.ASTC_4x4, TextureFormat.ASTC_4x4_SRGB];
371
+ get label() {
372
+ return "astc4x4";
373
+ }
374
+ get bytesPerBlock() {
375
+ return 16;
376
+ }
377
+ get supportsSrgb() {
378
+ return true;
379
+ }
380
+ wgslSource() {
381
+ return astc4x4_default;
382
+ }
383
+ gpuTextureFormat({ colorSpace }) {
384
+ return colorSpace === "srgb" ? "astc-4x4-unorm-srgb" : "astc-4x4-unorm";
385
+ }
386
+ threeTextureFormat() {
387
+ return RGBA_ASTC_4x4_Format;
388
+ }
389
+ };
390
+
391
+ // src/selectFormat.ts
392
+ function selectFormat(adapter, hint, options = {}) {
393
+ const { colorSpace = "srgb" } = options;
394
+ const srgb = colorSpace === "srgb";
395
+ const caps = detectCapabilities(adapter);
396
+ if (caps.bc) {
397
+ if (hint === "normal") {
398
+ return { format: TextureFormat.BC5, encoderClass: BC5Encoder, astcNormalRemap: false };
399
+ }
400
+ return {
401
+ format: srgb ? TextureFormat.BC7_SRGB : TextureFormat.BC7,
402
+ encoderClass: BC7Encoder,
403
+ astcNormalRemap: false
404
+ };
405
+ }
406
+ if (caps.astc) {
407
+ const format = srgb ? TextureFormat.ASTC_4x4_SRGB : TextureFormat.ASTC_4x4;
408
+ return {
409
+ format,
410
+ encoderClass: ASTC4x4Encoder,
411
+ astcNormalRemap: hint === "normal"
412
+ };
413
+ }
414
+ return { format: null, encoderClass: null, astcNormalRemap: false };
415
+ }
416
+
417
+ // src/compressTexture.ts
418
+ import { LinearFilter as LinearFilter2, LinearSRGBColorSpace as LinearSRGBColorSpace2, RepeatWrapping as RepeatWrapping2, SRGBColorSpace as SRGBColorSpace2, Texture } from "three";
419
+
420
+ // src/mipgen.ts
421
+ function generateMipChain(level0) {
422
+ if (level0.width < 1 || level0.height < 1) {
423
+ throw new Error(`generateMipChain: level 0 must be at least 1\xD71, got ${level0.width}\xD7${level0.height}`);
424
+ }
425
+ if (level0.data.length !== level0.width * level0.height * 4) {
426
+ throw new Error(
427
+ `generateMipChain: level 0 data length ${level0.data.length} does not match ${level0.width}\xD7${level0.height}\xD74 = ${level0.width * level0.height * 4}`
428
+ );
429
+ }
430
+ const chain = [level0];
431
+ let prev = level0;
432
+ while (prev.width > 1 || prev.height > 1) {
433
+ prev = downsample2x(prev);
434
+ chain.push(prev);
435
+ }
436
+ return chain;
437
+ }
438
+ function downsample2x(src) {
439
+ const dstW = Math.max(1, src.width >> 1);
440
+ const dstH = Math.max(1, src.height >> 1);
441
+ const dst = new Uint8ClampedArray(dstW * dstH * 4);
442
+ const sW = src.width;
443
+ const sMaxX = src.width - 1;
444
+ const sMaxY = src.height - 1;
445
+ for (let y = 0; y < dstH; y++) {
446
+ const sy0 = y * 2;
447
+ const sy1 = Math.min(sy0 + 1, sMaxY);
448
+ for (let x = 0; x < dstW; x++) {
449
+ const sx0 = x * 2;
450
+ const sx1 = Math.min(sx0 + 1, sMaxX);
451
+ const i00 = (sy0 * sW + sx0) * 4;
452
+ const i10 = (sy0 * sW + sx1) * 4;
453
+ const i01 = (sy1 * sW + sx0) * 4;
454
+ const i11 = (sy1 * sW + sx1) * 4;
455
+ const o = (y * dstW + x) * 4;
456
+ dst[o] = src.data[i00] + src.data[i10] + src.data[i01] + src.data[i11] + 2 >> 2;
457
+ dst[o + 1] = src.data[i00 + 1] + src.data[i10 + 1] + src.data[i01 + 1] + src.data[i11 + 1] + 2 >> 2;
458
+ dst[o + 2] = src.data[i00 + 2] + src.data[i10 + 2] + src.data[i01 + 2] + src.data[i11 + 2] + 2 >> 2;
459
+ dst[o + 3] = src.data[i00 + 3] + src.data[i10 + 3] + src.data[i01 + 3] + src.data[i11 + 3] + 2 >> 2;
460
+ }
461
+ }
462
+ return { data: dst, width: dstW, height: dstH };
463
+ }
464
+ function padToBlockMultiple(level) {
465
+ const pw = level.width + 3 & ~3;
466
+ const ph = level.height + 3 & ~3;
467
+ if (pw === level.width && ph === level.height) return level;
468
+ const out = new Uint8ClampedArray(pw * ph * 4);
469
+ const maxX = level.width - 1;
470
+ const maxY = level.height - 1;
471
+ for (let y = 0; y < ph; y++) {
472
+ const sy = Math.min(y, maxY);
473
+ for (let x = 0; x < pw; x++) {
474
+ const sx = Math.min(x, maxX);
475
+ const si = (sy * level.width + sx) * 4;
476
+ const di = (y * pw + x) * 4;
477
+ out[di] = level.data[si];
478
+ out[di + 1] = level.data[si + 1];
479
+ out[di + 2] = level.data[si + 2];
480
+ out[di + 3] = level.data[si + 3];
481
+ }
482
+ }
483
+ return { data: out, width: pw, height: ph };
484
+ }
485
+
486
+ // src/compressTexture.ts
487
+ async function sourceToBitmap(source) {
488
+ const opts = {
489
+ colorSpaceConversion: "none",
490
+ premultiplyAlpha: "none"
491
+ };
492
+ if (typeof source === "string") {
493
+ const resp = await fetch(source);
494
+ if (!resp.ok) {
495
+ throw new Error(`compressTexture: fetch ${source} failed (${resp.status})`);
496
+ }
497
+ const blob = await resp.blob();
498
+ return createImageBitmap(blob, opts);
499
+ }
500
+ if (source instanceof Blob) {
501
+ return createImageBitmap(source, opts);
502
+ }
503
+ if (source instanceof ImageBitmap) {
504
+ return source;
505
+ }
506
+ return createImageBitmap(source, opts);
507
+ }
508
+ function bitmapToMipLevel(bitmap, flipY) {
509
+ const w = bitmap.width, h = bitmap.height;
510
+ const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(w, h) : Object.assign(document.createElement("canvas"), { width: w, height: h });
511
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
512
+ if (!ctx) {
513
+ throw new Error("compressTexture: no 2D context available for mip generation");
514
+ }
515
+ if (flipY) {
516
+ ctx.translate(0, h);
517
+ ctx.scale(1, -1);
518
+ }
519
+ ctx.drawImage(bitmap, 0, 0);
520
+ const imageData = ctx.getImageData(0, 0, w, h);
521
+ return { data: imageData.data, width: w, height: h };
522
+ }
523
+ function mipLevelToImageData(level) {
524
+ return new ImageData(level.data, level.width, level.height);
525
+ }
526
+ function wrapUncompressed(bitmap, srgb, flipY) {
527
+ const tex = new Texture(bitmap);
528
+ tex.colorSpace = srgb ? SRGBColorSpace2 : LinearSRGBColorSpace2;
529
+ tex.magFilter = LinearFilter2;
530
+ tex.minFilter = LinearFilter2;
531
+ tex.wrapS = tex.wrapT = RepeatWrapping2;
532
+ tex.generateMipmaps = false;
533
+ tex.flipY = flipY;
534
+ tex.needsUpdate = true;
535
+ return tex;
536
+ }
537
+ async function compressTexture(source, options = {}) {
538
+ const {
539
+ hint = "color",
540
+ colorSpace = "srgb",
541
+ flipY = true,
542
+ mipmaps = false,
543
+ device: providedDevice,
544
+ adapter: providedAdapter
545
+ } = options;
546
+ const srgb = colorSpace === "srgb";
547
+ const bitmap = await sourceToBitmap(source);
548
+ if (!("gpu" in navigator)) {
549
+ console.warn("[compressTexture] WebGPU unavailable; returning uncompressed RGBA8.");
550
+ const tex = wrapUncompressed(bitmap, srgb, flipY);
551
+ return {
552
+ texture: tex,
553
+ format: null,
554
+ fallbackUncompressed: true,
555
+ astcNormalRemap: false,
556
+ width: bitmap.width,
557
+ height: bitmap.height,
558
+ mipLevels: 1,
559
+ encodeMs: 0,
560
+ destroy: () => {
561
+ tex.dispose();
562
+ }
563
+ };
564
+ }
565
+ const adapter = providedAdapter ?? await navigator.gpu.requestAdapter();
566
+ if (!adapter) {
567
+ console.warn("[compressTexture] No WebGPU adapter; returning uncompressed RGBA8.");
568
+ const tex = wrapUncompressed(bitmap, srgb, flipY);
569
+ return {
570
+ texture: tex,
571
+ format: null,
572
+ fallbackUncompressed: true,
573
+ astcNormalRemap: false,
574
+ width: bitmap.width,
575
+ height: bitmap.height,
576
+ mipLevels: 1,
577
+ encodeMs: 0,
578
+ destroy: () => {
579
+ tex.dispose();
580
+ }
581
+ };
582
+ }
583
+ const selection = selectFormat(adapter, hint, { colorSpace });
584
+ if (!selection.format || !selection.encoderClass) {
585
+ console.warn(
586
+ "[compressTexture] Adapter reports neither texture-compression-bc nor texture-compression-astc; returning uncompressed RGBA8."
587
+ );
588
+ const tex = wrapUncompressed(bitmap, srgb, flipY);
589
+ return {
590
+ texture: tex,
591
+ format: null,
592
+ fallbackUncompressed: true,
593
+ astcNormalRemap: false,
594
+ width: bitmap.width,
595
+ height: bitmap.height,
596
+ mipLevels: 1,
597
+ encodeMs: 0,
598
+ destroy: () => {
599
+ tex.dispose();
600
+ }
601
+ };
602
+ }
603
+ let encoder;
604
+ if (providedDevice) {
605
+ const EncoderCtor = selection.encoderClass;
606
+ encoder = new EncoderCtor({ device: providedDevice, adapter, ownsDevice: false });
607
+ } else {
608
+ encoder = await selection.encoderClass.create();
609
+ }
610
+ try {
611
+ if (!mipmaps) {
612
+ const bytes = await encoder.encodeToBytes(bitmap, { flipY });
613
+ const tex2 = encoder.buildMippedTexture([bytes], { colorSpace });
614
+ return {
615
+ texture: tex2,
616
+ format: selection.format,
617
+ fallbackUncompressed: false,
618
+ astcNormalRemap: selection.astcNormalRemap,
619
+ width: bytes.width,
620
+ height: bytes.height,
621
+ mipLevels: 1,
622
+ encodeMs: bytes.encodeMs,
623
+ destroy: () => {
624
+ tex2.dispose();
625
+ encoder.destroy();
626
+ }
627
+ };
628
+ }
629
+ const level0 = bitmapToMipLevel(bitmap, flipY);
630
+ const chain = generateMipChain(level0);
631
+ const encodedLevels = [];
632
+ let totalEncodeMs = 0;
633
+ for (const level of chain) {
634
+ const padded = padToBlockMultiple(level);
635
+ const imageData = mipLevelToImageData(padded);
636
+ const bytes = await encoder.encodeToBytes(imageData);
637
+ encodedLevels.push(bytes);
638
+ totalEncodeMs += bytes.encodeMs;
639
+ }
640
+ const tex = encoder.buildMippedTexture(encodedLevels, { colorSpace });
641
+ return {
642
+ texture: tex,
643
+ format: selection.format,
644
+ fallbackUncompressed: false,
645
+ astcNormalRemap: selection.astcNormalRemap,
646
+ width: level0.width,
647
+ height: level0.height,
648
+ mipLevels: encodedLevels.length,
649
+ encodeMs: totalEncodeMs,
650
+ destroy: () => {
651
+ tex.dispose();
652
+ encoder.destroy();
653
+ }
654
+ };
655
+ } catch (e) {
656
+ encoder.destroy();
657
+ throw e;
658
+ }
659
+ }
660
+
661
+ // src/CompressedTextureLoader.ts
662
+ import { Loader } from "three";
663
+ var WebGPUCompressedTextureLoader = class extends Loader {
664
+ /** Format-selection hint. Default 'color'. */
665
+ hint = "color";
666
+ /** Pick the sRGB or linear variant of the chosen format. Default 'srgb'. */
667
+ colorSpace = "srgb";
668
+ /** Flip the image vertically before encoding. Default true (matches Three.js convention). */
669
+ flipY = true;
670
+ /** Generate + encode a full mip chain. Default false. */
671
+ mipmaps = false;
672
+ /**
673
+ * Optional pre-existing WebGPU device. Reusing the renderer's device
674
+ * avoids spinning up a second WebGPU context for encoding.
675
+ */
676
+ device;
677
+ adapter;
678
+ /**
679
+ * Most recent full encode result. Useful when the caller wants format
680
+ * / mipLevels / astcNormalRemap metadata without threading a separate
681
+ * callback through `load()`. Cleared when a new load starts.
682
+ */
683
+ lastResult = null;
684
+ /**
685
+ * THREE.Loader contract: returns void, drives callbacks. `loadAsync`
686
+ * (inherited from the base class) wraps this with Promise semantics.
687
+ * Errors routed through `manager.itemError` so the LoadingManager's
688
+ * aggregate state stays accurate.
689
+ */
690
+ load(url, onLoad, _onProgress, onError) {
691
+ this.lastResult = null;
692
+ this.manager.itemStart(url);
693
+ compressTexture(url, {
694
+ hint: this.hint,
695
+ colorSpace: this.colorSpace,
696
+ flipY: this.flipY,
697
+ mipmaps: this.mipmaps,
698
+ device: this.device,
699
+ adapter: this.adapter
700
+ }).then(
701
+ (result) => {
702
+ this.lastResult = result;
703
+ const mip0 = result.texture.mipmaps?.[0];
704
+ result.texture.userData.gputex = {
705
+ format: result.format,
706
+ fallbackUncompressed: result.fallbackUncompressed,
707
+ astcNormalRemap: result.astcNormalRemap,
708
+ width: result.width,
709
+ height: result.height,
710
+ mipLevels: result.mipLevels,
711
+ encodeMs: result.encodeMs,
712
+ compressedBytes: result.fallbackUncompressed ? result.width * result.height * 4 : mip0?.data.byteLength ?? 0
713
+ };
714
+ onLoad?.(result.texture);
715
+ this.manager.itemEnd(url);
716
+ },
717
+ (err) => {
718
+ onError?.(err);
719
+ this.manager.itemError(url);
720
+ this.manager.itemEnd(url);
721
+ }
722
+ );
723
+ }
724
+ };
725
+ export {
726
+ ASTC4x4Encoder,
727
+ BC1Encoder,
728
+ BC5Encoder,
729
+ BC7Encoder,
730
+ Encoder,
731
+ TextureFormat,
732
+ WebGPUCompressedTextureLoader,
733
+ WebGPUFeature,
734
+ compressTexture,
735
+ detectCapabilities,
736
+ generateMipChain,
737
+ padToBlockMultiple,
738
+ selectFormat
739
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "gputex",
3
+ "version": "0.0.3",
4
+ "license": "MIT",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "type": "module",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "dev": "tsup --watch",
17
+ "build": "tsup && cp ../README.md README.md",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "devDependencies": {
21
+ "@types/bun": "1.3.8",
22
+ "@types/three": "^0.183.1",
23
+ "@webgpu/types": "^0.1.69",
24
+ "three": "^0.183.2",
25
+ "tsup": "8.5.1",
26
+ "typescript": "5.9.3"
27
+ },
28
+ "peerDependencies": {
29
+ "three": ">=0.170"
30
+ }
31
+ }