@ringozz/godot 4.7.2-657 → 4.7.2-661

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/src/web-image.ts CHANGED
@@ -1,388 +1,388 @@
1
- /**********************************************************************
2
- Copyright (c) Vladimir Davidovich. All rights reserved.
3
- ***********************************************************************/
4
-
5
- // Web-only compressed-texture (`ctex`) handling. Two container layouts share the
6
- // same per-image body (`sub-header + level-size table + payloads`):
7
- // - `GST2` `CompressedTexture2D` (`.ctex`): one body.
8
- // - `GSTL` `CompressedTexture2DArray` / `Cubemap` / `CubemapArray`
9
- // (`.ctexarray` / `.ccube` / `.ccubearray`): N bodies of identical geometry.
10
- // `CompressedTexture3D` (`.ctex3d`) has its own layout and is not handled, and
11
- // the outer headers are the same size for both (`magic + 8 x u32`), which is why
12
- // a single parse/allocate pair covers everything — a container is just a list of
13
- // layers.
14
- //
15
- // Exposes the container/format helpers, `pickGodotFormats` (the driver's
16
- // compressed-format list) and `decodeCtex` — the default staging path, which
17
- // decodes the PNG/WebP payloads with the browser and rebuilds a raw RGBA8
18
- // container so no image codec runs in wasm. The optional compressed-transcode
19
- // hook lives in load.ts.
20
-
21
- // Both container layouts share the outer header (magic + 8 x u32) and the
22
- // per-image sub-header, so an image body's data starts at a fixed offset.
23
- const OUTER_HEADER_SIZE = 36;
24
- const SUB_HEADER_SIZE = 16;
25
-
26
- const DATA_FORMAT_PNG = 1;
27
- const DATA_FORMAT_WEBP = 2;
28
-
29
- // `Image::Format` targets we emit (`core/io/image.h`). Every compressed target
30
- // is a 4x4 block format; they differ only in bytes per block, so the size math
31
- // is a small table rather than a per-format switch.
32
- export const FORMAT_DXT1 = 17; // BC1
33
- export const FORMAT_RGTC_R = 20; // BC4
34
- export const FORMAT_RGTC_RG = 21; // BC5
35
- export const FORMAT_BPTC_RGBA = 22; // BC7
36
- export const FORMAT_ETC2_R11 = 26; // EAC R
37
- export const FORMAT_ETC2_RG11 = 28; // EAC RG
38
- export const FORMAT_ETC2_RGB8 = 30;
39
- export const FORMAT_ASTC_4x4 = 35;
40
-
41
- // Image-body data starts here: outer header + sub-header.
42
- export const BLOB_TABLE_OFFSET = OUTER_HEADER_SIZE + SUB_HEADER_SIZE;
43
-
44
- const DATA_FORMAT_IMAGE = 0;
45
- const FORMAT_RGBA8 = 5;
46
- const RGBA8_PIXEL_SIZE = 4;
47
-
48
- const FORMAT_BIT_STREAM = 1 << 22;
49
- const MAGIC_LAYERED = 0x4c; // 'GSTL'; 'GST2' (0x32) is the single-image layout
50
-
51
- // Bytes per 4x4 block for each compressed target; RGBA8 is 4 bytes/pixel.
52
- const BLOCK_BYTES: Record<number, number> = {
53
- [FORMAT_DXT1]: 8,
54
- [FORMAT_ETC2_RGB8]: 8,
55
- [FORMAT_ETC2_R11]: 8,
56
- [FORMAT_RGTC_R]: 8,
57
- [FORMAT_RGTC_RG]: 16,
58
- [FORMAT_ETC2_RG11]: 16,
59
- [FORMAT_BPTC_RGBA]: 16,
60
- [FORMAT_ASTC_4x4]: 16,
61
- };
62
-
63
- /**
64
- * Bytes per 4x4 block of a compressed `Image::Format` target (RGBA8 reports 4,
65
- * its bytes per pixel). Unknown formats fall back to a 16-byte block.
66
- */
67
- export function blockBytes(format: number): number {
68
- return format === FORMAT_RGBA8 ? RGBA8_PIXEL_SIZE : (BLOCK_BYTES[format] ?? 16);
69
- }
70
-
71
- export interface CtexLayer {
72
- /** `DATA_FORMAT_PNG` (1) / `DATA_FORMAT_WEBP` (2). */
73
- dataFormat: number;
74
- width: number;
75
- height: number;
76
- /** Highest mipmap index (total levels = `mipmaps + 1`). */
77
- mipmaps: number;
78
- /** Per-level payload byte ranges, level 0 first. */
79
- levels: { offset: number; size: number }[];
80
- }
81
-
82
- function isLayered(bytes: Uint8Array): boolean {
83
- return bytes.length >= OUTER_HEADER_SIZE && bytes[3] === MAGIC_LAYERED;
84
- }
85
-
86
- /**
87
- * Parses a container into its layers — one for `GST2` (`.ctex`), N for `GSTL`
88
- * (`.ctexarray`/`.ccube`/`.ccubearray`), in order. Returns `null` for anything
89
- * else: non-ctex bytes, a streamable container, or payloads that aren't
90
- * PNG/WebP-embedded (raw / BASIS / VRAM containers pass through untouched), or
91
- * layers of differing geometry. Throws on a truncated level table so a malformed
92
- * file fails loudly rather than decoding garbage.
93
- */
94
- export function parseCtex(bytes: Uint8Array): CtexLayer[] | null {
95
- if (
96
- bytes.length < OUTER_HEADER_SIZE + SUB_HEADER_SIZE ||
97
- bytes[0] !== 0x47 || // 'G'
98
- bytes[1] !== 0x53 || // 'S'
99
- bytes[2] !== 0x54 || // 'T'
100
- (bytes[3] !== 0x32 && bytes[3] !== MAGIC_LAYERED)
101
- ) {
102
- return null;
103
- }
104
- const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
105
- if (dv.getUint32(16, true) & FORMAT_BIT_STREAM) {
106
- return null; // streamable layout differs; leave it to the engine
107
- }
108
- const count = isLayered(bytes) ? dv.getUint32(8, true) : 1;
109
- const layers: CtexLayer[] = [];
110
- let offset = OUTER_HEADER_SIZE;
111
- for (let i = 0; i < count; i++) {
112
- if (offset + SUB_HEADER_SIZE > bytes.length) {
113
- return null;
114
- }
115
- const dataFormat = dv.getUint32(offset, true);
116
- if (dataFormat !== DATA_FORMAT_PNG && dataFormat !== DATA_FORMAT_WEBP) {
117
- return null;
118
- }
119
- const width = dv.getUint16(offset + 4, true);
120
- const height = dv.getUint16(offset + 6, true);
121
- const mipmaps = dv.getUint32(offset + 8, true);
122
- const levels: { offset: number; size: number }[] = [];
123
- let p = offset + SUB_HEADER_SIZE;
124
- for (let level = 0; level <= mipmaps; level++) {
125
- if (p + 4 > bytes.length) {
126
- throw new Error(`web-image: truncated ctex at mipmap ${level}`);
127
- }
128
- const size = dv.getUint32(p, true);
129
- p += 4;
130
- if (p + size > bytes.length) {
131
- throw new Error(`web-image: truncated ctex blob at mipmap ${level}`);
132
- }
133
- levels.push({ offset: p, size });
134
- p += size;
135
- }
136
- const first = layers[0];
137
- if (first && (width !== first.width || height !== first.height || mipmaps !== first.mipmaps)) {
138
- return null; // texture arrays need uniform layer geometry
139
- }
140
- layers.push({ dataFormat, width, height, mipmaps, levels });
141
- offset = p;
142
- }
143
- return count === 0 ? null : layers;
144
- }
145
-
146
- /** Dimensions of a mipmap level (`max(dim >> level, 1)`). */
147
- export function mipLevelDimensions(width: number, height: number, level: number): [number, number] {
148
- return [Math.max(width >> level, 1), Math.max(height >> level, 1)];
149
- }
150
-
151
- /** Number of levels in the full mip chain down to 1×1 (>= 1). */
152
- export function mipChainLength(width: number, height: number): number {
153
- let levels = 1;
154
- while (width > 1 || height > 1) {
155
- width = Math.max(1, width >> 1);
156
- height = Math.max(1, height >> 1);
157
- levels++;
158
- }
159
- return levels;
160
- }
161
-
162
- // Bytes of one `DATA_FORMAT_IMAGE` image body: sub-header + tightly packed
163
- // levels. RGBA8 is 4 bytes/pixel; compressed targets are 4x4 blocks (8 or 16
164
- // bytes) whose dimensions round up, matching `Image::_get_dst_image_size`.
165
- function bodySize(width: number, height: number, mipmaps: number, format: number): number {
166
- const rgba8 = format === FORMAT_RGBA8;
167
- const block = rgba8 ? 1 : 4;
168
- const bytesPerBlock = blockBytes(format);
169
- let dataSize = 0;
170
- for (let level = 0; level <= mipmaps; level++) {
171
- const [w, h] = mipLevelDimensions(width, height, level);
172
- dataSize += Math.ceil(w / block) * Math.ceil(h / block) * bytesPerBlock;
173
- }
174
- return SUB_HEADER_SIZE + dataSize;
175
- }
176
-
177
- /**
178
- * Allocates a `.ctex` with `count` identical `DATA_FORMAT_IMAGE` bodies (one
179
- * array, no intermediate copies), reporting `stride` — bytes per body, so layer
180
- * `i`'s data goes at `BLOB_TABLE_OFFSET + i * stride`. The outer header is kept
181
- * verbatim (the layer count / layered type are the container's, not ours),
182
- * except that `GST2` also carries the image dimensions there, which are patched.
183
- */
184
- export function allocateCtex(
185
- source: Uint8Array,
186
- count: number,
187
- width: number,
188
- height: number,
189
- mipmaps: number,
190
- format: number,
191
- ): { bytes: Uint8Array; stride: number } {
192
- const stride = bodySize(width, height, mipmaps, format);
193
- const bytes = new Uint8Array(OUTER_HEADER_SIZE + count * stride);
194
- bytes.set(source.subarray(0, OUTER_HEADER_SIZE)); // outer header verbatim (version/flags)
195
- const dv = new DataView(bytes.buffer);
196
- if (!isLayered(source)) {
197
- dv.setUint32(8, width, true); // GST2 outer width
198
- dv.setUint32(12, height, true); // GST2 outer height
199
- }
200
- for (let i = 0; i < count; i++) {
201
- const offset = OUTER_HEADER_SIZE + i * stride;
202
- dv.setUint32(offset, DATA_FORMAT_IMAGE, true);
203
- dv.setUint16(offset + 4, width, true);
204
- dv.setUint16(offset + 6, height, true);
205
- dv.setUint32(offset + 8, mipmaps, true);
206
- dv.setUint32(offset + 12, format, true);
207
- }
208
- return { bytes, stride };
209
- }
210
-
211
- // ---------------------------------------------------------------------------
212
- // Capability probing. GLES3 uploads a compressed format only when its config
213
- // reports it supported, and this build compiles in NO decompressors, so a format
214
- // the device can't upload breaks the texture rather than degrading. Ask the
215
- // driver (`COMPRESSED_TEXTURE_FORMATS`) rather than reading extension names:
216
- // Chrome on Windows lists no ETC2/EAC and rejects those uploads, while
217
- // `config.cpp` would still route one to it. RGTC arrays are the one place 2D and
218
- // layered differ — `_texture_set_data` force-decompresses them for
219
- // `TEXTURE_2D_ARRAY`, gated here on `Image::can_decompress("s3tc")`.
220
- // ---------------------------------------------------------------------------
221
-
222
- // `CompressedTexture` GL enums -> Godot `Image::Format`, for the targets the
223
- // encoder can emit; only the unsigned-normal variants (their siblings share it).
224
- const GL_TO_GODOT_FORMAT: Record<number, number> = {
225
- 0x83f0: FORMAT_DXT1, // COMPRESSED_RGB_S3TC_DXT1_EXT
226
- 0x8dbb: FORMAT_RGTC_R, // COMPRESSED_RED_RGTC1_EXT
227
- 0x8dbd: FORMAT_RGTC_RG, // COMPRESSED_RED_GREEN_RGTC2_EXT
228
- 0x8e8c: FORMAT_BPTC_RGBA, // COMPRESSED_RGBA_BPTC_UNORM_EXT
229
- 0x9270: FORMAT_ETC2_R11, // COMPRESSED_R11_EAC
230
- 0x9272: FORMAT_ETC2_RG11, // COMPRESSED_RG11_EAC
231
- 0x9274: FORMAT_ETC2_RGB8, // COMPRESSED_RGB8_ETC2
232
- 0x93b0: FORMAT_ASTC_4x4, // COMPRESSED_RGBA_ASTC_4x4_KHR
233
- };
234
-
235
- /** Enabled before reading the list — a browser may only list a format once its extension is enabled. */
236
- const PROBED_EXTENSIONS = [
237
- 'EXT_texture_compression_bptc',
238
- 'EXT_texture_compression_rgtc',
239
- 'WEBGL_compressed_texture_s3tc',
240
- 'WEBGL_compressed_texture_astc',
241
- 'WEBGL_compressed_texture_etc',
242
- ];
243
-
244
- /** The Godot targets among a driver's `COMPRESSED_TEXTURE_FORMATS` list, in list order and deduplicated (pure). */
245
- export function godotFormatsFor(glFormats: ArrayLike<number>): number[] {
246
- const formats = new Set<number>();
247
- for (let i = 0; i < glFormats.length; i++) {
248
- const godot = GL_TO_GODOT_FORMAT[glFormats[i]!];
249
- if (godot !== undefined) {
250
- formats.add(godot);
251
- }
252
- }
253
- return [...formats];
254
- }
255
-
256
- /**
257
- * Compressed `Image::Format` targets the running device can upload. A list
258
- * because the encoder may also reject a format the browser exposes (e.g. spark
259
- * skips BC on iOS); the caller intersects it with spark's supported set, and
260
- * order here is not significant (the encoder has its own preference chain).
261
- */
262
- export function pickGodotFormats(): number[] {
263
- let gl: WebGL2RenderingContext | null = null;
264
- try {
265
- // Prefer a DOM canvas (works everywhere WebGL2 does); fall back to
266
- // OffscreenCanvas for worker contexts.
267
- const canvas =
268
- typeof document !== 'undefined'
269
- ? document.createElement('canvas')
270
- : typeof OffscreenCanvas !== 'undefined'
271
- ? new OffscreenCanvas(1, 1)
272
- : null;
273
- gl = (canvas?.getContext('webgl2', {
274
- antialias: false,
275
- depth: false,
276
- stencil: false,
277
- alpha: false,
278
- }) ?? null) as WebGL2RenderingContext | null;
279
- } catch {
280
- gl = null;
281
- }
282
- if (!gl) {
283
- return [];
284
- }
285
- try {
286
- for (const name of PROBED_EXTENSIONS) {
287
- gl.getExtension(name);
288
- }
289
- return godotFormatsFor(gl.getParameter(gl.COMPRESSED_TEXTURE_FORMATS) as ArrayLike<number>);
290
- } finally {
291
- // Don't retain the probe context for the page's lifetime.
292
- gl.getExtension('WEBGL_lose_context')?.loseContext();
293
- }
294
- }
295
-
296
- /**
297
- * Transforms fetched `.ctex` bytes into replacement bytes, or `null` to decline
298
- * (staging then keeps the raw RGBA8 decode). `path` is the resource path being
299
- * staged, so a platform transcoder can pick a block format from the file name.
300
- * Produced by `transcoder*.ts` and installed by `load.ts`'s staging.
301
- */
302
- export type CtexTranscoder = (bytes: Uint8Array, path: string) => Promise<Uint8Array | null>;
303
-
304
- /**
305
- * Decodes the embedded PNG/WebP payloads of a `.ctex` with the browser's
306
- * `createImageBitmap` and rebuilds it as raw RGBA8 (`DATA_FORMAT_IMAGE`) — for
307
- * `GST2` and for each layer of a `GSTL` array/cubemap. Non-ctex bytes,
308
- * streamable containers, and `DATA_FORMAT_IMAGE` / `DATA_FORMAT_BASIS_UNIVERSAL`
309
- * containers are returned unchanged. Throws if an embedded payload can't be
310
- * decoded or its dimensions don't match the expected mipmap layout, so a
311
- * would-be undecodable texture fails loudly at materialize time. A no-op outside
312
- * browsers (no `createImageBitmap`). Levels are decoded concurrently and written
313
- * straight into the output container (no per-level array).
314
- */
315
- export async function decodeCtex(bytes: Uint8Array): Promise<Uint8Array> {
316
- if (typeof createImageBitmap !== 'function') {
317
- return bytes;
318
- }
319
- const layers = parseCtex(bytes);
320
- if (!layers) {
321
- return bytes;
322
- }
323
- const { width, height, mipmaps } = layers[0];
324
- const { bytes: out, stride } = allocateCtex(bytes, layers.length, width, height, mipmaps, FORMAT_RGBA8);
325
- for (let i = 0; i < layers.length; i++) {
326
- await rasterizeLevels(bytes, layers[i], out, BLOB_TABLE_OFFSET + i * stride);
327
- }
328
- return out;
329
- }
330
-
331
- /**
332
- * Decodes one PNG/WebP payload into an `ImageBitmap` (level 0 for an encoder,
333
- * every level for {@link decodeCtex}). Pixel values are handed over unchanged
334
- * (`colorSpaceConversion`/`premultiplyAlpha` off).
335
- */
336
- export function payloadBitmap(
337
- bytes: Uint8Array,
338
- dataFormat: number,
339
- level: { offset: number; size: number },
340
- ): Promise<ImageBitmap> {
341
- const type = dataFormat === DATA_FORMAT_WEBP ? 'image/webp' : 'image/png';
342
- // The Blob ctor needs an ArrayBuffer-backed view.
343
- return createImageBitmap(
344
- new Blob([bytes.subarray(level.offset, level.offset + level.size) as Uint8Array<ArrayBuffer>], { type }),
345
- { colorSpaceConversion: 'none', premultiplyAlpha: 'none' },
346
- );
347
- }
348
-
349
- // Decodes a layer's PNG/WebP levels and rasterizes them into `out` at
350
- // `outOffset`, reusing one canvas across levels.
351
- async function rasterizeLevels(bytes: Uint8Array, layer: CtexLayer, out: Uint8Array, outOffset: number): Promise<void> {
352
- const { width, height } = layer;
353
- const bitmaps = await Promise.all(layer.levels.map((l) => payloadBitmap(bytes, layer.dataFormat, l)));
354
- const canvas = new OffscreenCanvas(width, height);
355
- try {
356
- let p = outOffset;
357
- for (let i = 0; i < bitmaps.length; i++) {
358
- const [lw, lh] = mipLevelDimensions(width, height, i);
359
- const bmp = bitmaps[i];
360
- if (bmp.width !== lw || bmp.height !== lh) {
361
- throw new Error(`web-image: mipmap ${i} decoded as ${bmp.width}x${bmp.height}, expected ${lw}x${lh}`);
362
- }
363
- out.set(rasterize(canvas, bmp), p);
364
- p += lw * lh * RGBA8_PIXEL_SIZE;
365
- }
366
- } finally {
367
- // close() on an already-closed bitmap is a no-op.
368
- for (const bmp of bitmaps) {
369
- bmp.close();
370
- }
371
- }
372
- }
373
-
374
- // Rasterizes an ImageBitmap into a raw RGBA8 view, drawing into the caller's
375
- // shared canvas (resized per level) so one canvas serves every mipmap.
376
- function rasterize(canvas: OffscreenCanvas, bmp: ImageBitmap): Uint8Array {
377
- const ctx = canvas.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true }) as
378
- | OffscreenCanvasRenderingContext2D
379
- | null;
380
- if (!ctx) {
381
- throw new Error('web-image: no 2d context for OffscreenCanvas');
382
- }
383
- canvas.width = bmp.width;
384
- canvas.height = bmp.height;
385
- ctx.drawImage(bmp, 0, 0);
386
- const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
387
- return new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength);
388
- }
1
+ /**********************************************************************
2
+ Copyright (c) Vladimir Davidovich. All rights reserved.
3
+ ***********************************************************************/
4
+
5
+ // Web-only compressed-texture (`ctex`) handling. Two container layouts share the
6
+ // same per-image body (`sub-header + level-size table + payloads`):
7
+ // - `GST2` `CompressedTexture2D` (`.ctex`): one body.
8
+ // - `GSTL` `CompressedTexture2DArray` / `Cubemap` / `CubemapArray`
9
+ // (`.ctexarray` / `.ccube` / `.ccubearray`): N bodies of identical geometry.
10
+ // `CompressedTexture3D` (`.ctex3d`) has its own layout and is not handled, and
11
+ // the outer headers are the same size for both (`magic + 8 x u32`), which is why
12
+ // a single parse/allocate pair covers everything — a container is just a list of
13
+ // layers.
14
+ //
15
+ // Exposes the container/format helpers, `pickGodotFormats` (the driver's
16
+ // compressed-format list) and `decodeCtex` — the default staging path, which
17
+ // decodes the PNG/WebP payloads with the browser and rebuilds a raw RGBA8
18
+ // container so no image codec runs in wasm. The optional compressed-transcode
19
+ // hook lives in load.ts.
20
+
21
+ // Both container layouts share the outer header (magic + 8 x u32) and the
22
+ // per-image sub-header, so an image body's data starts at a fixed offset.
23
+ const OUTER_HEADER_SIZE = 36;
24
+ const SUB_HEADER_SIZE = 16;
25
+
26
+ const DATA_FORMAT_PNG = 1;
27
+ const DATA_FORMAT_WEBP = 2;
28
+
29
+ // `Image::Format` targets we emit (`core/io/image.h`). Every compressed target
30
+ // is a 4x4 block format; they differ only in bytes per block, so the size math
31
+ // is a small table rather than a per-format switch.
32
+ export const FORMAT_DXT1 = 17; // BC1
33
+ export const FORMAT_RGTC_R = 20; // BC4
34
+ export const FORMAT_RGTC_RG = 21; // BC5
35
+ export const FORMAT_BPTC_RGBA = 22; // BC7
36
+ export const FORMAT_ETC2_R11 = 26; // EAC R
37
+ export const FORMAT_ETC2_RG11 = 28; // EAC RG
38
+ export const FORMAT_ETC2_RGB8 = 30;
39
+ export const FORMAT_ASTC_4x4 = 35;
40
+
41
+ // Image-body data starts here: outer header + sub-header.
42
+ export const BLOB_TABLE_OFFSET = OUTER_HEADER_SIZE + SUB_HEADER_SIZE;
43
+
44
+ const DATA_FORMAT_IMAGE = 0;
45
+ const FORMAT_RGBA8 = 5;
46
+ const RGBA8_PIXEL_SIZE = 4;
47
+
48
+ const FORMAT_BIT_STREAM = 1 << 22;
49
+ const MAGIC_LAYERED = 0x4c; // 'GSTL'; 'GST2' (0x32) is the single-image layout
50
+
51
+ // Bytes per 4x4 block for each compressed target; RGBA8 is 4 bytes/pixel.
52
+ const BLOCK_BYTES: Record<number, number> = {
53
+ [FORMAT_DXT1]: 8,
54
+ [FORMAT_ETC2_RGB8]: 8,
55
+ [FORMAT_ETC2_R11]: 8,
56
+ [FORMAT_RGTC_R]: 8,
57
+ [FORMAT_RGTC_RG]: 16,
58
+ [FORMAT_ETC2_RG11]: 16,
59
+ [FORMAT_BPTC_RGBA]: 16,
60
+ [FORMAT_ASTC_4x4]: 16,
61
+ };
62
+
63
+ /**
64
+ * Bytes per 4x4 block of a compressed `Image::Format` target (RGBA8 reports 4,
65
+ * its bytes per pixel). Unknown formats fall back to a 16-byte block.
66
+ */
67
+ export function blockBytes(format: number): number {
68
+ return format === FORMAT_RGBA8 ? RGBA8_PIXEL_SIZE : (BLOCK_BYTES[format] ?? 16);
69
+ }
70
+
71
+ export interface CtexLayer {
72
+ /** `DATA_FORMAT_PNG` (1) / `DATA_FORMAT_WEBP` (2). */
73
+ dataFormat: number;
74
+ width: number;
75
+ height: number;
76
+ /** Highest mipmap index (total levels = `mipmaps + 1`). */
77
+ mipmaps: number;
78
+ /** Per-level payload byte ranges, level 0 first. */
79
+ levels: { offset: number; size: number }[];
80
+ }
81
+
82
+ function isLayered(bytes: Uint8Array): boolean {
83
+ return bytes.length >= OUTER_HEADER_SIZE && bytes[3] === MAGIC_LAYERED;
84
+ }
85
+
86
+ /**
87
+ * Parses a container into its layers — one for `GST2` (`.ctex`), N for `GSTL`
88
+ * (`.ctexarray`/`.ccube`/`.ccubearray`), in order. Returns `null` for anything
89
+ * else: non-ctex bytes, a streamable container, or payloads that aren't
90
+ * PNG/WebP-embedded (raw / BASIS / VRAM containers pass through untouched), or
91
+ * layers of differing geometry. Throws on a truncated level table so a malformed
92
+ * file fails loudly rather than decoding garbage.
93
+ */
94
+ export function parseCtex(bytes: Uint8Array): CtexLayer[] | null {
95
+ if (
96
+ bytes.length < OUTER_HEADER_SIZE + SUB_HEADER_SIZE ||
97
+ bytes[0] !== 0x47 || // 'G'
98
+ bytes[1] !== 0x53 || // 'S'
99
+ bytes[2] !== 0x54 || // 'T'
100
+ (bytes[3] !== 0x32 && bytes[3] !== MAGIC_LAYERED)
101
+ ) {
102
+ return null;
103
+ }
104
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
105
+ if (dv.getUint32(16, true) & FORMAT_BIT_STREAM) {
106
+ return null; // streamable layout differs; leave it to the engine
107
+ }
108
+ const count = isLayered(bytes) ? dv.getUint32(8, true) : 1;
109
+ const layers: CtexLayer[] = [];
110
+ let offset = OUTER_HEADER_SIZE;
111
+ for (let i = 0; i < count; i++) {
112
+ if (offset + SUB_HEADER_SIZE > bytes.length) {
113
+ return null;
114
+ }
115
+ const dataFormat = dv.getUint32(offset, true);
116
+ if (dataFormat !== DATA_FORMAT_PNG && dataFormat !== DATA_FORMAT_WEBP) {
117
+ return null;
118
+ }
119
+ const width = dv.getUint16(offset + 4, true);
120
+ const height = dv.getUint16(offset + 6, true);
121
+ const mipmaps = dv.getUint32(offset + 8, true);
122
+ const levels: { offset: number; size: number }[] = [];
123
+ let p = offset + SUB_HEADER_SIZE;
124
+ for (let level = 0; level <= mipmaps; level++) {
125
+ if (p + 4 > bytes.length) {
126
+ throw new Error(`web-image: truncated ctex at mipmap ${level}`);
127
+ }
128
+ const size = dv.getUint32(p, true);
129
+ p += 4;
130
+ if (p + size > bytes.length) {
131
+ throw new Error(`web-image: truncated ctex blob at mipmap ${level}`);
132
+ }
133
+ levels.push({ offset: p, size });
134
+ p += size;
135
+ }
136
+ const first = layers[0];
137
+ if (first && (width !== first.width || height !== first.height || mipmaps !== first.mipmaps)) {
138
+ return null; // texture arrays need uniform layer geometry
139
+ }
140
+ layers.push({ dataFormat, width, height, mipmaps, levels });
141
+ offset = p;
142
+ }
143
+ return count === 0 ? null : layers;
144
+ }
145
+
146
+ /** Dimensions of a mipmap level (`max(dim >> level, 1)`). */
147
+ export function mipLevelDimensions(width: number, height: number, level: number): [number, number] {
148
+ return [Math.max(width >> level, 1), Math.max(height >> level, 1)];
149
+ }
150
+
151
+ /** Number of levels in the full mip chain down to 1×1 (>= 1). */
152
+ export function mipChainLength(width: number, height: number): number {
153
+ let levels = 1;
154
+ while (width > 1 || height > 1) {
155
+ width = Math.max(1, width >> 1);
156
+ height = Math.max(1, height >> 1);
157
+ levels++;
158
+ }
159
+ return levels;
160
+ }
161
+
162
+ // Bytes of one `DATA_FORMAT_IMAGE` image body: sub-header + tightly packed
163
+ // levels. RGBA8 is 4 bytes/pixel; compressed targets are 4x4 blocks (8 or 16
164
+ // bytes) whose dimensions round up, matching `Image::_get_dst_image_size`.
165
+ function bodySize(width: number, height: number, mipmaps: number, format: number): number {
166
+ const rgba8 = format === FORMAT_RGBA8;
167
+ const block = rgba8 ? 1 : 4;
168
+ const bytesPerBlock = blockBytes(format);
169
+ let dataSize = 0;
170
+ for (let level = 0; level <= mipmaps; level++) {
171
+ const [w, h] = mipLevelDimensions(width, height, level);
172
+ dataSize += Math.ceil(w / block) * Math.ceil(h / block) * bytesPerBlock;
173
+ }
174
+ return SUB_HEADER_SIZE + dataSize;
175
+ }
176
+
177
+ /**
178
+ * Allocates a `.ctex` with `count` identical `DATA_FORMAT_IMAGE` bodies (one
179
+ * array, no intermediate copies), reporting `stride` — bytes per body, so layer
180
+ * `i`'s data goes at `BLOB_TABLE_OFFSET + i * stride`. The outer header is kept
181
+ * verbatim (the layer count / layered type are the container's, not ours),
182
+ * except that `GST2` also carries the image dimensions there, which are patched.
183
+ */
184
+ export function allocateCtex(
185
+ source: Uint8Array,
186
+ count: number,
187
+ width: number,
188
+ height: number,
189
+ mipmaps: number,
190
+ format: number,
191
+ ): { bytes: Uint8Array; stride: number } {
192
+ const stride = bodySize(width, height, mipmaps, format);
193
+ const bytes = new Uint8Array(OUTER_HEADER_SIZE + count * stride);
194
+ bytes.set(source.subarray(0, OUTER_HEADER_SIZE)); // outer header verbatim (version/flags)
195
+ const dv = new DataView(bytes.buffer);
196
+ if (!isLayered(source)) {
197
+ dv.setUint32(8, width, true); // GST2 outer width
198
+ dv.setUint32(12, height, true); // GST2 outer height
199
+ }
200
+ for (let i = 0; i < count; i++) {
201
+ const offset = OUTER_HEADER_SIZE + i * stride;
202
+ dv.setUint32(offset, DATA_FORMAT_IMAGE, true);
203
+ dv.setUint16(offset + 4, width, true);
204
+ dv.setUint16(offset + 6, height, true);
205
+ dv.setUint32(offset + 8, mipmaps, true);
206
+ dv.setUint32(offset + 12, format, true);
207
+ }
208
+ return { bytes, stride };
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Capability probing. GLES3 uploads a compressed format only when its config
213
+ // reports it supported, and this build compiles in NO decompressors, so a format
214
+ // the device can't upload breaks the texture rather than degrading. Ask the
215
+ // driver (`COMPRESSED_TEXTURE_FORMATS`) rather than reading extension names:
216
+ // Chrome on Windows lists no ETC2/EAC and rejects those uploads, while
217
+ // `config.cpp` would still route one to it. RGTC arrays are the one place 2D and
218
+ // layered differ — `_texture_set_data` force-decompresses them for
219
+ // `TEXTURE_2D_ARRAY`, gated here on `Image::can_decompress("s3tc")`.
220
+ // ---------------------------------------------------------------------------
221
+
222
+ // `CompressedTexture` GL enums -> Godot `Image::Format`, for the targets the
223
+ // encoder can emit; only the unsigned-normal variants (their siblings share it).
224
+ const GL_TO_GODOT_FORMAT: Record<number, number> = {
225
+ 0x83f0: FORMAT_DXT1, // COMPRESSED_RGB_S3TC_DXT1_EXT
226
+ 0x8dbb: FORMAT_RGTC_R, // COMPRESSED_RED_RGTC1_EXT
227
+ 0x8dbd: FORMAT_RGTC_RG, // COMPRESSED_RED_GREEN_RGTC2_EXT
228
+ 0x8e8c: FORMAT_BPTC_RGBA, // COMPRESSED_RGBA_BPTC_UNORM_EXT
229
+ 0x9270: FORMAT_ETC2_R11, // COMPRESSED_R11_EAC
230
+ 0x9272: FORMAT_ETC2_RG11, // COMPRESSED_RG11_EAC
231
+ 0x9274: FORMAT_ETC2_RGB8, // COMPRESSED_RGB8_ETC2
232
+ 0x93b0: FORMAT_ASTC_4x4, // COMPRESSED_RGBA_ASTC_4x4_KHR
233
+ };
234
+
235
+ /** Enabled before reading the list — a browser may only list a format once its extension is enabled. */
236
+ const PROBED_EXTENSIONS = [
237
+ 'EXT_texture_compression_bptc',
238
+ 'EXT_texture_compression_rgtc',
239
+ 'WEBGL_compressed_texture_s3tc',
240
+ 'WEBGL_compressed_texture_astc',
241
+ 'WEBGL_compressed_texture_etc',
242
+ ];
243
+
244
+ /** The Godot targets among a driver's `COMPRESSED_TEXTURE_FORMATS` list, in list order and deduplicated (pure). */
245
+ export function godotFormatsFor(glFormats: ArrayLike<number>): number[] {
246
+ const formats = new Set<number>();
247
+ for (let i = 0; i < glFormats.length; i++) {
248
+ const godot = GL_TO_GODOT_FORMAT[glFormats[i]!];
249
+ if (godot !== undefined) {
250
+ formats.add(godot);
251
+ }
252
+ }
253
+ return [...formats];
254
+ }
255
+
256
+ /**
257
+ * Compressed `Image::Format` targets the running device can upload. A list
258
+ * because the encoder may also reject a format the browser exposes (e.g. spark
259
+ * skips BC on iOS); the caller intersects it with spark's supported set, and
260
+ * order here is not significant (the encoder has its own preference chain).
261
+ */
262
+ export function pickGodotFormats(): number[] {
263
+ let gl: WebGL2RenderingContext | null = null;
264
+ try {
265
+ // Prefer a DOM canvas (works everywhere WebGL2 does); fall back to
266
+ // OffscreenCanvas for worker contexts.
267
+ const canvas =
268
+ typeof document !== 'undefined'
269
+ ? document.createElement('canvas')
270
+ : typeof OffscreenCanvas !== 'undefined'
271
+ ? new OffscreenCanvas(1, 1)
272
+ : null;
273
+ gl = (canvas?.getContext('webgl2', {
274
+ antialias: false,
275
+ depth: false,
276
+ stencil: false,
277
+ alpha: false,
278
+ }) ?? null) as WebGL2RenderingContext | null;
279
+ } catch {
280
+ gl = null;
281
+ }
282
+ if (!gl) {
283
+ return [];
284
+ }
285
+ try {
286
+ for (const name of PROBED_EXTENSIONS) {
287
+ gl.getExtension(name);
288
+ }
289
+ return godotFormatsFor(gl.getParameter(gl.COMPRESSED_TEXTURE_FORMATS) as ArrayLike<number>);
290
+ } finally {
291
+ // Don't retain the probe context for the page's lifetime.
292
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Transforms fetched `.ctex` bytes into replacement bytes, or `null` to decline
298
+ * (staging then keeps the raw RGBA8 decode). `path` is the resource path being
299
+ * staged, so a platform transcoder can pick a block format from the file name.
300
+ * Produced by `transcoder*.ts` and installed by `load.ts`'s staging.
301
+ */
302
+ export type CtexTranscoder = (bytes: Uint8Array, path: string) => Promise<Uint8Array | null>;
303
+
304
+ /**
305
+ * Decodes the embedded PNG/WebP payloads of a `.ctex` with the browser's
306
+ * `createImageBitmap` and rebuilds it as raw RGBA8 (`DATA_FORMAT_IMAGE`) — for
307
+ * `GST2` and for each layer of a `GSTL` array/cubemap. Non-ctex bytes,
308
+ * streamable containers, and `DATA_FORMAT_IMAGE` / `DATA_FORMAT_BASIS_UNIVERSAL`
309
+ * containers are returned unchanged. Throws if an embedded payload can't be
310
+ * decoded or its dimensions don't match the expected mipmap layout, so a
311
+ * would-be undecodable texture fails loudly at materialize time. A no-op outside
312
+ * browsers (no `createImageBitmap`). Levels are decoded concurrently and written
313
+ * straight into the output container (no per-level array).
314
+ */
315
+ export async function decodeCtex(bytes: Uint8Array): Promise<Uint8Array> {
316
+ if (typeof createImageBitmap !== 'function') {
317
+ return bytes;
318
+ }
319
+ const layers = parseCtex(bytes);
320
+ if (!layers) {
321
+ return bytes;
322
+ }
323
+ const { width, height, mipmaps } = layers[0];
324
+ const { bytes: out, stride } = allocateCtex(bytes, layers.length, width, height, mipmaps, FORMAT_RGBA8);
325
+ for (let i = 0; i < layers.length; i++) {
326
+ await rasterizeLevels(bytes, layers[i], out, BLOB_TABLE_OFFSET + i * stride);
327
+ }
328
+ return out;
329
+ }
330
+
331
+ /**
332
+ * Decodes one PNG/WebP payload into an `ImageBitmap` (level 0 for an encoder,
333
+ * every level for {@link decodeCtex}). Pixel values are handed over unchanged
334
+ * (`colorSpaceConversion`/`premultiplyAlpha` off).
335
+ */
336
+ export function payloadBitmap(
337
+ bytes: Uint8Array,
338
+ dataFormat: number,
339
+ level: { offset: number; size: number },
340
+ ): Promise<ImageBitmap> {
341
+ const type = dataFormat === DATA_FORMAT_WEBP ? 'image/webp' : 'image/png';
342
+ // The Blob ctor needs an ArrayBuffer-backed view.
343
+ return createImageBitmap(
344
+ new Blob([bytes.subarray(level.offset, level.offset + level.size) as Uint8Array<ArrayBuffer>], { type }),
345
+ { colorSpaceConversion: 'none', premultiplyAlpha: 'none' },
346
+ );
347
+ }
348
+
349
+ // Decodes a layer's PNG/WebP levels and rasterizes them into `out` at
350
+ // `outOffset`, reusing one canvas across levels.
351
+ async function rasterizeLevels(bytes: Uint8Array, layer: CtexLayer, out: Uint8Array, outOffset: number): Promise<void> {
352
+ const { width, height } = layer;
353
+ const bitmaps = await Promise.all(layer.levels.map((l) => payloadBitmap(bytes, layer.dataFormat, l)));
354
+ const canvas = new OffscreenCanvas(width, height);
355
+ try {
356
+ let p = outOffset;
357
+ for (let i = 0; i < bitmaps.length; i++) {
358
+ const [lw, lh] = mipLevelDimensions(width, height, i);
359
+ const bmp = bitmaps[i];
360
+ if (bmp.width !== lw || bmp.height !== lh) {
361
+ throw new Error(`web-image: mipmap ${i} decoded as ${bmp.width}x${bmp.height}, expected ${lw}x${lh}`);
362
+ }
363
+ out.set(rasterize(canvas, bmp), p);
364
+ p += lw * lh * RGBA8_PIXEL_SIZE;
365
+ }
366
+ } finally {
367
+ // close() on an already-closed bitmap is a no-op.
368
+ for (const bmp of bitmaps) {
369
+ bmp.close();
370
+ }
371
+ }
372
+ }
373
+
374
+ // Rasterizes an ImageBitmap into a raw RGBA8 view, drawing into the caller's
375
+ // shared canvas (resized per level) so one canvas serves every mipmap.
376
+ function rasterize(canvas: OffscreenCanvas, bmp: ImageBitmap): Uint8Array {
377
+ const ctx = canvas.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true }) as
378
+ | OffscreenCanvasRenderingContext2D
379
+ | null;
380
+ if (!ctx) {
381
+ throw new Error('web-image: no 2d context for OffscreenCanvas');
382
+ }
383
+ canvas.width = bmp.width;
384
+ canvas.height = bmp.height;
385
+ ctx.drawImage(bmp, 0, 0);
386
+ const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
387
+ return new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength);
388
+ }