@driftengine/splats 3.61.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.
Files changed (71) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +56 -0
  4. package/dist/half.d.ts +32 -0
  5. package/dist/half.js +88 -0
  6. package/dist/index.d.ts +32 -0
  7. package/dist/index.js +38 -0
  8. package/dist/shaders/generated/splat.wgsl.d.ts +89 -0
  9. package/dist/shaders/generated/splat.wgsl.js +95 -0
  10. package/dist/shaders/splat.d.ts +25 -0
  11. package/dist/shaders/splat.js +337 -0
  12. package/dist/splat.d.ts +26 -0
  13. package/dist/splat.js +63 -0
  14. package/dist/splatBudget.d.ts +40 -0
  15. package/dist/splatBudget.js +45 -0
  16. package/dist/splatCapture.d.ts +76 -0
  17. package/dist/splatCapture.js +108 -0
  18. package/dist/splatCull.d.ts +25 -0
  19. package/dist/splatCull.js +80 -0
  20. package/dist/splatData.d.ts +177 -0
  21. package/dist/splatData.js +223 -0
  22. package/dist/splatGl.d.ts +49 -0
  23. package/dist/splatGl.js +176 -0
  24. package/dist/splatGpu.d.ts +50 -0
  25. package/dist/splatGpu.js +180 -0
  26. package/dist/splatLayout.d.ts +52 -0
  27. package/dist/splatLayout.js +75 -0
  28. package/dist/splatMatrix.d.ts +29 -0
  29. package/dist/splatMatrix.js +68 -0
  30. package/dist/splatPass.d.ts +83 -0
  31. package/dist/splatPass.js +206 -0
  32. package/dist/splatPly.d.ts +14 -0
  33. package/dist/splatPly.js +242 -0
  34. package/dist/splatSog.d.ts +110 -0
  35. package/dist/splatSog.js +285 -0
  36. package/dist/splatSogDecoder.d.ts +26 -0
  37. package/dist/splatSogDecoder.js +29 -0
  38. package/dist/splatSort.d.ts +137 -0
  39. package/dist/splatSort.js +199 -0
  40. package/dist/splatSortWorker.d.ts +14 -0
  41. package/dist/splatSortWorker.js +137 -0
  42. package/dist/splatSorter.d.ts +112 -0
  43. package/dist/splatSorter.js +231 -0
  44. package/dist/splatView.d.ts +52 -0
  45. package/dist/splatView.js +115 -0
  46. package/package.json +56 -0
  47. package/src/fixtures/README.md +36 -0
  48. package/src/fixtures/cloud.sog +0 -0
  49. package/src/fixtures/cloud.texels.json +27 -0
  50. package/src/fixtures/cloud.truth.json +582 -0
  51. package/src/half.ts +92 -0
  52. package/src/index.ts +55 -0
  53. package/src/shaders/generated/splat.wgsl.ts +98 -0
  54. package/src/shaders/splat.ts +344 -0
  55. package/src/splat.ts +75 -0
  56. package/src/splatBudget.ts +48 -0
  57. package/src/splatCapture.ts +154 -0
  58. package/src/splatCull.ts +91 -0
  59. package/src/splatData.ts +398 -0
  60. package/src/splatGl.ts +262 -0
  61. package/src/splatGpu.ts +259 -0
  62. package/src/splatLayout.ts +86 -0
  63. package/src/splatMatrix.ts +81 -0
  64. package/src/splatPass.ts +324 -0
  65. package/src/splatPly.ts +283 -0
  66. package/src/splatSog.ts +375 -0
  67. package/src/splatSogDecoder.ts +33 -0
  68. package/src/splatSort.ts +296 -0
  69. package/src/splatSortWorker.ts +155 -0
  70. package/src/splatSorter.ts +285 -0
  71. package/src/splatView.ts +147 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * The `.sog` reader: a ZIP of lossless WebP images and a manifest that says what each channel is.
