@driftengine/assets 4.9.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -179,6 +179,15 @@ export { ddsBlocks } from './ddsBlocks.js';
179
179
  export { decodeBc } from './bcDecode.js';
180
180
  export { decodeBcImage } from './bcImage.js';
181
181
  export { flipBc5Green } from './bcFlip.js';
182
+ /*
183
+ * Blocks a phone samples. `readKtx2` takes a texture compressed offline — ASTC, ETC2 or BC — as the
184
+ * blocks it carries, for `createSurfaceTexture`; `encodeEtc2Chain` makes ETC2 or EAC blocks and
185
+ * their chain from pixels a caller holds, which is what the loader's worker does with a BC texture
186
+ * on a device that samples ETC2 and not BC. A 2048² image is 262,144 blocks: call it in a worker.
187
+ */
188
+ export { readKtx2 } from './ktx2.js';
189
+ export { encodeEtc2 } from './etc2Encode.js';
190
+ export { encodeEtc2Chain } from './etc2Chain.js';
182
191
  /*
183
192
  * **The offline half of the GPU-driven pipeline, which nothing outside this package could reach.**
184
193
  *
package/dist/ktx2.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { CompressedTextureSource } from '@driftengine/core';
2
+ /** A KTX2 file's blocks, and the colour space its format names. */
3
+ export interface Ktx2Texture {
4
+ /** The blocks, for `createSurfaceTexture` where `uploadsCompressed` says the device takes them. */
5
+ readonly source: CompressedTextureSource;
6
+ /** Whether the file's format is an sRGB one: the `colorSpace` to create the texture in. */
7
+ readonly srgb: boolean;
8
+ }
9
+ /** `bytes` as a KTX2 file of one 2D block texture. Throws, naming the reason, on anything else. */
10
+ export declare function readKtx2(bytes: Uint8Array): Ktx2Texture;
package/dist/ktx2.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * KTX2 textures read as the blocks they carry, for `createSurfaceTexture`: a texture compressed
3
+ * offline for the devices a game ships to — ASTC or ETC2 for a phone, BC for a desktop — goes up as
4
+ * it was made, with its own chain, and nothing is decoded or encoded on the way. A game carrying a
5
+ * phone's textures in the file is the answer to a device that cannot hold them all as RGBA even
6
+ * for the moment `etc2Load.ts` re-encodes them in.
7
+ *
8
+ * **Uncompressed containers of 2D block textures only.** Refused, each by name: a supercompressed
9
+ * file, since BasisLZ needs a transcoder and Zstandard or zlib an inflater, and this engine ships
10
+ * neither — a texture tool writes the same blocks with supercompression off; a format no device
11
+ * here samples as blocks — HDR BC6H, the signed BC4, BC5 and EAC, anything that is not blocks; and a
12
+ * cube, an array or a volume, which a surface texture is none of.
13
+ *
14
+ * **BC1 with and without alpha are one format here**, as they are to WebGPU, so a texel a file
15
+ * marked opaque stores in BC1's three-colour mode comes out transparent black rather than black.
16
+ * What would make that wrong is a colour map leaning on that texel being opaque.
17
+ *
18
+ * The levels are views over `bytes`, not copies: the file lives as long as the source does, which is
19
+ * until `createSurfaceTexture` has uploaded it.
20
+ */
21
+ import { levelBytes } from '@driftengine/core';
22
+ const IDENTIFIER = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a];
23
+ const ASTC_SIZES = [
24
+ '4x4',
25
+ '5x4',
26
+ '5x5',
27
+ '6x5',
28
+ '6x6',
29
+ '8x5',
30
+ '8x6',
31
+ '8x8',
32
+ '10x5',
33
+ '10x6',
34
+ '10x8',
35
+ '10x10',
36
+ '12x10',
37
+ '12x12',
38
+ ];
39
+ /** Each Vulkan block format this engine samples, as its own format and whether it is sRGB. */
40
+ const VK_FORMATS = new Map([
41
+ [131, ['bc1', false]],
42
+ [132, ['bc1', true]],
43
+ [133, ['bc1', false]],
44
+ [134, ['bc1', true]],
45
+ [135, ['bc2', false]],
46
+ [136, ['bc2', true]],
47
+ [137, ['bc3', false]],
48
+ [138, ['bc3', true]],
49
+ [139, ['bc4', false]],
50
+ [141, ['bc5', false]],
51
+ [145, ['bc7', false]],
52
+ [146, ['bc7', true]],
53
+ [147, ['etc2-rgb8', false]],
54
+ [148, ['etc2-rgb8', true]],
55
+ [149, ['etc2-rgb8a1', false]],
56
+ [150, ['etc2-rgb8a1', true]],
57
+ [151, ['etc2-rgba8', false]],
58
+ [152, ['etc2-rgba8', true]],
59
+ [153, ['eac-r11', false]],
60
+ [155, ['eac-rg11', false]],
61
+ ...ASTC_SIZES.flatMap((size, i) => [
62
+ [157 + i * 2, [`astc-${size}`, false]],
63
+ [158 + i * 2, [`astc-${size}`, true]],
64
+ ]),
65
+ ]);
66
+ /** The block formats a file may name that this engine does not sample, and why. */
67
+ const REFUSED = new Map([
68
+ [140, 'signed BC4'],
69
+ [142, 'signed BC5'],
70
+ [143, 'HDR BC6H'],
71
+ [144, 'signed HDR BC6H'],
72
+ [154, 'signed EAC R11'],
73
+ [156, 'signed EAC RG11'],
74
+ ]);
75
+ const SUPERCOMPRESSION = ['', 'BasisLZ', 'Zstandard', 'zlib'];
76
+ /** `bytes` as a KTX2 file of one 2D block texture. Throws, naming the reason, on anything else. */
77
+ export function readKtx2(bytes) {
78
+ if (bytes.length < 80 || IDENTIFIER.some((byte, i) => bytes[i] !== byte)) {
79
+ throw new Error('readKtx2: not a KTX2 file (its first twelve bytes are not the identifier)');
80
+ }
81
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
82
+ const u32 = (at) => view.getUint32(at, true);
83
+ const u64 = (at) => {
84
+ if (u32(at + 4) !== 0)
85
+ throw new Error('readKtx2: an offset past four gigabytes');
86
+ return u32(at);
87
+ };
88
+ const vkFormat = u32(12);
89
+ const width = u32(20);
90
+ const height = u32(24);
91
+ const depth = u32(28);
92
+ const layers = u32(32);
93
+ const faces = u32(36);
94
+ const levelCount = Math.max(1, u32(40));
95
+ const scheme = u32(44);
96
+ if (scheme !== 0) {
97
+ throw new Error(`readKtx2: the file is supercompressed with ${SUPERCOMPRESSION[scheme] ?? `scheme ${scheme}`}, ` +
98
+ 'which this engine does not undo; write it with supercompression off');
99
+ }
100
+ const known = VK_FORMATS.get(vkFormat);
101
+ if (known === undefined) {
102
+ const named = REFUSED.get(vkFormat);
103
+ throw new Error(named !== undefined
104
+ ? `readKtx2: ${named} is not a format this engine samples`
105
+ : `readKtx2: vkFormat ${vkFormat} is not a block format this engine samples`);
106
+ }
107
+ if (faces !== 1)
108
+ throw new Error(`readKtx2: a cube of ${faces} faces, not a 2D texture`);
109
+ if (layers > 1)
110
+ throw new Error(`readKtx2: an array of ${layers} layers, not a 2D texture`);
111
+ if (depth > 0)
112
+ throw new Error(`readKtx2: a volume ${depth} deep, not a 2D texture`);
113
+ if (width === 0 || height === 0)
114
+ throw new Error('readKtx2: a texture with no height is 1D');
115
+ const [format, srgb] = known;
116
+ const levels = [];
117
+ for (let level = 0; level < levelCount; level++) {
118
+ const entry = 80 + level * 24;
119
+ if (entry + 24 > bytes.length)
120
+ throw new Error('readKtx2: the level index runs past the file');
121
+ const offset = u64(entry);
122
+ const length = u64(entry + 8);
123
+ const expected = levelBytes(format, width, height, level);
124
+ if (length !== expected) {
125
+ throw new Error(`readKtx2: level ${level} of a ${width}x${height} ${format} texture is ${expected} bytes, ` +
126
+ `and the file gives it ${length}`);
127
+ }
128
+ if (offset + length > bytes.length) {
129
+ throw new Error(`readKtx2: level ${level} runs past the end of the file`);
130
+ }
131
+ levels.push(bytes.subarray(offset, offset + length));
132
+ }
133
+ return { source: { format, width, height, levels }, srgb };
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/assets",
3
- "version": "4.9.0",
3
+ "version": "4.10.0",
4
4
  "description": "Model readers and the streaming loader for the .drft container",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -32,11 +32,11 @@
32
32
  ],
