@driftengine/assets 4.8.7 → 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/README.md +22 -4
- package/dist/bcLoad.d.ts +8 -3
- package/dist/bcLoad.js +4 -2
- package/dist/bcWorker.js +12 -4
- package/dist/bcWorkers.d.ts +11 -3
- package/dist/bcWorkers.js +1 -1
- package/dist/drftLoader.d.ts +26 -1
- package/dist/drftLoader.js +74 -0
- package/dist/etc2Answer.d.ts +31 -0
- package/dist/etc2Answer.js +15 -0
- package/dist/etc2Chain.d.ts +30 -0
- package/dist/etc2Chain.js +84 -0
- package/dist/etc2Encode.d.ts +33 -0
- package/dist/etc2Encode.js +531 -0
- package/dist/etc2Load.d.ts +20 -0
- package/dist/etc2Load.js +124 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -0
- package/dist/ktx2.d.ts +10 -0
- package/dist/ktx2.js +134 -0
- package/package.json +9 -4
- package/src/bcLoad.ts +10 -4
- package/src/bcWorker.ts +14 -5
- package/src/bcWorkers.ts +11 -3
- package/src/drftLoader.ts +89 -2
- package/src/etc2Answer.ts +48 -0
- package/src/etc2Chain.ts +113 -0
- package/src/etc2Encode.ts +566 -0
- package/src/etc2Load.ts +154 -0
- package/src/index.ts +11 -0
- package/src/ktx2.ts +151 -0
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.
|
|
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",
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"types": "./dist/index.d.ts",
|
|
13
13
|
"default": "./dist/index.js"
|
|
14
14
|
},
|
|
15
|
+
"./bcWorkers": {
|
|
16
|
+
"drift-source": "./src/bcWorkers.ts",
|
|
17
|
+
"types": "./dist/bcWorkers.d.ts",
|
|
18
|
+
"default": "./dist/bcWorkers.js"
|
|
19
|
+
},
|
|
15
20
|
"./package.json": "./package.json",
|
|
16
21
|
"./*": "./*"
|
|
17
22
|
},
|
|
@@ -27,11 +32,11 @@
|
|
|
27
32
|
],
|
|
28
33
|
"sideEffects": false,
|
|
29
34
|
"dependencies": {
|
|
30
|
-
"@driftengine/drft": "4.
|
|
31
|
-
"@driftengine/texture": "4.
|
|
35
|
+
"@driftengine/drft": "4.10.0",
|
|
36
|
+
"@driftengine/texture": "4.10.0"
|
|
32
37
|
},
|
|
33
38
|
"peerDependencies": {
|
|
34
|
-
"@driftengine/core": "4.
|
|
39
|
+
"@driftengine/core": "4.10.0"
|
|
35
40
|
},
|
|
36
41
|
"author": "Drift Technologies",
|
|
37
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
|
-
/**
|
|
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;
|
|
@@ -76,7 +80,8 @@ function settle(reply: BcReply, pending: Pending): void {
|
|
|
76
80
|
|
|
77
81
|
/**
|
|
78
82
|
* A decoder over a worker `spawn` starts, or on the main thread where it is null — the default, since
|
|
79
|
-
* the worker's factory lives behind its own specifier (
|
|
83
|
+
* the worker's factory lives behind its own specifier (`@driftengine/assets/bcWorkers`) and a caller
|
|
84
|
+
* names it.
|
|
80
85
|
*/
|
|
81
86
|
export function createBcDecoder(spawn: (() => BcWorker) | null = null): BcDecoder {
|
|
82
87
|
let worker: BcWorker | null = null;
|
|
@@ -96,7 +101,8 @@ export function createBcDecoder(spawn: (() => BcWorker) | null = null): BcDecode
|
|
|
96
101
|
if (spawn === null) {
|
|
97
102
|
reason =
|
|
98
103
|
'BC textures decode on the main thread: no worker was named. Pass `bcWorker: spawnBcWorker`, ' +
|
|
99
|
-
|
|
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';
|
|
100
106
|
} else {
|
|
101
107
|
try {
|
|
102
108
|
worker = spawn();
|
package/src/bcWorker.ts
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The worker `bcLoad.ts`
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
|
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
|
|
@@ -13,18 +15,24 @@
|
|
|
13
15
|
*
|
|
14
16
|
* ```ts
|
|
15
17
|
* import { DrftLoader } from '@driftengine/assets';
|
|
16
|
-
* import { spawnBcWorker } from '@driftengine/assets/
|
|
18
|
+
* import { spawnBcWorker } from '@driftengine/assets/bcWorkers';
|
|
17
19
|
*
|
|
18
20
|
* const loader = new DrftLoader(renderer, { bcWorker: spawnBcWorker });
|
|
19
21
|
* ```
|
|
20
22
|
*
|
|
23
|
+
* **A declared subpath, so it resolves the way the barrel does**: to the build by default and to
|
|
24
|
+
* this file under `drift-source`. The path into `src/` that 4.8.7 documented still resolves, through
|
|
25
|
+
* the manifest's `./*` passthrough, and hands every consumer TypeScript inside `node_modules`, which
|
|
26
|
+
* is the default the 2026-09-13 flip took away from the barrels; a resolver that maps a package's
|
|
27
|
+
* subpaths into `src/` also doubled it, to `src/src/bcWorkers.ts`.
|
|
28
|
+
*
|
|
21
29
|
* Without it BC textures decode on the main thread, and the loader says so once. **What that costs**
|
|
22
30
|
* is the decode of a texture the device cannot take as blocks, which on a phone is every BC texture:
|
|
23
31
|
* a 2048² BC7 image is 262,144 blocks.
|
|
24
32
|
*/
|
|
25
33
|
import type { BcWorker } from './bcLoad.ts';
|
|
26
34
|
|
|
27
|
-
/** A BC
|
|
35
|
+
/** A BC worker, as a bundler understands one. Throws where the runtime has no `Worker`. */
|
|
28
36
|
export function spawnBcWorker(): BcWorker {
|
|
29
37
|
// platform: browser default — `DrftLoaderOptions.bcWorker` is the seam a host supplies
|
|
30
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 {
|
|
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';
|
|
@@ -84,9 +90,15 @@ export interface DrftLoaderOptions {
|
|
|
84
90
|
readonly fetchImpl?: FetchLike;
|
|
85
91
|
/**
|
|
86
92
|
* What starts the worker a BC texture the device cannot take as blocks is decoded in:
|
|
87
|
-
* `spawnBcWorker`, from `@driftengine/assets/
|
|
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
|
+
}
|
package/src/etc2Chain.ts
ADDED
|
@@ -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
|
+
}
|