3
+ *
4
+ * **SOG is a container, and the name is shared with the technique it grew out of.** The row that
5
+ * asked for this pointed at `fraunhoferhhi/Self-Organizing-Gaussians`, which is the ECCV 2024 paper
6
+ * that sorts Gaussian parameters into a 2D grid so that ordinary image compression can carry them.
7
+ * That paper ships pre-trained scenes and a script that decompresses them to `.ply`, and documents
8
+ * no on-disk `.sog` structure at all. What capture tools emit is the container specified by
9
+ * PlayCanvas and open-sourced with `splat-transform`, and this file reads *that*: version 2, a
10
+ * `meta.json` naming its images, and a fixed encoding per property.
11
+ *
12
+ * A reader built against the paper would decode something no tool produces. The distinction is
13
+ * recorded here because it cost an afternoon to find and the next person should start with it.
14
+ *
15
+ * ## Two capabilities this cannot supply itself
16
+ *
17
+ * **WebP decode**, which is `WebpDecoder` below and is a parameter rather than a dependency. Every
18
+ * browser has a decoder; Node has none, and the alternative to asking for one is a WebP decoder in
19
+ * this package, which is `AGENTS.md`'s "do not add dependencies" arriving as a thousand lines of
20
+ * vendored VP8L instead. `browserWebpDecoder` is the ordinary implementation, exactly as
21
+ * `BrowserStore` is for `KeyValueStore` — and it forwards to core rather than reaching for a
22
+ * canvas, for a measured reason its own note carries.
23
+ *
24
+ * **Inflate**, if a producer ever deflates. `splat-transform` 3.3.3 stores every entry
25
+ * uncompressed — the images are already compressed and a second pass buys nothing — so the common
26
+ * path needs no inflate at all, and `DecompressionStream` covers the other one. It is in every
27
+ * browser and in Node 18, which is under this engine's floor.
28
+ */
29
+
30
+ import { SPLAT_SH1_COEFFICIENTS, packSplats } from './splatData.ts';
31
+ import type { SplatData, SplatSource } from './splatData.ts';
32
+
33
+ /**
34
+ * The band-0 constant, `0.5 * sqrt(1 / pi)`.
35
+ *
36
+ * The same number `splatPly.ts` carries and for the same reason: a capture stores colour as the DC
37
+ * term of a spherical-harmonic expansion, which is signed radiance rather than a colour, and
38
+ * `0.5 + C0 * dc` is the conversion. Duplicated rather than shared because each reader states the
39
+ * encoding it is undoing, and this one undoes a codebook lookup first.
40
+ */
41
+ const SH_C0 = 0.28209479177387814;
42
+
43
+ /** The container version this reader understands. */
44
+ export const SOG_VERSION = 2;
45
+
46
+ /** What a `.sog` manifest declares. Field names are the file's, not this engine's. */
47
+ export interface SogMeta {
48
+ readonly version: number;
49
+ readonly count: number;
50
+ readonly asset?: { readonly generator?: string };
51
+ readonly antialias?: boolean;
52
+ readonly means: {
53
+ readonly mins: readonly number[];
54
+ readonly maxs: readonly number[];
55
+ readonly files: readonly string[];
56
+ };
57
+ readonly scales: { readonly codebook: readonly number[]; readonly files: readonly string[] };
58
+ readonly quats: { readonly files: readonly string[] };
59
+ readonly sh0: { readonly codebook: readonly number[]; readonly files: readonly string[] };
60
+ /** Absent where the capture carries no bands past the DC term, which is the common case. */
61
+ readonly shN?: {
62
+ readonly count: number;
63
+ readonly bands: number;
64
+ readonly codebook: readonly number[];
65
+ readonly files: readonly string[];
66
+ };
67
+ }
68
+
69
+ /** One decoded image: 8-bit RGBA, row-major from the top left, four bytes a texel. */
70
+ export interface DecodedImage {
71
+ readonly width: number;
72
+ readonly height: number;
73
+ readonly rgba: Uint8Array | Uint8ClampedArray;
74
+ }
75
+
76
+ /** What turns a WebP file's bytes into texels. See this file's header for why it is a parameter. */
77
+ export type WebpDecoder = (bytes: Uint8Array) => Promise<DecodedImage>;
78
+
79
+ /* ------------------------------------------------------------------ the zip */
80
+
81
+ const EOCD_SIGNATURE = 0x06054b50;
82
+ const CENTRAL_SIGNATURE = 0x02014b50;
83
+ /** The end-of-central-directory record with no comment. A comment may follow, up to 65535 bytes. */
84
+ const EOCD_BYTES = 22;
85
+
86
+ /**
87
+ * Unpack a bundled `.sog`, which is a ZIP with every file at the root.
88
+ *
89
+ * **Read through the central directory and never through the local headers**, and that is not
90
+ * defensive: `splat-transform` writes its entries streaming, so bit 3 of the general-purpose flags
91
+ * is set and every local header carries a compressed size of *zero* with the real sizes in a data
92
+ * descriptor after the payload. A reader that trusted the local header would extract nothing from
93
+ * every file a capture tool has produced. Measured on a file that tool wrote: flags `0x808`, sizes
94
+ * zero in the local header and correct in the central directory.
95
+ *
96
+ * Only the two methods the format can produce: stored, which is what `splat-transform` writes
97
+ * because the images are already compressed, and deflate, through `DecompressionStream`.
98
+ */
99
+ export async function unbundleSog(bundle: ArrayBuffer): Promise<Map<string, Uint8Array>> {
100
+ const view = new DataView(bundle);
101
+ const bytes = new Uint8Array(bundle);
102
+
103
+ let eocd = -1;
104
+ const earliest = Math.max(0, bundle.byteLength - EOCD_BYTES - 0xffff);
105
+ for (let at = bundle.byteLength - EOCD_BYTES; at >= earliest; at--) {
106
+ if (view.getUint32(at, true) === EOCD_SIGNATURE) {
107
+ eocd = at;
108
+ break;
109
+ }
110
+ }
111
+ if (eocd < 0) {
112
+ throw new Error(
113
+ 'splatSog: this is not a bundled `.sog`. A bundle is a ZIP and no end-of-central-directory ' +
114
+ 'record was found — an unbundled capture is a `meta.json` beside its images, which ' +
115
+ '`readSplatSog` takes directly.',
116
+ );
117
+ }
118
+
119
+ const entries = view.getUint16(eocd + 10, true);
120
+ let at = view.getUint32(eocd + 16, true);
121
+ const files = new Map<string, Uint8Array>();
122
+
123
+ for (let i = 0; i < entries; i++) {
124
+ if (view.getUint32(at, true) !== CENTRAL_SIGNATURE) {
125
+ throw new Error(`splatSog: the central directory entry ${i} is not one.`);
126
+ }
127
+ const method = view.getUint16(at + 10, true);
128
+ const compressed = view.getUint32(at + 20, true);
129
+ const uncompressed = view.getUint32(at + 24, true);
130
+ const nameLength = view.getUint16(at + 28, true);
131
+ const extraLength = view.getUint16(at + 30, true);
132
+ const commentLength = view.getUint16(at + 32, true);
133
+ const localAt = view.getUint32(at + 42, true);
134
+ const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLength));
135
+ at += 46 + nameLength + extraLength + commentLength;
136
+
137
+ /* The local header's own name and extra lengths, which are the only two of its fields a
138
+ streaming writer fills in truthfully. Everything else comes from the directory above. */
139
+ const localName = view.getUint16(localAt + 26, true);
140
+ const localExtra = view.getUint16(localAt + 28, true);
141
+ const from = localAt + 30 + localName + localExtra;
142
+ const payload = bytes.subarray(from, from + compressed);
143
+
144
+ if (method === 0) {
145
+ files.set(name, payload);
146
+ } else if (method === 8) {
147
+ files.set(name, await inflateRaw(payload, uncompressed));
148
+ } else {
149
+ throw new Error(
150
+ `splatSog: \`${name}\` uses ZIP method ${method}, which is neither stored nor deflate.`,
151
+ );
152
+ }
153
+ }
154
+ return files;
155
+ }
156
+
157
+ async function inflateRaw(payload: Uint8Array, expected: number): Promise<Uint8Array> {
158
+ const stream = new Blob([payload as unknown as BlobPart])
159
+ .stream()
160
+ .pipeThrough(new DecompressionStream('deflate-raw'));
161
+ const out = new Uint8Array(await new Response(stream).arrayBuffer());
162
+ if (expected !== 0 && out.byteLength !== expected) {
163
+ throw new Error(
164
+ `splatSog: an entry inflated to ${out.byteLength} bytes where the directory says ${expected}.`,
165
+ );
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /* ------------------------------------------------------------- the manifest */
171
+
172
+ /** Parse and check a `meta.json`. Refuses a version it does not understand rather than guessing. */
173
+ export function readSogMeta(bytes: Uint8Array | string): SogMeta {
174
+ const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
175
+ const meta = JSON.parse(text) as SogMeta;
176
+ if (meta.version !== SOG_VERSION) {
177
+ throw new Error(
178
+ `splatSog: this manifest declares version ${String(meta.version)} and this reader ` +
179
+ `implements ${SOG_VERSION}. The encodings are versioned, so reading it anyway would ` +
180
+ 'produce a plausible cloud rather than an error.',
181
+ );
182
+ }
183
+ if (!Number.isFinite(meta.count) || meta.count <= 0) {
184
+ throw new Error('splatSog: the manifest declares no `count`.');
185
+ }
186
+ for (const name of ['means', 'scales', 'quats', 'sh0'] as const) {
187
+ if (meta[name] === undefined)
188
+ throw new Error(`splatSog: the manifest declares no \`${name}\`.`);
189
+ }
190
+ return meta;
191
+ }
192
+
193
+ /* --------------------------------------------------------- the dequantisers */
194
+
195
+ /**
196
+ * A position's own inverse, and it is not the obvious one.
197
+ *
198
+ * **Positions are stored in a signed logarithmic domain**, so that a capture's dense middle gets
199
+ * the resolution and its far outliers do not spend it: what is quantised is
200
+ * `sign(x) * log(1 + |x|)`, and `mins`/`maxs` in the manifest are in *that* domain rather than in
201
+ * metres. Read as metres they are wrong by an exponential, which does not look like an error — it
202
+ * looks like a cloud that has been squashed toward its own centre.
203
+ */
204
+ function unlog(n: number): number {
205
+ return Math.sign(n) * (Math.exp(Math.abs(n)) - 1);
206
+ }
207
+
208
+ /**
209
+ * The smallest-three quaternion, whose fourth component the file does not store.
210
+ *
211
+ * Three components quantised into `[-sqrt(2)/2, +sqrt(2)/2]` and an alpha of 252 to 255 saying
212
+ * which one was dropped — it is always the largest, so the reconstructed one is non-negative and
213
+ * the sign is not ambiguous. The order the three are read back into is the *rotation* of the
214
+ * component list past the dropped one, which is what makes 252 mean w and 255 mean z.
215
+ */
216
+ const QUAT_SCALE = Math.SQRT2;
217
+
218
+ function unpackQuaternion(
219
+ r: number,
220
+ g: number,
221
+ b: number,
222
+ mode: number,
223
+ out: Float32Array,
224
+ at: number,
225
+ ): void {
226
+ const a = (r / 255 - 0.5) * QUAT_SCALE;
227
+ const c = (g / 255 - 0.5) * QUAT_SCALE;
228
+ const d = (b / 255 - 0.5) * QUAT_SCALE;
229
+ const largest = Math.sqrt(Math.max(0, 1 - a * a - c * c - d * d));
230
+ /* wxyz as the file thinks of it, with the dropped component put back where it belongs. */
231
+ const wxyz = [0, 0, 0, 0];
232
+ const dropped = mode - 252;
233
+ const rest = [a, c, d];
234
+ let k = 0;
235
+ for (let i = 0; i < 4; i++) wxyz[i] = i === dropped ? largest : (rest[k++] ?? 0);
236
+ /* And out as xyzw, which is what `SplatSource` takes. See its own note on the two conventions. */
237
+ out[at] = wxyz[1] ?? 0;
238
+ out[at + 1] = wxyz[2] ?? 0;
239
+ out[at + 2] = wxyz[3] ?? 0;
240
+ out[at + 3] = wxyz[0] ?? 0;
241
+ }
242
+
243
+ /* ------------------------------------------------------------- the reader */
244
+
245
+ /** The files a capture is made of, by the names its manifest gives them. */
246
+ export type SogFiles = ReadonlyMap<string, Uint8Array>;
247
+
248
+ /**
249
+ * Read a `.sog` capture into a `SplatSource`.
250
+ *
251
+ * `files` is what `unbundleSog` returns for a bundle, or the files of an unbundled capture keyed by
252
+ * the names its `meta.json` uses. `decode` turns one WebP into texels; see `browserWebpDecoder`.
253
+ *
254
+ * **Every per-Gaussian property is co-located**: the texel at `(x, y)` means the same Gaussian in
255
+ * every image, and the Gaussians run row-major from the top left. So one index walks all of them,
256
+ * which is the property that makes this reader a single loop and is also the property a reader that
257
+ * transposed one image would break silently.
258
+ */
259
+ export async function readSplatSog(files: SogFiles, decode: WebpDecoder): Promise<SplatData> {
260
+ return packSplats(await readSogSource(files, decode));
261
+ }
262
+
263
+ /**
264
+ * The same read, stopping one step short: the linear values, before they are packed for the GPU.
265
+ *
266
+ * **Exported because the packing is lossy and a check has to see what was decoded**, not what
267
+ * survived a half-float. `splatSog.test.ts` compares sixty-four Gaussians against the `.ply` they
268
+ * were encoded from and needs metres and quaternions to do it; a consumer transforming a capture
269
+ * before it is uploaded wants the same thing, which is why this is public rather than internal.
270
+ */
271
+ export async function readSogSource(files: SogFiles, decode: WebpDecoder): Promise<SplatSource> {
272
+ const metaBytes = files.get('meta.json');
273
+ if (metaBytes === undefined) throw new Error('splatSog: the capture has no `meta.json`.');
274
+ const meta = readSogMeta(metaBytes);
275
+
276
+ const image = async (name: string): Promise<DecodedImage> => {
277
+ const bytes = files.get(name);
278
+ if (bytes === undefined) {
279
+ throw new Error(
280
+ `splatSog: the manifest names \`${name}\` and the capture does not carry it. ` +
281
+ `It carries: ${[...files.keys()].join(', ')}`,
282
+ );
283
+ }
284
+ return decode(bytes);
285
+ };
286
+
287
+ const [meansLow, meansHigh, scales, quats, sh0] = await Promise.all([
288
+ image(meta.means.files[0] ?? 'means_l.webp'),
289
+ image(meta.means.files[1] ?? 'means_u.webp'),
290
+ image(meta.scales.files[0] ?? 'scales.webp'),
291
+ image(meta.quats.files[0] ?? 'quats.webp'),
292
+ image(meta.sh0.files[0] ?? 'sh0.webp'),
293
+ ]);
294
+
295
+ const count = meta.count;
296
+ const positions = new Float32Array(count * 3);
297
+ const scaleOut = new Float32Array(count * 3);
298
+ const rotations = new Float32Array(count * 4);
299
+ const colors = new Float32Array(count * 3);
300
+ const opacities = new Float32Array(count);
301
+
302
+ const scaleBook = meta.scales.codebook;
303
+ const colourBook = meta.sh0.codebook;
304
+ const mins = meta.means.mins;
305
+ const maxs = meta.means.maxs;
306
+
307
+ for (let i = 0; i < count; i++) {
308
+ const t = i * 4;
309
+ for (let axis = 0; axis < 3; axis++) {
310
+ /* Sixteen bits per axis, split across two images: the low byte and the high one. */
311
+ const q = ((meansHigh.rgba[t + axis] ?? 0) << 8) | (meansLow.rgba[t + axis] ?? 0);
312
+ const lo = mins[axis] ?? 0;
313
+ const hi = maxs[axis] ?? 0;
314
+ positions[i * 3 + axis] = unlog(lo + (hi - lo) * (q / 65535));
315
+ /* The codebook is in the log domain, exactly as a `.ply`'s `scale_n` is. */
316
+ scaleOut[i * 3 + axis] = Math.exp(scaleBook[scales.rgba[t + axis] ?? 0] ?? 0);
317
+ /* And the colour's codebook is a DC coefficient, so it takes the band constant. */
318
+ colors[i * 3 + axis] = 0.5 + (colourBook[sh0.rgba[t + axis] ?? 0] ?? 0) * SH_C0;
319
+ }
320
+ unpackQuaternion(
321
+ quats.rgba[t] ?? 0,
322
+ quats.rgba[t + 1] ?? 0,
323
+ quats.rgba[t + 2] ?? 0,
324
+ quats.rgba[t + 3] ?? 252,
325
+ rotations,
326
+ i * 4,
327
+ );
328
+ /* Opacity is linear in alpha here, where a `.ply` stores its logit. */
329
+ opacities[i] = (sh0.rgba[t + 3] ?? 0) / 255;
330
+ }
331
+
332
+ const source: SplatSource = { count, positions, scales: scaleOut, rotations, colors, opacities };
333
+ const sh1 = await readSh1(meta, image, count);
334
+ return sh1 === null ? source : { ...source, sh1 };
335
+ }
336
+
337
+ /**
338
+ * The l=1 band, out of the palette the container stores it in, or null where there is none.
339
+ *
340
+ * **A capture's higher bands are a palette and an index per Gaussian**, not a value per Gaussian:
341
+ * `shN_labels` is a sixteen-bit index into `shN_centroids`, whose rows hold 64 palette entries and
342
+ * whose width is the band count times three. Only the l=1 coefficients are read, because that is
343
+ * what `SplatSource.sh1` carries and what this engine's shader evaluates — degrees 2 and 3 are
344
+ * declined against a measured bandwidth figure, which `docs/IMPROVEMENTS.md` records with its
345
+ * number.
346
+ */
347
+ async function readSh1(
348
+ meta: SogMeta,
349
+ image: (name: string) => Promise<DecodedImage>,
350
+ count: number,
351
+ ): Promise<Float32Array | null> {
352
+ const shN = meta.shN;
353
+ if (shN === undefined || shN.bands < 1) return null;
354
+ const centroids = await image(shN.files[0] ?? 'shN_centroids.webp');
355
+ const labels = await image(shN.files[1] ?? 'shN_labels.webp');
356
+ const book = shN.codebook;
357
+ /* Three coefficients a band for l=1, one palette entry per row of `coefficients` pixels. */
358
+ const coefficients = shN.bands * (shN.bands + 2);
359
+ const out = new Float32Array(count * SPLAT_SH1_COEFFICIENTS);
360
+
361
+ for (let i = 0; i < count; i++) {
362
+ const label = (labels.rgba[i * 4] ?? 0) | ((labels.rgba[i * 4 + 1] ?? 0) << 8);
363
+ const entry = label * coefficients;
364
+ /* Interleaved by basis function and then by channel, which is what `SplatSource.sh1` takes and
365
+ is not how the palette stores it: the palette is one pixel per coefficient, RGB in it. */
366
+ for (let basis = 0; basis < 3; basis++) {
367
+ const at = (entry + basis) * 4;
368
+ for (let channel = 0; channel < 3; channel++) {
369
+ out[i * SPLAT_SH1_COEFFICIENTS + basis * 3 + channel] =
370
+ book[centroids.rgba[at + channel] ?? 0] ?? 0;
371
+ }
372
+ }
373
+ }
374
+ return out;
375
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The ordinary WebP decoder for `.sog` captures, which lives here and not beside the reader.
3
+ *
4
+ * **Its own module because it is the reader's only reason to name `@driftengine/core`**, and that
5
+ * import has two costs worth avoiding: a consumer supplying its own decoder should not pull core
6
+ * through this path, and `scripts/sog-reader.test.mjs` runs under Node's strip-only mode, where an
7
+ * import that resolves through a `node_modules` symlink is refused outright — which is the trap
8
+ * `docs/IMPROVEMENTS.md` records under proving a package from its tarball. The reader itself
9
+ * imports nothing but its own package, so the test loads it and this file stays out of the way.
10
+ */
11
+
12
+ import { createImageTexelDecoder } from '@driftengine/core';
13
+
14
+ import type { DecodedImage, WebpDecoder } from './splatSog.ts';
15
+
16
+ /**
17
+ * The ordinary decoder: `@driftengine/core`'s exact texel read.
18
+ *
19
+ * **A thin forward, and the reason it is not implemented here is worth reading before anybody
20
+ * inlines it.** Every browser API that hands back an image's pixels without a GPU premultiplies
21
+ * on the way through — a 2D canvas by storing premultiplied, WebCodecs by decoding a WebP with
22
+ * alpha to a premultiplied `BGRA` frame — so every RGB value comes back through
23
+ * `round(round(c * a / 255) * 255 / a)`. On the committed fixture that is 3 of 255 out on
24
+ * `sh0.webp`, whose RGB are *codebook indices* and whose alpha is the opacity: an index several
25
+ * entries away wherever a Gaussian is transparent. `createImageTexelDecoder` reads the same file
26
+ * exactly, and it lives in core because it is raw WebGL and `AGENTS.md` allows that only there.
27
+ *
28
+ * A consumer with its own decoder passes it instead; that is what the parameter is for.
29
+ */
30
+ export function browserWebpDecoder(): WebpDecoder {
31
+ const decode = createImageTexelDecoder();
32
+ return async (bytes: Uint8Array): Promise<DecodedImage> => decode(bytes, 'image/webp');
33
+ }