33
33
  "sideEffects": false,
34
34
  "dependencies": {
35
- "@driftengine/drft": "4.9.0",
36
- "@driftengine/texture": "4.9.0"
35
+ "@driftengine/drft": "4.10.0",
36
+ "@driftengine/texture": "4.10.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "@driftengine/core": "4.9.0"
39
+ "@driftengine/core": "4.10.0"
40
40
  },
41
41
  "author": "Drift Technologies",
42
42
  "repository": {
package/src/bcLoad.ts CHANGED
@@ -20,6 +20,7 @@ import type { BcImage } from '@driftengine/drft';
20
20
 
21
21
  import { answerBcRequest } from './bcAnswer.ts';
22
22
  import type { BcReply, BcRequest } from './bcAnswer.ts';
23
+ import type { Etc2Request } from './etc2Answer.ts';
23
24
 
24
25
  export type { BcReply, BcRequest } from './bcAnswer.ts';
25
26
  export { answerBcRequest } from './bcAnswer.ts';
@@ -39,9 +40,12 @@ export function bcPlan(
39
40
  : 'decode';
40
41
  }
41
42
 
42
- /** The part of a worker the decoder uses. A browser `Worker` satisfies it. */
43
+ /**
44
+ * The part of a worker the decoder and the ETC2 encoder use (`etc2Load.ts`), each starting one of
45
+ * its own from the same factory. A browser `Worker` satisfies it.
46
+ */
43
47
  export interface BcWorker {
44
- postMessage(request: BcRequest, transfer?: Transferable[]): void;
48
+ postMessage(request: BcRequest | Etc2Request, transfer?: Transferable[]): void;
45
49
  terminate(): void;
46
50
  onmessage: ((event: { data: unknown }) => void) | null;
47
51
  onerror?: ((event: unknown) => void) | null;
@@ -97,7 +101,8 @@ export function createBcDecoder(spawn: (() => BcWorker) | null = null): BcDecode
97
101
  if (spawn === null) {
98
102
  reason =
99
103
  'BC textures decode on the main thread: no worker was named. Pass `bcWorker: spawnBcWorker`, ' +
100
- "exported by '@driftengine/assets/bcWorkers', to decode them off it";
104
+ "exported by '@driftengine/assets/bcWorkers', to decode them off it — and, on a device " +
105
+ 'that samples ETC2 and not BC, to re-encode them as ETC2 rather than hold them as RGBA';
101
106
  } else {
102
107
  try {
103
108
  worker = spawn();
package/src/bcWorker.ts CHANGED
@@ -1,20 +1,29 @@
1
1
  /**
2
- * The worker `bcLoad.ts` starts: decodes BC blocks off the main thread, one request at a time, and
3
- * hands each image back rather than copying it. Imports `bcAnswer.ts` alone, so its bundle is the
4
- * decoders and nothing else.
2
+ * The worker `bcLoad.ts` and `etc2Load.ts` each start one of: decodes BC blocks off the main
3
+ * thread, or re-encodes them as an ETC2 chain, one request at a time, and hands each answer back
4
+ * rather than copying it. Imports the two answers alone, so its bundle is the codecs and nothing
5
+ * else.
5
6
  *
6
7
  * No top-level await, for the reason `islandWorker.ts` gives: a consumer's bundler compiles every
7
8
  * `new Worker(new URL(…))` it finds, and an IIFE worker cannot carry one.
8
9
  */
9
10
  import { answerBcRequest } from './bcAnswer.ts';
10
11
  import type { BcRequest } from './bcAnswer.ts';
12
+ import { answerEtc2Request } from './etc2Answer.ts';
13
+ import type { Etc2Request } from './etc2Answer.ts';
11
14
 
12
15
  const scope = globalThis as unknown as {
13
- onmessage: ((event: { data: BcRequest }) => void) | null;
16
+ onmessage: ((event: { data: BcRequest | Etc2Request }) => void) | null;
14
17
  postMessage(reply: unknown, transfer: ArrayBuffer[]): void;
15
18
  };
16
19
 
17
20
  scope.onmessage = (event): void => {
18
- const reply = answerBcRequest(event.data);
21
+ const request = event.data;
22
+ if ('kind' in request && request.kind === 'etc2') {
23
+ const reply = answerEtc2Request(request);
24
+ scope.postMessage(reply, reply.levels ?? []);
25
+ return;
26
+ }
27
+ const reply = answerBcRequest(request as BcRequest);
19
28
  scope.postMessage(reply, reply.rgba === undefined ? [] : [reply.rgba]);
20
29
  };
package/src/bcWorkers.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /**
2
- * The BC decode worker, behind its own specifier, for the reason `physics/src/workers.ts` gives.
2
+ * The BC worker — the decode, and on a device that samples ETC2 and not BC the re-encode as ETC2
3
+ * (`etc2Load.ts`), each in a worker of its own from this factory — behind its own specifier, for
4
+ * the reason `physics/src/workers.ts` gives.
3
5
  *
4
6
  * **A bundler emits a worker before it decides the code reaching it is unreachable.** The loader's
5
7
  * decoder built its worker with `new Worker(new URL('./bcWorker.ts', import.meta.url))` inside the
@@ -30,7 +32,7 @@
30
32
  */
31
33
  import type { BcWorker } from './bcLoad.ts';
32
34
 
33
- /** A BC decode worker, as a bundler understands one. Throws where the runtime has no `Worker`. */
35
+ /** A BC worker, as a bundler understands one. Throws where the runtime has no `Worker`. */
34
36
  export function spawnBcWorker(): BcWorker {
35
37
  // platform: browser default — `DrftLoaderOptions.bcWorker` is the seam a host supplies
36
38
  return new Worker(new URL('./bcWorker.ts', import.meta.url), {
package/src/drftLoader.ts CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  import type { MeshData } from '@driftengine/drft';
4
4
  import type { MeshHandle, RendererApi } from '@driftengine/core';
5
- import type { GlassOptions, SurfaceTextureHandle } from '@driftengine/core';
5
+ import type {
6
+ CompressedTextureSource,
7
+ GlassOptions,
8
+ SurfaceTextureHandle,
9
+ } from '@driftengine/core';
6
10
  import { MeshBuilder, concatMeshes, placeMesh } from '@driftengine/core';
7
11
  import type { DrftMaterial } from '@driftengine/drft';
8
12
  import type { DrftSdfvEntry } from '@driftengine/drft';
@@ -11,6 +15,8 @@ import type { DrftFieldPlacement } from './fieldPlacement.ts';
11
15
  import type { DrftTexture } from '@driftengine/drft';
12
16
  import { TextureSet, textureColorSpaces } from './drftTextures.ts';
13
17
  import { bcPlan, createBcDecoder } from './bcLoad.ts';
18
+ import { createEtc2Encoder, etc2Plan } from './etc2Load.ts';
19
+ import type { Etc2Encoder } from './etc2Load.ts';
14
20
  import type { BcDecoder, BcWorker } from './bcLoad.ts';
15
21
  import { decodeBc } from './bcDecode.ts';
16
22
  import { isCompressedSource, uploadsCompressed } from '@driftengine/core';
@@ -87,6 +93,12 @@ export interface DrftLoaderOptions {
87
93
  * `spawnBcWorker`, from `@driftengine/assets/bcWorkers`. Absent, they decode on the main
88
94
  * thread and the loader says so once — the factory is behind its own specifier so a consumer that
89
95
  * never names it has no worker in its build. See `bcWorkers.ts`.
96
+ *
97
+ * **On a device that samples ETC2 and not BC**, which is a phone, a second worker from the same
98
+ * factory then re-encodes each such texture as ETC2 or EAC and swaps the chain in behind the
99
+ * handle its decoded image went up under, at half a byte a texel, or a byte with alpha, where the
100
+ * image holds four. Nothing arrives later for it; see `etc2Load.ts` for what it costs. Without a worker
101
+ * the textures stay RGBA.
90
102
  */
91
103
  readonly bcWorker?: () => BcWorker;
92
104
  /**
@@ -340,6 +352,15 @@ export class DrftLoader {
340
352
  /** Started at the first BC texture that has to be decoded, and only then. See `bcLoad.ts`. */
341
353
  private bcDecoder: BcDecoder | null = null;
342
354
  private warnedBc = false;
355
+ /** Started at the first BC texture this device takes better as ETC2. See `etc2Load.ts`. */
356
+ private etc2Encoder: Etc2Encoder | null = null;
357
+ private warnedEtc2 = false;
358
+ /** Encoded chains whose image has not gone up yet, swapped in by `update` once it has. */
359
+ private readonly swaps: {
360
+ readonly ordinal: number;
361
+ readonly blocks: CompressedTextureSource;
362
+ readonly source: BcImage;
363
+ }[] = [];
343
364
  private readonly chunks: DrftTexture[] = [];
344
365
  private materials: readonly DrftMaterial[] = [];
345
366
  /**
@@ -1008,6 +1029,18 @@ export class DrftLoader {
1008
1029
  begun++;
1009
1030
  }
1010
1031
 
1032
+ /* An encoded chain whose image has gone up since it arrived; one a frame, as images go. */
1033
+ if (this.swaps.length > 0 && mayBeginMore(begun, performance.now() - startedMs, msBudget)) {
1034
+ for (let at = 0; at < this.swaps.length; at++) {
1035
+ const swap = this.swaps[at] as (typeof this.swaps)[number];
1036
+ if (!this.swapIn(swap.ordinal, swap.blocks, swap.source)) continue;
1037
+ this.swaps.splice(at, 1);
1038
+ changed = true;
1039
+ begun++;
1040
+ break;
1041
+ }
1042
+ }
1043
+
1011
1044
  const revealSec = this.options.revealSec ?? DEFAULT_REVEAL_SEC;
1012
1045
  for (const part of this.revealed) {
1013
1046
  if (part.reveal >= 1) continue;
@@ -1069,6 +1102,9 @@ export class DrftLoader {
1069
1102
  this.regionStore.dispose();
1070
1103
  this.bcDecoder?.dispose();
1071
1104
  this.bcDecoder = null;
1105
+ this.etc2Encoder?.dispose();
1106
+ this.etc2Encoder = null;
1107
+ this.swaps.length = 0;
1072
1108
  /*
1073
1109
  * Everything this class ever made, each disposed once.
1074
1110
  *
@@ -1543,10 +1579,61 @@ export class DrftLoader {
1543
1579
  }
1544
1580
  const pixels = imageDataOf(rgba, image.width, image.height);
1545
1581
  done(asImage ? await createImageBitmap(pixels, AS_AUTHORED) : pixels);
1582
+ if (!asImage) this.encodeLater(image, ordinal, srgb);
1546
1583
  })
1547
1584
  .catch(failed);
1548
1585
  }
1549
1586
 
1587
+ /**
1588
+ * A BC texture just decoded for the picture, re-encoded as ETC2 or EAC where this device samples
1589
+ * that and not BC, and swapped in behind its handle when the chain comes back — at once where the
1590
+ * image has gone up, and from `update` where it is still queued. See `etc2Load.ts`.
1591
+ */
1592
+ private encodeLater(image: BcImage, ordinal: number, srgb: boolean): void {
1593
+ const spawn = this.options.bcWorker;
1594
+ if (spawn === undefined || !etc2Plan(image, srgb, this.renderer.compressedFormats)) return;
1595
+ if (this.etc2Encoder === null) this.etc2Encoder = createEtc2Encoder(spawn);
1596
+ const encoder = this.etc2Encoder;
1597
+ encoder
1598
+ .encode(image, srgb)
1599
+ .then((blocks) => {
1600
+ if (this.disposed) return;
1601
+ if (!this.swapIn(ordinal, blocks, image))
1602
+ this.swaps.push({ ordinal, blocks, source: image });
1603
+ })
1604
+ .catch((error: unknown) => {
1605
+ /* Said once: the texture stays the image it already is, which is a cost and not a fault. */
1606
+ if (this.disposed || this.warnedEtc2) return;
1607
+ this.warnedEtc2 = true;
1608
+ console.warn(
1609
+ `DrftLoader: ${encoder.reason !== '' ? encoder.reason : `image ${ordinal} did not re-encode as ETC2 and stays RGBA`}`,
1610
+ error,
1611
+ );
1612
+ });
1613
+ }
1614
+
1615
+ /**
1616
+ * An encoded chain into the texture its image went up as: false where that texture is not made
1617
+ * yet. **The decoded image is let go**, and the BC blocks it came from held in its place — a
1618
+ * view over the file the loader keeps anyway — so a texture made again from them later decodes
1619
+ * as it first did. A chain the texture refuses, where the slot came to read a colour space the
1620
+ * blocks have no twin in, leaves the image where it is.
1621
+ */
1622
+ private swapIn(ordinal: number, blocks: CompressedTextureSource, source: BcImage): boolean {
1623
+ const held = this.textureSet?.at(ordinal) ?? null;
1624
+ if (held === null) return false;
1625
+ try {
1626
+ this.renderer.updateSurfaceTexture(held, blocks);
1627
+ this.images[ordinal] = source;
1628
+ } catch (error) {
1629
+ if (!this.warnedEtc2) {
1630
+ this.warnedEtc2 = true;
1631
+ console.warn(`DrftLoader: image ${ordinal} stays RGBA`, error);
1632
+ }
1633
+ }
1634
+ return true;
1635
+ }
1636
+
1550
1637
  /**
1551
1638
  * What goes to `createSurfaceTexture`: the image as it is, unless it is blocks this device cannot
1552
1639
  * take in the colour space the slot reads — a material read after its texture arrived can change
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The encode worker's whole job, as a function: one BC texture's level 0 decoded as a GPU samples
3
+ * it and made into an ETC2 or EAC chain (`etc2Chain.ts`), or the reason it could not be. Its own
4
+ * module beside `bcAnswer.ts`, which the same worker answers decode requests with.
5
+ *
6
+ * **The blocks cross, not the pixels**: the loader has already decoded this texture once, for the
7
+ * picture it shows while this runs, and hands its blocks over again rather than a copy of that
8
+ * image — a quarter to an eighth of the bytes, decoded a second time here at about a fifth of the
9
+ * encode's cost. An error is answered rather than thrown, so one bad texture fails on its own.
10
+ */
11
+ import type { BcFormat } from '@driftengine/drft';
12
+
13
+ import { decodeBc } from './bcDecode.ts';
14
+ import { encodeEtc2Chain, etc2FormatFor } from './etc2Chain.ts';
15
+ import type { Etc2Format } from './etc2Encode.ts';
16
+
17
+ /** What the loader asks the encode worker: one level's BC blocks, and how the texture is read. */
18
+ export interface Etc2Request {
19
+ readonly kind: 'etc2';
20
+ readonly id: number;
21
+ readonly format: BcFormat;
22
+ readonly width: number;
23
+ readonly height: number;
24
+ readonly blocks: ArrayBuffer;
25
+ /** Whether the slot reads colour, so the chain is averaged in light. */
26
+ readonly srgb: boolean;
27
+ }
28
+
29
+ /** The answer: every level of the chain in one format, or why not. */
30
+ export interface Etc2Reply {
31
+ readonly id: number;
32
+ readonly format?: Etc2Format;
33
+ readonly levels?: ArrayBuffer[];
34
+ readonly error?: string;
35
+ }
36
+
37
+ export function answerEtc2Request(request: Etc2Request): Etc2Reply {
38
+ try {
39
+ const { width, height } = request;
40
+ const rgba = decodeBc(request.format, width, height, new Uint8Array(request.blocks));
41
+ const format = etc2FormatFor(request.format, rgba);
42
+ const colour = format === 'etc2-rgb8' || format === 'etc2-rgba8';
43
+ const levels = encodeEtc2Chain(format, rgba, width, height, colour && request.srgb);
44
+ return { id: request.id, format, levels: levels.map((level) => level.buffer as ArrayBuffer) };
45
+ } catch (error) {
46
+ return { id: request.id, error: error instanceof Error ? error.message : String(error) };
47
+ }
48
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * A decoded texture as ETC2 or EAC blocks with its whole chain, down to 1x1: what the loader's
3
+ * encode worker makes of a BC texture on a device that samples ETC2 and not BC.
4
+ *
5
+ * **The format follows what the BC format stores**: BC4's one channel as `eac-r11` and BC5's two as
6
+ * `eac-rg11`, so a roughness or normal map keeps eleven bits a channel where colour would round it
7
+ * to a brightness step; every colour format as `etc2-rgb8`, or `etc2-rgba8` where any texel is less
8
+ * than opaque. **Opaque is 254 and above**, because a BC7 encoder storing an opaque image in its
9
+ * mode with a seven-bit alpha and a shared low bit can land every texel on 254, and that is no
10
+ * reason to spend twice the memory: what it gives up is an alpha of 254 read as 255, a 0.4% change
11
+ * no blend or cutout can show. **And what the choice gives up** is `etc2-rgb8a1`, half the size of
12
+ * `etc2-rgba8` for a cutout whose alpha is only ever 0 or 255, which needs ETC2's punch-through
13
+ * modes and this encoder writes none of them.
14
+ *
15
+ * **Each level is a box average of the one above, colour in linear light where the texture is
16
+ * colour**, as a GPU builds a chain for an sRGB image: averaged as stored, a fine bright detail on
17
+ * a dark ground fades darker with distance than it should. Alpha and data are averaged as stored.
18
+ * Allocates every level, once; this runs in a worker, once a texture.
19
+ */
20
+ import type { BcFormat } from '@driftengine/drft';
21
+
22
+ import { encodeEtc2 } from './etc2Encode.ts';
23
+ import type { Etc2Format } from './etc2Encode.ts';
24
+
25
+ /** The ETC2 or EAC format a BC texture decoded to `rgba` becomes. */
26
+ export function etc2FormatFor(source: BcFormat, rgba: Uint8Array): Etc2Format {
27
+ if (source === 'bc4') return 'eac-r11';
28
+ if (source === 'bc5') return 'eac-rg11';
29
+ for (let at = 3; at < rgba.length; at += 4) {
30
+ if ((rgba[at] as number) < 254) return 'etc2-rgba8';
31
+ }
32
+ return 'etc2-rgb8';
33
+ }
34
+
35
+ /** `rgba` at `width` by `height`, and each level below it to 1x1, as `format` blocks. */
36
+ export function encodeEtc2Chain(
37
+ format: Etc2Format,
38
+ rgba: Uint8Array,
39
+ width: number,
40
+ height: number,
41
+ srgb: boolean,
42
+ ): Uint8Array[] {
43
+ const levels: Uint8Array[] = [];
44
+ let level = rgba;
45
+ let w = width;
46
+ let h = height;
47
+ for (;;) {
48
+ levels.push(encodeEtc2(format, level, w, h));
49
+ if (w === 1 && h === 1) return levels;
50
+ level = halve(level, w, h, srgb);
51
+ w = Math.max(1, w >> 1);
52
+ h = Math.max(1, h >> 1);
53
+ }
54
+ }
55
+
56
+ /** Each eight-bit sRGB value in linear light. */
57
+ const LINEAR = new Float64Array(256);
58
+ for (let v = 0; v < 256; v++) {
59
+ const c = v / 255;
60
+ LINEAR[v] = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
61
+ }
62
+
63
+ /** The eight-bit sRGB value whose light is nearest `light`, found in `LINEAR` and not by a power. */
64
+ function fromLinear(light: number): number {
65
+ let lo = 0;
66
+ let hi = 255;
67
+ while (hi - lo > 1) {
68
+ const mid = (lo + hi) >> 1;
69
+ if ((LINEAR[mid] as number) <= light) lo = mid;
70
+ else hi = mid;
71
+ }
72
+ return light - (LINEAR[lo] as number) <= (LINEAR[hi] as number) - light ? lo : hi;
73
+ }
74
+
75
+ /**
76
+ * The next level down: each texel the average of the two by two above it, or of the one or two an
77
+ * odd edge leaves. Colour in linear light where `srgb`; alpha, and every channel of data, as stored.
78
+ */
79
+ export function halve(rgba: Uint8Array, width: number, height: number, srgb: boolean): Uint8Array {
80
+ const w = Math.max(1, width >> 1);
81
+ const h = Math.max(1, height >> 1);
82
+ const out = new Uint8Array(w * h * 4);
83
+ for (let y = 0; y < h; y++) {
84
+ const y0 = Math.min(y * 2, height - 1);
85
+ const y1 = Math.min(y * 2 + 1, height - 1);
86
+ for (let x = 0; x < w; x++) {
87
+ const x0 = Math.min(x * 2, width - 1);
88
+ const x1 = Math.min(x * 2 + 1, width - 1);
89
+ const a = (y0 * width + x0) * 4;
90
+ const b = (y0 * width + x1) * 4;
91
+ const c = (y1 * width + x0) * 4;
92
+ const d = (y1 * width + x1) * 4;
93
+ const to = (y * w + x) * 4;
94
+ for (let channel = 0; channel < 4; channel++) {
95
+ const pa = rgba[a + channel] as number;
96
+ const pb = rgba[b + channel] as number;
97
+ const pc = rgba[c + channel] as number;
98
+ const pd = rgba[d + channel] as number;
99
+ out[to + channel] =
100
+ srgb && channel < 3
101
+ ? fromLinear(
102
+ ((LINEAR[pa] as number) +
103
+ (LINEAR[pb] as number) +
104
+ (LINEAR[pc] as number) +
105
+ (LINEAR[pd] as number)) /
106
+ 4,
107
+ )
108
+ : (pa + pb + pc + pd + 2) >> 2;
109
+ }
110
+ }
111
+ }
112
+ return out;
113
+ }