@driftengine/assets 4.9.0 → 4.11.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 +6 -2
- package/dist/bcLoad.js +2 -1
- package/dist/bcWorker.js +12 -4
- package/dist/bcWorkers.d.ts +4 -2
- package/dist/bcWorkers.js +1 -1
- package/dist/drftLoader.d.ts +25 -0
- 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 +4 -4
- package/src/bcLoad.ts +8 -3
- package/src/bcWorker.ts +14 -5
- package/src/bcWorkers.ts +4 -2
- package/src/drftLoader.ts +88 -1
- 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/src/etc2Load.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BC textures re-encoded as ETC2 or EAC for a device that samples those and not BC — which is a
|
|
3
|
+
* phone — and the worker that does it.
|
|
4
|
+
*
|
|
5
|
+
* **After the picture, not before it.** Such a texture is decoded and shown as RGBA first, exactly
|
|
6
|
+
* as it was before this existed (`bcLoad.ts`), and its blocks then go to an encode worker of their
|
|
7
|
+
* own; the chain that comes back replaces the image behind the handle every draw already holds
|
|
8
|
+
* (`updateSurfaceTexture`). So nothing arrives later than it did, and the texture ends at half a
|
|
9
|
+
* byte a texel, or a byte with alpha, where it held four. **What it costs** is the encode, on a core
|
|
10
|
+
* of its own: measured at 0.7 s for a 2048² photograph on a desktop processor, and several times
|
|
11
|
+
* that on a phone's, one texture at a time. **What it gives up** is the memory a load peaks at,
|
|
12
|
+
* which is still every texture as RGBA until the encodes catch up: what would make that wrong is a
|
|
13
|
+
* device that cannot hold the RGBA at all, and the answer then is blocks it samples in the file,
|
|
14
|
+
* which a KTX2 texture carries through as they are.
|
|
15
|
+
*
|
|
16
|
+
* **A worker of its own, not the decode worker**, so a texture waiting to be decoded never waits
|
|
17
|
+
* behind a second of somebody else's encode; and one job in flight at a time, so a model's hundred
|
|
18
|
+
* textures are a hundred small messages queued here rather than a hundred copies of their blocks
|
|
19
|
+
* queued there. **Never on the main thread**: where no worker was named, or the one started fails,
|
|
20
|
+
* the textures stay RGBA and the loader says why once — a texture held at four bytes a texel is
|
|
21
|
+
* the state this improves on, never a stall.
|
|
22
|
+
*/
|
|
23
|
+
import { uploadsCompressed } from '@driftengine/core';
|
|
24
|
+
import type { CompressedTextureFormat, CompressedTextureSource } from '@driftengine/core';
|
|
25
|
+
import type { BcImage } from '@driftengine/drft';
|
|
26
|
+
|
|
27
|
+
import type { BcWorker } from './bcLoad.ts';
|
|
28
|
+
import type { Etc2Reply, Etc2Request } from './etc2Answer.ts';
|
|
29
|
+
|
|
30
|
+
export type { Etc2Reply, Etc2Request } from './etc2Answer.ts';
|
|
31
|
+
export { answerEtc2Request } from './etc2Answer.ts';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether `image` can go up as ETC2 or EAC on a device offering `available`, read in the colour
|
|
35
|
+
* space the slot reads: every format it could become, since which colour format is decided by the
|
|
36
|
+
* decoded alpha. EAC has no sRGB twin, so a BC4 or BC5 texture read as colour stays an image.
|
|
37
|
+
*/
|
|
38
|
+
export function etc2Plan(
|
|
39
|
+
image: BcImage,
|
|
40
|
+
srgb: boolean,
|
|
41
|
+
available: readonly CompressedTextureFormat[],
|
|
42
|
+
): boolean {
|
|
43
|
+
const { format, width, height } = image;
|
|
44
|
+
if (format === 'bc4') return uploadsCompressed('eac-r11', srgb, width, height, available);
|
|
45
|
+
if (format === 'bc5') return uploadsCompressed('eac-rg11', srgb, width, height, available);
|
|
46
|
+
return (
|
|
47
|
+
uploadsCompressed('etc2-rgb8', srgb, width, height, available) &&
|
|
48
|
+
uploadsCompressed('etc2-rgba8', srgb, width, height, available)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** BC textures to ETC2 or EAC chains, one at a time, on a worker. */
|
|
53
|
+
export interface Etc2Encoder {
|
|
54
|
+
encode(image: BcImage, srgb: boolean): Promise<CompressedTextureSource>;
|
|
55
|
+
/** Empty while the worker lives; otherwise why the textures stay RGBA. */
|
|
56
|
+
readonly reason: string;
|
|
57
|
+
dispose(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Job {
|
|
61
|
+
readonly id: number;
|
|
62
|
+
readonly image: BcImage;
|
|
63
|
+
readonly srgb: boolean;
|
|
64
|
+
resolve(source: CompressedTextureSource): void;
|
|
65
|
+
reject(error: Error): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An encoder over a worker `spawn` starts: the same factory the decoder is given. */
|
|
69
|
+
export function createEtc2Encoder(spawn: () => BcWorker): Etc2Encoder {
|
|
70
|
+
let worker: BcWorker | null = null;
|
|
71
|
+
let reason = '';
|
|
72
|
+
let running: Job | null = null;
|
|
73
|
+
const waiting: Job[] = [];
|
|
74
|
+
let next = 0;
|
|
75
|
+
|
|
76
|
+
const stop = (why: string): void => {
|
|
77
|
+
reason = why;
|
|
78
|
+
worker?.terminate();
|
|
79
|
+
worker = null;
|
|
80
|
+
const error = new Error(why);
|
|
81
|
+
running?.reject(error);
|
|
82
|
+
running = null;
|
|
83
|
+
for (const job of waiting) job.reject(error);
|
|
84
|
+
waiting.length = 0;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const pump = (): void => {
|
|
88
|
+
const target = worker;
|
|
89
|
+
if (running !== null || target === null) return;
|
|
90
|
+
const job = waiting.shift();
|
|
91
|
+
if (job === undefined) return;
|
|
92
|
+
running = job;
|
|
93
|
+
const { image } = job;
|
|
94
|
+
/* A copy, for the reason `bcLoad.ts` gives: a level is a view over the fetched file. */
|
|
95
|
+
const blocks = (image.levels[0] as Uint8Array).slice().buffer;
|
|
96
|
+
const request: Etc2Request = {
|
|
97
|
+
kind: 'etc2',
|
|
98
|
+
id: job.id,
|
|
99
|
+
format: image.format,
|
|
100
|
+
width: image.width,
|
|
101
|
+
height: image.height,
|
|
102
|
+
blocks,
|
|
103
|
+
srgb: job.srgb,
|
|
104
|
+
};
|
|
105
|
+
target.postMessage(request, [blocks]);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const started = spawn();
|
|
110
|
+
worker = started;
|
|
111
|
+
started.onmessage = (event): void => {
|
|
112
|
+
const reply = event.data as Etc2Reply;
|
|
113
|
+
const job = running;
|
|
114
|
+
if (job === null || reply.id !== job.id) return;
|
|
115
|
+
running = null;
|
|
116
|
+
if (reply.format !== undefined && reply.levels !== undefined) {
|
|
117
|
+
const levels = reply.levels.map((level) => new Uint8Array(level));
|
|
118
|
+
const { width, height } = job.image;
|
|
119
|
+
job.resolve({ format: reply.format, width, height, levels });
|
|
120
|
+
} else {
|
|
121
|
+
job.reject(new Error(reply.error ?? 'etc2: the encode answered nothing'));
|
|
122
|
+
}
|
|
123
|
+
pump();
|
|
124
|
+
};
|
|
125
|
+
started.onerror = (event): void => {
|
|
126
|
+
const message = (event as { message?: unknown } | null)?.message;
|
|
127
|
+
stop(
|
|
128
|
+
`the ETC2 worker failed (${typeof message === 'string' ? message : 'no message'}), so ` +
|
|
129
|
+
'BC textures stay RGBA on this device',
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
stop(
|
|
134
|
+
`the ETC2 worker would not start (${error instanceof Error ? error.message : String(error)})` +
|
|
135
|
+
', so BC textures stay RGBA on this device',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
get reason(): string {
|
|
141
|
+
return reason;
|
|
142
|
+
},
|
|
143
|
+
encode(image: BcImage, srgb: boolean): Promise<CompressedTextureSource> {
|
|
144
|
+
if (worker === null) return Promise.reject(new Error(reason));
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
waiting.push({ id: next++, image, srgb, resolve, reject });
|
|
147
|
+
pump();
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
dispose(): void {
|
|
151
|
+
stop('etc2: the encoder was disposed');
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -245,6 +245,17 @@ export { ddsBlocks } from './ddsBlocks.ts';
|
|
|
245
245
|
export { decodeBc } from './bcDecode.ts';
|
|
246
246
|
export { decodeBcImage } from './bcImage.ts';
|
|
247
247
|
export { flipBc5Green } from './bcFlip.ts';
|
|
248
|
+
/*
|
|
249
|
+
* Blocks a phone samples. `readKtx2` takes a texture compressed offline — ASTC, ETC2 or BC — as the
|
|
250
|
+
* blocks it carries, for `createSurfaceTexture`; `encodeEtc2Chain` makes ETC2 or EAC blocks and
|
|
251
|
+
* their chain from pixels a caller holds, which is what the loader's worker does with a BC texture
|
|
252
|
+
* on a device that samples ETC2 and not BC. A 2048² image is 262,144 blocks: call it in a worker.
|
|
253
|
+
*/
|
|
254
|
+
export { readKtx2 } from './ktx2.ts';
|
|
255
|
+
export type { Ktx2Texture } from './ktx2.ts';
|
|
256
|
+
export { encodeEtc2 } from './etc2Encode.ts';
|
|
257
|
+
export type { Etc2Format } from './etc2Encode.ts';
|
|
258
|
+
export { encodeEtc2Chain } from './etc2Chain.ts';
|
|
248
259
|
/*
|
|
249
260
|
* **The offline half of the GPU-driven pipeline, which nothing outside this package could reach.**
|
|
250
261
|
*
|
package/src/ktx2.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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
|
+
import type { BlockFormat, CompressedTextureSource } from '@driftengine/core';
|
|
23
|
+
|
|
24
|
+
/** A KTX2 file's blocks, and the colour space its format names. */
|
|
25
|
+
export interface Ktx2Texture {
|
|
26
|
+
/** The blocks, for `createSurfaceTexture` where `uploadsCompressed` says the device takes them. */
|
|
27
|
+
readonly source: CompressedTextureSource;
|
|
28
|
+
/** Whether the file's format is an sRGB one: the `colorSpace` to create the texture in. */
|
|
29
|
+
readonly srgb: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const IDENTIFIER = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
33
|
+
|
|
34
|
+
const ASTC_SIZES = [
|
|
35
|
+
'4x4',
|
|
36
|
+
'5x4',
|
|
37
|
+
'5x5',
|
|
38
|
+
'6x5',
|
|
39
|
+
'6x6',
|
|
40
|
+
'8x5',
|
|
41
|
+
'8x6',
|
|
42
|
+
'8x8',
|
|
43
|
+
'10x5',
|
|
44
|
+
'10x6',
|
|
45
|
+
'10x8',
|
|
46
|
+
'10x10',
|
|
47
|
+
'12x10',
|
|
48
|
+
'12x12',
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/** Each Vulkan block format this engine samples, as its own format and whether it is sRGB. */
|
|
52
|
+
const VK_FORMATS = new Map<number, readonly [BlockFormat, boolean]>([
|
|
53
|
+
[131, ['bc1', false]],
|
|
54
|
+
[132, ['bc1', true]],
|
|
55
|
+
[133, ['bc1', false]],
|
|
56
|
+
[134, ['bc1', true]],
|
|
57
|
+
[135, ['bc2', false]],
|
|
58
|
+
[136, ['bc2', true]],
|
|
59
|
+
[137, ['bc3', false]],
|
|
60
|
+
[138, ['bc3', true]],
|
|
61
|
+
[139, ['bc4', false]],
|
|
62
|
+
[141, ['bc5', false]],
|
|
63
|
+
[145, ['bc7', false]],
|
|
64
|
+
[146, ['bc7', true]],
|
|
65
|
+
[147, ['etc2-rgb8', false]],
|
|
66
|
+
[148, ['etc2-rgb8', true]],
|
|
67
|
+
[149, ['etc2-rgb8a1', false]],
|
|
68
|
+
[150, ['etc2-rgb8a1', true]],
|
|
69
|
+
[151, ['etc2-rgba8', false]],
|
|
70
|
+
[152, ['etc2-rgba8', true]],
|
|
71
|
+
[153, ['eac-r11', false]],
|
|
72
|
+
[155, ['eac-rg11', false]],
|
|
73
|
+
...ASTC_SIZES.flatMap((size, i): [number, readonly [BlockFormat, boolean]][] => [
|
|
74
|
+
[157 + i * 2, [`astc-${size}`, false]],
|
|
75
|
+
[158 + i * 2, [`astc-${size}`, true]],
|
|
76
|
+
]),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/** The block formats a file may name that this engine does not sample, and why. */
|
|
80
|
+
const REFUSED: ReadonlyMap<number, string> = new Map([
|
|
81
|
+
[140, 'signed BC4'],
|
|
82
|
+
[142, 'signed BC5'],
|
|
83
|
+
[143, 'HDR BC6H'],
|
|
84
|
+
[144, 'signed HDR BC6H'],
|
|
85
|
+
[154, 'signed EAC R11'],
|
|
86
|
+
[156, 'signed EAC RG11'],
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const SUPERCOMPRESSION = ['', 'BasisLZ', 'Zstandard', 'zlib'];
|
|
90
|
+
|
|
91
|
+
/** `bytes` as a KTX2 file of one 2D block texture. Throws, naming the reason, on anything else. */
|
|
92
|
+
export function readKtx2(bytes: Uint8Array): Ktx2Texture {
|
|
93
|
+
if (bytes.length < 80 || IDENTIFIER.some((byte, i) => bytes[i] !== byte)) {
|
|
94
|
+
throw new Error('readKtx2: not a KTX2 file (its first twelve bytes are not the identifier)');
|
|
95
|
+
}
|
|
96
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
97
|
+
const u32 = (at: number): number => view.getUint32(at, true);
|
|
98
|
+
const u64 = (at: number): number => {
|
|
99
|
+
if (u32(at + 4) !== 0) throw new Error('readKtx2: an offset past four gigabytes');
|
|
100
|
+
return u32(at);
|
|
101
|
+
};
|
|
102
|
+
const vkFormat = u32(12);
|
|
103
|
+
const width = u32(20);
|
|
104
|
+
const height = u32(24);
|
|
105
|
+
const depth = u32(28);
|
|
106
|
+
const layers = u32(32);
|
|
107
|
+
const faces = u32(36);
|
|
108
|
+
const levelCount = Math.max(1, u32(40));
|
|
109
|
+
const scheme = u32(44);
|
|
110
|
+
|
|
111
|
+
if (scheme !== 0) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`readKtx2: the file is supercompressed with ${SUPERCOMPRESSION[scheme] ?? `scheme ${scheme}`}, ` +
|
|
114
|
+
'which this engine does not undo; write it with supercompression off',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const known = VK_FORMATS.get(vkFormat);
|
|
118
|
+
if (known === undefined) {
|
|
119
|
+
const named = REFUSED.get(vkFormat);
|
|
120
|
+
throw new Error(
|
|
121
|
+
named !== undefined
|
|
122
|
+
? `readKtx2: ${named} is not a format this engine samples`
|
|
123
|
+
: `readKtx2: vkFormat ${vkFormat} is not a block format this engine samples`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (faces !== 1) throw new Error(`readKtx2: a cube of ${faces} faces, not a 2D texture`);
|
|
127
|
+
if (layers > 1) throw new Error(`readKtx2: an array of ${layers} layers, not a 2D texture`);
|
|
128
|
+
if (depth > 0) throw new Error(`readKtx2: a volume ${depth} deep, not a 2D texture`);
|
|
129
|
+
if (width === 0 || height === 0) throw new Error('readKtx2: a texture with no height is 1D');
|
|
130
|
+
|
|
131
|
+
const [format, srgb] = known;
|
|
132
|
+
const levels: Uint8Array[] = [];
|
|
133
|
+
for (let level = 0; level < levelCount; level++) {
|
|
134
|
+
const entry = 80 + level * 24;
|
|
135
|
+
if (entry + 24 > bytes.length) throw new Error('readKtx2: the level index runs past the file');
|
|
136
|
+
const offset = u64(entry);
|
|
137
|
+
const length = u64(entry + 8);
|
|
138
|
+
const expected = levelBytes(format, width, height, level);
|
|
139
|
+
if (length !== expected) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`readKtx2: level ${level} of a ${width}x${height} ${format} texture is ${expected} bytes, ` +
|
|
142
|
+
`and the file gives it ${length}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (offset + length > bytes.length) {
|
|
146
|
+
throw new Error(`readKtx2: level ${level} runs past the end of the file`);
|
|
147
|
+
}
|
|
148
|
+
levels.push(bytes.subarray(offset, offset + length));
|
|
149
|
+
}
|
|
150
|
+
return { source: { format, width, height, levels }, srgb };
|
|
151
|
+
}
|