@ringozz/godot 4.7.2-654 → 4.7.2-659
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 +2 -2
- package/package.json +4 -4
- package/src/dump-deps.gd.uid +1 -0
- package/src/pack.ts +29 -8
- package/src/planRefs.ts +209 -0
- package/src/web-image.ts +388 -388
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ await import('./app.ts'); // may now import @ringozz/godot / react-godot freely
|
|
|
53
53
|
| `@ringozz/godot/runtime` | Low-level dispatch: `_C`, `_S`, `_R`, `_G`, `_P`, `_V`, `toValueType`, `getGodot`, `GodotVar` |
|
|
54
54
|
| `@ringozz/godot/boot` | Platform boot: `getNativeModule()`; web `preloadGodot()` (awaited before importing the app; no-op on desktop) |
|
|
55
55
|
| `@ringozz/godot/load` | Async resource loading: `loadResourceAsync`, `loadAsset`, `loadAssetPack` (native `.pck` packs) |
|
|
56
|
-
| `@ringozz/godot/pack` | `buildAssetPacks()` + the `godot pack` subcommand: bake a project's `.pck`(s) + loose-texture manifest, one pack per scene |
|
|
56
|
+
| `@ringozz/godot/pack` | `buildAssetPacks()` + the `godot pack` subcommand: bake a project's `.pck`(s) + loose-texture manifest, one pack per level (its scene, or the `.gltfx` its plan lives in) |
|
|
57
57
|
| `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `snapshot`, `inspect`, `click`, `find`, `gc` |
|
|
58
58
|
|
|
59
59
|
Class-scoped enums use bare names (import `ProcessMode` from `@ringozz/godot/Node`, not `NodeProcessMode`).
|
|
@@ -97,7 +97,7 @@ const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); //
|
|
|
97
97
|
|
|
98
98
|
`godot pack` (the `godot` bin, `@ringozz/godot/pack`) bakes a project's assets for the runtime: it imports them, asks `dump-deps.gd` for each source's packed closure and its loose (WebP-backed) textures, and exports native `.pck`s — the engine-converted binary resources plus a remap table — through the project's committed `export_presets.cfg`. `loadAssetPack(url)` fetches a pack into MEMFS and hands it to `ProjectSettings.loadResourcePack`; packs are add-only, so several can be mounted and `res://` resolves across all of them.
|
|
99
99
|
|
|
100
|
-
The bake always splits: **one pack per
|
|
100
|
+
The bake always splits: **one pack per level, plus a shared pack**, so a host fetches the shared data and only the level it is playing. A level is its scene — or, in a project that builds levels from their plans at run time, the `.gltfx` its plan lives in; a project with neither under its roots gets a single `shared.pck`. What the runtime reads is `.godot/packs/packs.json` beside the packs: which packs exist, which sources each owns, and which loose sources the host must stage per file (the WebP-backed textures are transcoded; a plan's plain header JSONs are just written). The preload plugin mounts the right packs per asset, so importing a level pulls in the shared pack and that level's, nothing else. How the split is decided — and why it needs no configuration — is in `AGENTS.md`.
|
|
101
101
|
|
|
102
102
|
```ts
|
|
103
103
|
import { loadAssetPack } from '@ringozz/godot/load';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ringozz/godot",
|
|
3
3
|
"author": "Vladimir Davidovich",
|
|
4
|
-
"version": "4.7.2-
|
|
4
|
+
"version": "4.7.2-659",
|
|
5
5
|
"description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"precision": "single"
|
|
67
67
|
},
|
|
68
68
|
"optionalDependencies": {
|
|
69
|
-
"@ringozz/godot-macos-arm64": "^4.7.2-
|
|
70
|
-
"@ringozz/godot-windows-x86_64": "^4.7.2-
|
|
71
|
-
"@ringozz/godot-web-wasm32": "^4.7.2-
|
|
69
|
+
"@ringozz/godot-macos-arm64": "^4.7.2-659",
|
|
70
|
+
"@ringozz/godot-windows-x86_64": "^4.7.2-659",
|
|
71
|
+
"@ringozz/godot-web-wasm32": "^4.7.2-659"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"@types/bun": "*"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://dfbmw3t660p7v
|
package/src/pack.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
|
|
|
7
7
|
import { dirname, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { resolveEditor } from './editor.ts';
|
|
10
|
+
import { mergePlanRefs, planRefs } from './planRefs.ts';
|
|
10
11
|
|
|
11
12
|
// Bakes a Godot project's assets for the runtime: imports them, then exports one native
|
|
12
13
|
// `.pck` **per scene** plus a shared one, so a host fetches the shared data and only the
|
|
@@ -84,27 +85,43 @@ interface Dump {
|
|
|
84
85
|
* reach, or none does, is shared**. A scene whose own pack would be empty is dropped: every
|
|
85
86
|
* source it reaches is reached by another scene too, so it is already in `shared`, and a pack
|
|
86
87
|
* is mounted by *owner*.
|
|
88
|
+
*
|
|
89
|
+
* The graph it reads is the dump's *plus* every level's plan refs (`mergePlanRefs`), so a level's
|
|
90
|
+
* terrain, its layered containers and their layers, and its ground/environment textures are part of
|
|
91
|
+
* its closure whether or not a baked scene names them. When a project stops baking one scene per
|
|
92
|
+
* level — a level built from its plan at run time is a single shared scene plus the plan — the level
|
|
93
|
+
* unit becomes the `.gltfx` its plan lives in instead; until then both units exist and this keying
|
|
94
|
+
* keeps the scenes.
|
|
87
95
|
*/
|
|
88
96
|
export function splitPacks(graph: Graph): AssetPackSplit[] {
|
|
89
|
-
const stem = (asset: string) =>
|
|
97
|
+
const stem = (asset: string) =>
|
|
98
|
+
asset.slice(asset.lastIndexOf('/') + 1).replace(/\.(prefab|level)\.[^.]+$/i, '').replace(/\.[^.]+$/, '');
|
|
99
|
+
// A level is its scene — and, in a project that builds levels from their plans at run time, the
|
|
100
|
+
// `.gltfx` its plan lives in. A `.gltfx` a scene **reaches** is that scene's composition, not a
|
|
101
|
+
// second level: keying both would make every source of a baked level look shared.
|
|
90
102
|
const scenes = Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.tscn')).sort();
|
|
91
|
-
const
|
|
103
|
+
const reachedByScene = new Set(scenes.flatMap((s) => graph[s] ?? []));
|
|
104
|
+
const levels = [
|
|
105
|
+
...scenes,
|
|
106
|
+
...Object.keys(graph).filter((s) => s.toLowerCase().endsWith('.gltfx') && !reachedByScene.has(s)),
|
|
107
|
+
].sort();
|
|
108
|
+
const closure = new Map(levels.map((s) => [s, new Set([s, ...(graph[s] ?? [])])]));
|
|
92
109
|
|
|
93
|
-
// One pack per
|
|
110
|
+
// One pack per level, named after its file stem (`shared` is reserved; a repeated stem —
|
|
94
111
|
// two `level.tscn` in different directories — gets a suffix).
|
|
95
112
|
const used = new Set(['shared']);
|
|
96
113
|
const packOf = new Map<string, AssetPackSplit>();
|
|
97
|
-
for (const
|
|
98
|
-
let name = stem(
|
|
99
|
-
for (let n = 2; used.has(name); n++) name = `${stem(
|
|
114
|
+
for (const level of levels) {
|
|
115
|
+
let name = stem(level);
|
|
116
|
+
for (let n = 2; used.has(name); n++) name = `${stem(level)}-${n}`;
|
|
100
117
|
used.add(name);
|
|
101
|
-
packOf.set(
|
|
118
|
+
packOf.set(level, { name, owns: [] });
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
const shared: string[] = [];
|
|
105
122
|
const sources = new Set([...Object.keys(graph), ...Object.values(graph).flat()]);
|
|
106
123
|
for (const source of sources) {
|
|
107
|
-
const owners =
|
|
124
|
+
const owners = levels.filter((level) => closure.get(level)!.has(source));
|
|
108
125
|
if (owners.length === 1) packOf.get(owners[0])!.owns.push(source);
|
|
109
126
|
else shared.push(source);
|
|
110
127
|
}
|
|
@@ -138,6 +155,10 @@ export async function buildAssetPacks(opts: BuildAssetPackOptions = {}): Promise
|
|
|
138
155
|
const run = runner(editor, cwd);
|
|
139
156
|
run(['--headless', '--path', '.', '--import']);
|
|
140
157
|
const { graph, deps } = dump(run, cwd, roots);
|
|
158
|
+
// A level's plan names what no scene does any more — its terrain directory, its layered containers
|
|
159
|
+
// and their layers, its ground/environment textures (`planRefs`). Merged here so the graph and the
|
|
160
|
+
// loose lists carry the level's whole closure with or without a baked scene.
|
|
161
|
+
mergePlanRefs(graph, deps, planRefs(cwd, roots));
|
|
141
162
|
|
|
142
163
|
const packs = splitPacks(graph);
|
|
143
164
|
const owned = packs.flatMap((pack) => pack.owns);
|
package/src/planRefs.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
// A level's refs, read from its **plan**. A `.gltfx` level scene carries the converter's derived
|
|
6
|
+
// render state under `extras.wartales.plan`: it names the level's terrain by *directory* and its
|
|
7
|
+
// ground/environment textures by resource route. A host's closure walk cannot see any of it — the
|
|
8
|
+
// routes are relative to the manifest that names them, a layered container's layers are plain text
|
|
9
|
+
// lines, and a runtime script reads the plan dynamically (so no scene names them literally any more).
|
|
10
|
+
// Reading it here keeps a `.gltfx` source's closure complete without a baked scene.
|
|
11
|
+
//
|
|
12
|
+
// Pure filesystem: a project directory plus the `res://` roots a host scans in, the refs out. No
|
|
13
|
+
// engine, no editor — so it is unit-testable and runs before the pack bake.
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** The refs a `.gltfx`'s plan implies, ready to merge into a dump. */
|
|
19
|
+
export interface PlanRefs {
|
|
20
|
+
/** The level source's own extra loose refs: its terrain data, layered-container manifests, and the
|
|
21
|
+
* ground/environment textures its plan names. */
|
|
22
|
+
loose: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Two of the refs a plan names are plain files rather than imported sources — the terrain's header
|
|
26
|
+
// JSONs, which a plan-built level reads at run time. They are in the level's `loose` list, so a
|
|
27
|
+
// *staging* host writes them; a *pack* needs no help either, because Godot loads a `.json` as a
|
|
28
|
+
// `JSON` resource (verified: `ResourceLoader.exists` is true and the class is `JSON`), so an
|
|
29
|
+
// `all_resources` preset packs it — and since no pack *owns* a source it is not excluded from any,
|
|
30
|
+
// every pack carries those few KB.
|
|
31
|
+
|
|
32
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
33
|
+
|
|
34
|
+
/** The `extras.wartales.plan` of a parsed gltfx document, or null for a foreign/older document. */
|
|
35
|
+
export function planOf(doc: unknown): Record<string, unknown> | null {
|
|
36
|
+
if (!isObject(doc) || !isObject(doc.extras)) return null;
|
|
37
|
+
const w = (doc.extras as Record<string, unknown>)['wartales'];
|
|
38
|
+
if (!isObject(w) || !isObject(w.plan)) return null;
|
|
39
|
+
return w.plan as Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every `src/<dir>`-style root as an absolute disk directory, paired with its `res://` prefix. */
|
|
43
|
+
function rootDirs(projectDir: string, roots: string[]): { disk: string; res: string }[] {
|
|
44
|
+
const out: { disk: string; res: string }[] = [];
|
|
45
|
+
for (const root of roots) {
|
|
46
|
+
const rel = root.replace(/^res:\/\//, '');
|
|
47
|
+
out.push({ disk: join(projectDir, ...rel.split('/')), res: `res://${rel}` });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A path's last segment. */
|
|
53
|
+
const base = (p: string) => p.slice(p.lastIndexOf('/') + 1);
|
|
54
|
+
|
|
55
|
+
/** Every file under `dir`, as paths relative to `dir` (posix, one level of nesting per entry). */
|
|
56
|
+
function walk(dir: string, prefix = ''): string[] {
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
let entries: string[];
|
|
59
|
+
try {
|
|
60
|
+
entries = readdirSync(dir);
|
|
61
|
+
} catch {
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
for (const name of entries) {
|
|
65
|
+
if (name.startsWith('.')) continue;
|
|
66
|
+
const full = join(dir, name);
|
|
67
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
68
|
+
try {
|
|
69
|
+
if (readdirSync(full).length >= 0) {
|
|
70
|
+
out.push(...walk(full, rel));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// not a directory
|
|
75
|
+
}
|
|
76
|
+
out.push(rel);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The environment a **host renders**: the level's authored stage — `stages[0].env`, which the plan
|
|
83
|
+
* also carries as `lighting.env`. A host shows one time of day, so the other stages (a viewer's
|
|
84
|
+
* blend material) and the reflection probe (which a host does not sample) are not runtime refs.
|
|
85
|
+
*/
|
|
86
|
+
function authoredEnvMap(lighting: Record<string, unknown>): string | null {
|
|
87
|
+
const stages = Array.isArray(lighting.stages) ? lighting.stages : [];
|
|
88
|
+
const stage0 = stages.length && isObject(stages[0]) ? (stages[0] as Record<string, unknown>) : null;
|
|
89
|
+
const env = (stage0 && isObject(stage0.env) ? stage0.env : null) ?? (isObject(lighting.env) ? lighting.env : null);
|
|
90
|
+
return env && typeof env.map === 'string' ? env.map : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The layer file names the `.webparray` manifests in `dir` name, one per line. Those layers are read
|
|
95
|
+
* by their *importer* and live inside the imported product afterwards, so nothing loads them at run
|
|
96
|
+
* time: they are import-time inputs, and the directory walk must not stage them.
|
|
97
|
+
*/
|
|
98
|
+
function layerSources(dir: string): Set<string> {
|
|
99
|
+
const names = new Set<string>();
|
|
100
|
+
for (const manifest of walk(dir)) {
|
|
101
|
+
if (!manifest.toLowerCase().endsWith(".webparray")) continue;
|
|
102
|
+
let text: string;
|
|
103
|
+
try {
|
|
104
|
+
text = readFileSync(join(dir, ...manifest.split("/")), "utf8");
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const line of text.split("\n")) {
|
|
109
|
+
const ref = line.trim();
|
|
110
|
+
if (ref && !ref.startsWith("#")) names.add(base(ref));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return names;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The resource route of a ref the plan names, probing the served `.webp` suffix a texture carries. */
|
|
117
|
+
function routeOf(root: { disk: string; res: string }, route: string): string | null {
|
|
118
|
+
for (const candidate of [route, `${route}.webp`]) {
|
|
119
|
+
if (existsSync(join(root.disk, ...candidate.split('/')))) return `${root.res}/${candidate}`;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every `.gltfx` under `roots` with its plan-derived refs. A level's terrain is discovered by
|
|
126
|
+
* *listing* its directory (the tree holds exactly the level's atlas there, so no name list to keep in
|
|
127
|
+
* step), its manifests' layers are read from the manifests, and its ground/environment textures are
|
|
128
|
+
* probed per route (a plan route may or may not carry the served `.webp` suffix).
|
|
129
|
+
*/
|
|
130
|
+
export function planRefs(projectDir: string, roots: string[]): Map<string, PlanRefs> {
|
|
131
|
+
const out = new Map<string, PlanRefs>();
|
|
132
|
+
for (const root of rootDirs(projectDir, roots)) {
|
|
133
|
+
for (const file of walk(root.disk)) {
|
|
134
|
+
if (!file.toLowerCase().endsWith('.gltfx')) continue;
|
|
135
|
+
let doc: unknown;
|
|
136
|
+
try {
|
|
137
|
+
doc = JSON.parse(readFileSync(join(root.disk, ...file.split('/')), 'utf8'));
|
|
138
|
+
} catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const plan = planOf(doc);
|
|
142
|
+
if (!plan) continue;
|
|
143
|
+
const levelRoute = `${root.res}/${file}`;
|
|
144
|
+
const loose = new Set<string>();
|
|
145
|
+
|
|
146
|
+
// The terrain: the plan names its directory, and the tree holds exactly the level's own atlas
|
|
147
|
+
// there (the height and tile-normal maps, the composed weight planes, the passmap and its
|
|
148
|
+
// meta, the header JSON and the layered containers) — so the directory *is* the list. A
|
|
149
|
+
// container's *layers* are deliberately not listed: they live inside its imported product,
|
|
150
|
+
// and are import-time inputs, so nothing stages them at run time.
|
|
151
|
+
const terrain = isObject(plan.terrain) ? plan.terrain : null;
|
|
152
|
+
if (terrain && typeof terrain.dir === 'string') {
|
|
153
|
+
const dir = terrain.dir;
|
|
154
|
+
const abs = join(root.disk, ...dir.split('/'));
|
|
155
|
+
const layers = layerSources(abs);
|
|
156
|
+
for (const name of walk(abs)) {
|
|
157
|
+
if (name.endsWith(".import") || layers.has(base(name))) continue;
|
|
158
|
+
loose.add(`${root.res}/${dir}/${name}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The ground's patches and the rig's skies: plain resource routes with the served suffix.
|
|
163
|
+
const ground = Array.isArray(plan.ground) ? plan.ground : [];
|
|
164
|
+
for (const g of ground) {
|
|
165
|
+
if (!isObject(g)) continue;
|
|
166
|
+
const fields = g.source === 'overlay' ? [isObject(g.material) ? g.material : {}] : [isObject(g.water) ? g.water : {}];
|
|
167
|
+
for (const f of fields) {
|
|
168
|
+
for (const key of ['diffuse', 'normal', 'orm']) {
|
|
169
|
+
if (typeof f[key] !== 'string') continue;
|
|
170
|
+
const route = routeOf(root, f[key] as string);
|
|
171
|
+
if (route) loose.add(route);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const lighting = isObject(plan.lighting) ? plan.lighting : {};
|
|
176
|
+
const map = authoredEnvMap(lighting);
|
|
177
|
+
const route = map ? routeOf(root, map) : null;
|
|
178
|
+
if (route) loose.add(route);
|
|
179
|
+
|
|
180
|
+
out.set(levelRoute, { loose: [...loose].sort() });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Merge `planRefs` into a dump's `graph`/`loose` halves: a `.gltfx` source gains its plan's refs, and
|
|
188
|
+
* so does every source that reaches it (a scene that instances it, so mounting the scene stages the
|
|
189
|
+
* level's terrain and ground too — a union, and a no-op for a project with no plan).
|
|
190
|
+
*/
|
|
191
|
+
export function mergePlanRefs(
|
|
192
|
+
graph: Record<string, string[]>,
|
|
193
|
+
deps: Record<string, string[]>,
|
|
194
|
+
refs: Map<string, PlanRefs>,
|
|
195
|
+
): void {
|
|
196
|
+
for (const [level, r] of refs) {
|
|
197
|
+
if (!(level in graph)) continue;
|
|
198
|
+
deps[level] = [...new Set([...(deps[level] ?? []), ...r.loose])].sort();
|
|
199
|
+
}
|
|
200
|
+
// A scene that instances a `.gltfx` carries that plan's refs too: mounting the scene must stage the
|
|
201
|
+
// level's terrain and ground, not only its model composition. (The scene may already name some of
|
|
202
|
+
// them — a baked one does — so this is a union, and a no-op for a project with no plan.)
|
|
203
|
+
for (const source of Object.keys(graph)) {
|
|
204
|
+
const plans = graph[source].filter((dep) => refs.has(dep));
|
|
205
|
+
if (!plans.length || refs.has(source)) continue;
|
|
206
|
+
const extra = plans.flatMap((p) => refs.get(p)!.loose);
|
|
207
|
+
deps[source] = [...new Set([...(deps[source] ?? []), ...extra])].sort();
|
|
208
|
+
}
|
|
209
|
+
}
|
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
|
+
}
|