@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,242 @@
1
+ /** The `.ply` reader: an ASCII header describing a binary body, as a training run writes it. */
2
+ import { SPLAT_SH1_COEFFICIENTS, packSplats } from './splatData.js';
3
+ /** Coefficients per channel in the l=1 band: the three basis functions Y(1,−1), Y(1,0), Y(1,1). */
4
+ const SH1_PER_CHANNEL = 3;
5
+ /**
6
+ * The spherical-harmonic band-0 constant, `0.5 * sqrt(1 / pi)`.
7
+ *
8
+ * A capture stores colour as the DC term of a spherical-harmonic expansion, which is a *radiance
9
+ * coefficient* and not a colour: it is signed, unbounded, and centred on zero rather than on a half.
10
+ * `0.5 + C0 * f_dc` is the conversion every viewer of this format uses, and getting it wrong is not
11
+ * subtle — dropping the 0.5 leaves half the capture negative and clamped to black.
12
+ */
13
+ const SH_C0 = 0.28209479177387814;
14
+ /** How many bytes each PLY scalar type occupies, and how to read one. */
15
+ const SCALARS = {
16
+ char: { bytes: 1, read: (v, at) => v.getInt8(at) },
17
+ int8: { bytes: 1, read: (v, at) => v.getInt8(at) },
18
+ uchar: { bytes: 1, read: (v, at) => v.getUint8(at) },
19
+ uint8: { bytes: 1, read: (v, at) => v.getUint8(at) },
20
+ short: { bytes: 2, read: (v, at) => v.getInt16(at, true) },
21
+ int16: { bytes: 2, read: (v, at) => v.getInt16(at, true) },
22
+ ushort: { bytes: 2, read: (v, at) => v.getUint16(at, true) },
23
+ uint16: { bytes: 2, read: (v, at) => v.getUint16(at, true) },
24
+ int: { bytes: 4, read: (v, at) => v.getInt32(at, true) },
25
+ int32: { bytes: 4, read: (v, at) => v.getInt32(at, true) },
26
+ uint: { bytes: 4, read: (v, at) => v.getUint32(at, true) },
27
+ uint32: { bytes: 4, read: (v, at) => v.getUint32(at, true) },
28
+ float: { bytes: 4, read: (v, at) => v.getFloat32(at, true) },
29
+ float32: { bytes: 4, read: (v, at) => v.getFloat32(at, true) },
30
+ double: { bytes: 8, read: (v, at) => v.getFloat64(at, true) },
31
+ float64: { bytes: 8, read: (v, at) => v.getFloat64(at, true) },
32
+ };
33
+ const HEADER_END = 'end_header';
34
+ /**
35
+ * Parse the ASCII header and build a property offset table from the **declared** order.
36
+ *
37
+ * **Declared, never assumed.** Training runs emit roughly
38
+ * `x y z nx ny nz f_dc_0..2 f_rest_0..44 opacity scale_0..2 rot_0..3`, and the normals are unused
39
+ * and sometimes absent — so a reader that hard-codes offsets reads a file that is one property
40
+ * short as garbage, silently, at every field after the gap.
41
+ */
42
+ function parseHeader(bytes) {
43
+ /* Latin-1 rather than UTF-8: the header is ASCII and the body is not text, so decoding the whole
44
+ buffer as UTF-8 can throw on a byte sequence that is simply a float. */
45
+ const text = new TextDecoder('latin1').decode(bytes.subarray(0, Math.min(bytes.length, 65536)));
46
+ const endsAt = text.indexOf(HEADER_END);
47
+ if (endsAt < 0) {
48
+ throw new Error(`no \`${HEADER_END}\` in the first ${Math.min(bytes.length, 65536)} bytes, so this is not a ` +
49
+ 'PLY this reader understands.');
50
+ }
51
+ /* Past the marker and its line ending, which is \n or \r\n depending on the writer. */
52
+ const afterMarker = endsAt + HEADER_END.length;
53
+ let bodyAt = afterMarker;
54
+ if (text[bodyAt] === '\r')
55
+ bodyAt++;
56
+ if (text[bodyAt] === '\n')
57
+ bodyAt++;
58
+ const lines = text
59
+ .slice(0, endsAt)
60
+ .split(/\r?\n/)
61
+ .map((line) => line.trim());
62
+ if ((lines[0] ?? '') !== 'ply')
63
+ throw new Error('the first line of a PLY is `ply`; this one is not.');
64
+ const format = lines.find((line) => line.startsWith('format '));
65
+ if (format === undefined)
66
+ throw new Error('the header declares no `format` line.');
67
+ if (format !== 'format binary_little_endian 1.0') {
68
+ throw new Error(`this reader takes \`format binary_little_endian 1.0\` and the file says \`${format}\`. ` +
69
+ 'An ASCII or big-endian body is refused rather than misread, because a silent misread of a ' +
70
+ 'two-hundred-megabyte capture is worse than a stop.');
71
+ }
72
+ /*
73
+ * Only the `vertex` element carries splats. Other elements are skipped entirely rather than
74
+ * parsed: a capture that also declares faces is still a capture, and this reader has no use for
75
+ * them — but their properties must not land in the vertex stride.
76
+ */
77
+ const properties = new Map();
78
+ let count = -1;
79
+ let stride = 0;
80
+ let inVertex = false;
81
+ let sphericalHarmonics = 0;
82
+ for (const line of lines) {
83
+ if (line.startsWith('element ')) {
84
+ const [, name, howMany] = line.split(/\s+/);
85
+ inVertex = name === 'vertex';
86
+ if (inVertex)
87
+ count = Number(howMany);
88
+ continue;
89
+ }
90
+ if (!inVertex || !line.startsWith('property '))
91
+ continue;
92
+ const [, type, name] = line.split(/\s+/);
93
+ if (type === 'list') {
94
+ throw new Error('a `property list` in the vertex element gives every splat a different size, which this ' +
95
+ 'reader does not handle. A Gaussian capture has none.');
96
+ }
97
+ const scalar = SCALARS[type ?? ''];
98
+ if (scalar === undefined)
99
+ throw new Error(`unknown PLY scalar type \`${type}\`.`);
100
+ if (name === undefined)
101
+ throw new Error(`a \`property ${type}\` line names no property.`);
102
+ properties.set(name, { name, offset: stride, read: scalar.read });
103
+ /* Located and counted, never read: view-dependent colour is out of this row's scope, and
104
+ knowing a capture carries the coefficients is what makes adding it a reader change. */
105
+ if (name.startsWith('f_rest_'))
106
+ sphericalHarmonics++;
107
+ stride += scalar.bytes;
108
+ }
109
+ if (count < 0)
110
+ throw new Error('the header declares no `vertex` element.');
111
+ return { count, stride, properties, bodyAt, sphericalHarmonics };
112
+ }
113
+ function require_(header, name) {
114
+ const found = header.properties.get(name);
115
+ if (found === undefined) {
116
+ throw new Error(`the vertex element declares no \`${name}\`, which a Gaussian capture must carry. ` +
117
+ `It declares: ${[...header.properties.keys()].slice(0, 12).join(', ')}…`);
118
+ }
119
+ return found;
120
+ }
121
+ /**
122
+ * Read a `.ply` Gaussian capture.
123
+ *
124
+ * **The encodings are the whole of this function's risk.** Scale is stored as its *logarithm* and
125
+ * opacity as its *logit*, so both are undone here — and here only, because `packSplats` documents
126
+ * that it takes linear values. Getting the boundary wrong produces a capture that is either
127
+ * invisible or a solid block, and both have been reported against other viewers as rendering bugs.
128
+ *
129
+ * **The file stores rotation as wxyz** — `rot_0` is the real part — and `SplatSource` takes xyzw,
130
+ * so the reorder happens here for the same reason it happens in the `.splat` reader.
131
+ */
132
+ export function readSplatPly(buffer) {
133
+ const bytes = new Uint8Array(buffer);
134
+ const header = parseHeader(bytes);
135
+ const { count, stride, bodyAt } = header;
136
+ const needed = bodyAt + count * stride;
137
+ if (buffer.byteLength < needed) {
138
+ throw new Error(`the header declares ${count} vertices of ${stride} bytes after a ${bodyAt}-byte header, ` +
139
+ `which needs ${needed} bytes; the file is ${buffer.byteLength}. It is truncated.`);
140
+ }
141
+ const x = require_(header, 'x');
142
+ const y = require_(header, 'y');
143
+ const z = require_(header, 'z');
144
+ const dc0 = require_(header, 'f_dc_0');
145
+ const dc1 = require_(header, 'f_dc_1');
146
+ const dc2 = require_(header, 'f_dc_2');
147
+ const alpha = require_(header, 'opacity');
148
+ const s0 = require_(header, 'scale_0');
149
+ const s1 = require_(header, 'scale_1');
150
+ const s2 = require_(header, 'scale_2');
151
+ const r0 = require_(header, 'rot_0');
152
+ const r1 = require_(header, 'rot_1');
153
+ const r2 = require_(header, 'rot_2');
154
+ const r3 = require_(header, 'rot_3');
155
+ /*
156
+ * The l=1 band, if the file has one, and **the transpose that finding it needs**.
157
+ *
158
+ * A training run writes `f_rest_*` **channel-major**: all of red's coefficients, then all of
159
+ * green's, then all of blue's. So the per-channel stride is the total over three, and the l=1
160
+ * band is the first three of each channel — `f_rest_0..2`, then `f_rest_{n}..{n+2}`, then
161
+ * `f_rest_{2n}..{2n+2}` — where `n` is 3 at degree 1, 8 at degree 2 and 15 at degree 3.
162
+ *
163
+ * **Reading them as if they were interleaved is the failure to expect**, because it produces a
164
+ * capture that is plausible from a distance: the nine values are all real coefficients of
165
+ * *something*, so the cloud still has a sheen, and it is the wrong sheen in the wrong channel.
166
+ * The count is checked against a multiple of three rather than trusted, and a file whose
167
+ * `f_rest_*` count is not divisible by three is read as having no harmonics at all.
168
+ */
169
+ const perChannel = Math.floor(header.sphericalHarmonics / 3);
170
+ const wantsSh = header.sphericalHarmonics > 0 &&
171
+ header.sphericalHarmonics % 3 === 0 &&
172
+ perChannel >= SH1_PER_CHANNEL;
173
+ const restBands = [];
174
+ if (wantsSh) {
175
+ for (let channel = 0; channel < 3; channel++) {
176
+ for (let band = 0; band < SH1_PER_CHANNEL; band++) {
177
+ const property = header.properties.get(`f_rest_${channel * perChannel + band}`);
178
+ if (property === undefined) {
179
+ restBands.length = 0;
180
+ break;
181
+ }
182
+ restBands.push(property);
183
+ }
184
+ if (restBands.length === 0)
185
+ break;
186
+ }
187
+ }
188
+ const hasSh = restBands.length === SH1_PER_CHANNEL * 3;
189
+ const sh1 = hasSh ? new Float32Array(count * SPLAT_SH1_COEFFICIENTS) : undefined;
190
+ const view = new DataView(buffer);
191
+ const positions = new Float32Array(count * 3);
192
+ const scales = new Float32Array(count * 3);
193
+ const rotations = new Float32Array(count * 4);
194
+ const colors = new Float32Array(count * 3);
195
+ const opacities = new Float32Array(count);
196
+ const clamp01 = (value) => (value < 0 ? 0 : value > 1 ? 1 : value);
197
+ for (let index = 0; index < count; index++) {
198
+ const at = bodyAt + index * stride;
199
+ const p = index * 3;
200
+ positions[p] = x.read(view, at + x.offset);
201
+ positions[p + 1] = y.read(view, at + y.offset);
202
+ positions[p + 2] = z.read(view, at + z.offset);
203
+ /* Stored as a logarithm, so the standard deviation is its exponential. */
204
+ scales[p] = Math.exp(s0.read(view, at + s0.offset));
205
+ scales[p + 1] = Math.exp(s1.read(view, at + s1.offset));
206
+ scales[p + 2] = Math.exp(s2.read(view, at + s2.offset));
207
+ colors[p] = clamp01(0.5 + SH_C0 * dc0.read(view, at + dc0.offset));
208
+ colors[p + 1] = clamp01(0.5 + SH_C0 * dc1.read(view, at + dc1.offset));
209
+ colors[p + 2] = clamp01(0.5 + SH_C0 * dc2.read(view, at + dc2.offset));
210
+ /* Stored as a logit, so the opacity is its logistic. */
211
+ opacities[index] = 1 / (1 + Math.exp(-alpha.read(view, at + alpha.offset)));
212
+ const r = index * 4;
213
+ rotations[r] = r1.read(view, at + r1.offset);
214
+ rotations[r + 1] = r2.read(view, at + r2.offset);
215
+ rotations[r + 2] = r3.read(view, at + r3.offset);
216
+ rotations[r + 3] = r0.read(view, at + r0.offset);
217
+ /*
218
+ * Channel-major in the file, basis-major in `SplatSource` — the transpose the block above
219
+ * describes, in the one loop that touches the file's own order.
220
+ */
221
+ if (sh1 !== undefined) {
222
+ const to = index * SPLAT_SH1_COEFFICIENTS;
223
+ for (let band = 0; band < SH1_PER_CHANNEL; band++) {
224
+ for (let channel = 0; channel < 3; channel++) {
225
+ const property = restBands[channel * SH1_PER_CHANNEL + band];
226
+ sh1[to + band * 3 + channel] =
227
+ property === undefined ? 0 : property.read(view, at + property.offset);
228
+ }
229
+ }
230
+ }
231
+ }
232
+ const data = packSplats({
233
+ count,
234
+ positions,
235
+ scales,
236
+ rotations,
237
+ colors,
238
+ opacities,
239
+ ...(sh1 === undefined ? {} : { sh1 }),
240
+ });
241
+ return { ...data, sphericalHarmonics: header.sphericalHarmonics };
242
+ }
@@ -0,0 +1,110 @@
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
+ import type { SplatData, SplatSource } from './splatData.ts';
30
+ /** The container version this reader understands. */
31
+ export declare const SOG_VERSION = 2;
32
+ /** What a `.sog` manifest declares. Field names are the file's, not this engine's. */
33
+ export interface SogMeta {
34
+ readonly version: number;
35
+ readonly count: number;
36
+ readonly asset?: {
37
+ readonly generator?: string;
38
+ };
39
+ readonly antialias?: boolean;
40
+ readonly means: {
41
+ readonly mins: readonly number[];
42
+ readonly maxs: readonly number[];
43
+ readonly files: readonly string[];
44
+ };
45
+ readonly scales: {
46
+ readonly codebook: readonly number[];
47
+ readonly files: readonly string[];
48
+ };
49
+ readonly quats: {
50
+ readonly files: readonly string[];
51
+ };
52
+ readonly sh0: {
53
+ readonly codebook: readonly number[];
54
+ readonly files: readonly string[];
55
+ };
56
+ /** Absent where the capture carries no bands past the DC term, which is the common case. */
57
+ readonly shN?: {
58
+ readonly count: number;
59
+ readonly bands: number;
60
+ readonly codebook: readonly number[];
61
+ readonly files: readonly string[];
62
+ };
63
+ }
64
+ /** One decoded image: 8-bit RGBA, row-major from the top left, four bytes a texel. */
65
+ export interface DecodedImage {
66
+ readonly width: number;
67
+ readonly height: number;
68
+ readonly rgba: Uint8Array | Uint8ClampedArray;
69
+ }
70
+ /** What turns a WebP file's bytes into texels. See this file's header for why it is a parameter. */
71
+ export type WebpDecoder = (bytes: Uint8Array) => Promise<DecodedImage>;
72
+ /**
73
+ * Unpack a bundled `.sog`, which is a ZIP with every file at the root.
74
+ *
75
+ * **Read through the central directory and never through the local headers**, and that is not
76
+ * defensive: `splat-transform` writes its entries streaming, so bit 3 of the general-purpose flags
77
+ * is set and every local header carries a compressed size of *zero* with the real sizes in a data
78
+ * descriptor after the payload. A reader that trusted the local header would extract nothing from
79
+ * every file a capture tool has produced. Measured on a file that tool wrote: flags `0x808`, sizes
80
+ * zero in the local header and correct in the central directory.
81
+ *
82
+ * Only the two methods the format can produce: stored, which is what `splat-transform` writes
83
+ * because the images are already compressed, and deflate, through `DecompressionStream`.
84
+ */
85
+ export declare function unbundleSog(bundle: ArrayBuffer): Promise<Map<string, Uint8Array>>;
86
+ /** Parse and check a `meta.json`. Refuses a version it does not understand rather than guessing. */
87
+ export declare function readSogMeta(bytes: Uint8Array | string): SogMeta;
88
+ /** The files a capture is made of, by the names its manifest gives them. */
89
+ export type SogFiles = ReadonlyMap<string, Uint8Array>;
90
+ /**
91
+ * Read a `.sog` capture into a `SplatSource`.
92
+ *
93
+ * `files` is what `unbundleSog` returns for a bundle, or the files of an unbundled capture keyed by
94
+ * the names its `meta.json` uses. `decode` turns one WebP into texels; see `browserWebpDecoder`.
95
+ *
96
+ * **Every per-Gaussian property is co-located**: the texel at `(x, y)` means the same Gaussian in
97
+ * every image, and the Gaussians run row-major from the top left. So one index walks all of them,
98
+ * which is the property that makes this reader a single loop and is also the property a reader that
99
+ * transposed one image would break silently.
100
+ */
101
+ export declare function readSplatSog(files: SogFiles, decode: WebpDecoder): Promise<SplatData>;
102
+ /**
103
+ * The same read, stopping one step short: the linear values, before they are packed for the GPU.
104
+ *
105
+ * **Exported because the packing is lossy and a check has to see what was decoded**, not what
106
+ * survived a half-float. `splatSog.test.ts` compares sixty-four Gaussians against the `.ply` they
107
+ * were encoded from and needs metres and quaternions to do it; a consumer transforming a capture
108
+ * before it is uploaded wants the same thing, which is why this is public rather than internal.
109
+ */
110
+ export declare function readSogSource(files: SogFiles, decode: WebpDecoder): Promise<SplatSource>;
@@ -0,0 +1,285 @@
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
+ import { SPLAT_SH1_COEFFICIENTS, packSplats } from './splatData.js';
30
+ /**
31
+ * The band-0 constant, `0.5 * sqrt(1 / pi)`.
32
+ *
33
+ * The same number `splatPly.ts` carries and for the same reason: a capture stores colour as the DC
34
+ * term of a spherical-harmonic expansion, which is signed radiance rather than a colour, and
35
+ * `0.5 + C0 * dc` is the conversion. Duplicated rather than shared because each reader states the
36
+ * encoding it is undoing, and this one undoes a codebook lookup first.
37
+ */
38
+ const SH_C0 = 0.28209479177387814;
39
+ /** The container version this reader understands. */
40
+ export const SOG_VERSION = 2;
41
+ /* ------------------------------------------------------------------ the zip */
42
+ const EOCD_SIGNATURE = 0x06054b50;
43
+ const CENTRAL_SIGNATURE = 0x02014b50;
44
+ /** The end-of-central-directory record with no comment. A comment may follow, up to 65535 bytes. */
45
+ const EOCD_BYTES = 22;
46
+ /**
47
+ * Unpack a bundled `.sog`, which is a ZIP with every file at the root.
48
+ *
49
+ * **Read through the central directory and never through the local headers**, and that is not
50
+ * defensive: `splat-transform` writes its entries streaming, so bit 3 of the general-purpose flags
51
+ * is set and every local header carries a compressed size of *zero* with the real sizes in a data
52
+ * descriptor after the payload. A reader that trusted the local header would extract nothing from
53
+ * every file a capture tool has produced. Measured on a file that tool wrote: flags `0x808`, sizes
54
+ * zero in the local header and correct in the central directory.
55
+ *
56
+ * Only the two methods the format can produce: stored, which is what `splat-transform` writes
57
+ * because the images are already compressed, and deflate, through `DecompressionStream`.
58
+ */
59
+ export async function unbundleSog(bundle) {
60
+ const view = new DataView(bundle);
61
+ const bytes = new Uint8Array(bundle);
62
+ let eocd = -1;
63
+ const earliest = Math.max(0, bundle.byteLength - EOCD_BYTES - 0xffff);
64
+ for (let at = bundle.byteLength - EOCD_BYTES; at >= earliest; at--) {
65
+ if (view.getUint32(at, true) === EOCD_SIGNATURE) {
66
+ eocd = at;
67
+ break;
68
+ }
69
+ }
70
+ if (eocd < 0) {
71
+ throw new Error('splatSog: this is not a bundled `.sog`. A bundle is a ZIP and no end-of-central-directory ' +
72
+ 'record was found — an unbundled capture is a `meta.json` beside its images, which ' +
73
+ '`readSplatSog` takes directly.');
74
+ }
75
+ const entries = view.getUint16(eocd + 10, true);
76
+ let at = view.getUint32(eocd + 16, true);
77
+ const files = new Map();
78
+ for (let i = 0; i < entries; i++) {
79
+ if (view.getUint32(at, true) !== CENTRAL_SIGNATURE) {
80
+ throw new Error(`splatSog: the central directory entry ${i} is not one.`);
81
+ }
82
+ const method = view.getUint16(at + 10, true);
83
+ const compressed = view.getUint32(at + 20, true);
84
+ const uncompressed = view.getUint32(at + 24, true);
85
+ const nameLength = view.getUint16(at + 28, true);
86
+ const extraLength = view.getUint16(at + 30, true);
87
+ const commentLength = view.getUint16(at + 32, true);
88
+ const localAt = view.getUint32(at + 42, true);
89
+ const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLength));
90
+ at += 46 + nameLength + extraLength + commentLength;
91
+ /* The local header's own name and extra lengths, which are the only two of its fields a
92
+ streaming writer fills in truthfully. Everything else comes from the directory above. */
93
+ const localName = view.getUint16(localAt + 26, true);
94
+ const localExtra = view.getUint16(localAt + 28, true);
95
+ const from = localAt + 30 + localName + localExtra;
96
+ const payload = bytes.subarray(from, from + compressed);
97
+ if (method === 0) {
98
+ files.set(name, payload);
99
+ }
100
+ else if (method === 8) {
101
+ files.set(name, await inflateRaw(payload, uncompressed));
102
+ }
103
+ else {
104
+ throw new Error(`splatSog: \`${name}\` uses ZIP method ${method}, which is neither stored nor deflate.`);
105
+ }
106
+ }
107
+ return files;
108
+ }
109
+ async function inflateRaw(payload, expected) {
110
+ const stream = new Blob([payload])
111
+ .stream()
112
+ .pipeThrough(new DecompressionStream('deflate-raw'));
113
+ const out = new Uint8Array(await new Response(stream).arrayBuffer());
114
+ if (expected !== 0 && out.byteLength !== expected) {
115
+ throw new Error(`splatSog: an entry inflated to ${out.byteLength} bytes where the directory says ${expected}.`);
116
+ }
117
+ return out;
118
+ }
119
+ /* ------------------------------------------------------------- the manifest */
120
+ /** Parse and check a `meta.json`. Refuses a version it does not understand rather than guessing. */
121
+ export function readSogMeta(bytes) {
122
+ const text = typeof bytes === 'string' ? bytes : new TextDecoder().decode(bytes);
123
+ const meta = JSON.parse(text);
124
+ if (meta.version !== SOG_VERSION) {
125
+ throw new Error(`splatSog: this manifest declares version ${String(meta.version)} and this reader ` +
126
+ `implements ${SOG_VERSION}. The encodings are versioned, so reading it anyway would ` +
127
+ 'produce a plausible cloud rather than an error.');
128
+ }
129
+ if (!Number.isFinite(meta.count) || meta.count <= 0) {
130
+ throw new Error('splatSog: the manifest declares no `count`.');
131
+ }
132
+ for (const name of ['means', 'scales', 'quats', 'sh0']) {
133
+ if (meta[name] === undefined)
134
+ throw new Error(`splatSog: the manifest declares no \`${name}\`.`);
135
+ }
136
+ return meta;
137
+ }
138
+ /* --------------------------------------------------------- the dequantisers */
139
+ /**
140
+ * A position's own inverse, and it is not the obvious one.
141
+ *
142
+ * **Positions are stored in a signed logarithmic domain**, so that a capture's dense middle gets
143
+ * the resolution and its far outliers do not spend it: what is quantised is
144
+ * `sign(x) * log(1 + |x|)`, and `mins`/`maxs` in the manifest are in *that* domain rather than in
145
+ * metres. Read as metres they are wrong by an exponential, which does not look like an error — it
146
+ * looks like a cloud that has been squashed toward its own centre.
147
+ */
148
+ function unlog(n) {
149
+ return Math.sign(n) * (Math.exp(Math.abs(n)) - 1);
150
+ }
151
+ /**
152
+ * The smallest-three quaternion, whose fourth component the file does not store.
153
+ *
154
+ * Three components quantised into `[-sqrt(2)/2, +sqrt(2)/2]` and an alpha of 252 to 255 saying
155
+ * which one was dropped — it is always the largest, so the reconstructed one is non-negative and
156
+ * the sign is not ambiguous. The order the three are read back into is the *rotation* of the
157
+ * component list past the dropped one, which is what makes 252 mean w and 255 mean z.
158
+ */
159
+ const QUAT_SCALE = Math.SQRT2;
160
+ function unpackQuaternion(r, g, b, mode, out, at) {
161
+ const a = (r / 255 - 0.5) * QUAT_SCALE;
162
+ const c = (g / 255 - 0.5) * QUAT_SCALE;
163
+ const d = (b / 255 - 0.5) * QUAT_SCALE;
164
+ const largest = Math.sqrt(Math.max(0, 1 - a * a - c * c - d * d));
165
+ /* wxyz as the file thinks of it, with the dropped component put back where it belongs. */
166
+ const wxyz = [0, 0, 0, 0];
167
+ const dropped = mode - 252;
168
+ const rest = [a, c, d];
169
+ let k = 0;
170
+ for (let i = 0; i < 4; i++)
171
+ wxyz[i] = i === dropped ? largest : (rest[k++] ?? 0);
172
+ /* And out as xyzw, which is what `SplatSource` takes. See its own note on the two conventions. */
173
+ out[at] = wxyz[1] ?? 0;
174
+ out[at + 1] = wxyz[2] ?? 0;
175
+ out[at + 2] = wxyz[3] ?? 0;
176
+ out[at + 3] = wxyz[0] ?? 0;
177
+ }
178
+ /**
179
+ * Read a `.sog` capture into a `SplatSource`.
180
+ *
181
+ * `files` is what `unbundleSog` returns for a bundle, or the files of an unbundled capture keyed by
182
+ * the names its `meta.json` uses. `decode` turns one WebP into texels; see `browserWebpDecoder`.
183
+ *
184
+ * **Every per-Gaussian property is co-located**: the texel at `(x, y)` means the same Gaussian in
185
+ * every image, and the Gaussians run row-major from the top left. So one index walks all of them,
186
+ * which is the property that makes this reader a single loop and is also the property a reader that
187
+ * transposed one image would break silently.
188
+ */
189
+ export async function readSplatSog(files, decode) {
190
+ return packSplats(await readSogSource(files, decode));
191
+ }
192
+ /**
193
+ * The same read, stopping one step short: the linear values, before they are packed for the GPU.
194
+ *
195
+ * **Exported because the packing is lossy and a check has to see what was decoded**, not what
196
+ * survived a half-float. `splatSog.test.ts` compares sixty-four Gaussians against the `.ply` they
197
+ * were encoded from and needs metres and quaternions to do it; a consumer transforming a capture
198
+ * before it is uploaded wants the same thing, which is why this is public rather than internal.
199
+ */
200
+ export async function readSogSource(files, decode) {
201
+ const metaBytes = files.get('meta.json');
202
+ if (metaBytes === undefined)
203
+ throw new Error('splatSog: the capture has no `meta.json`.');
204
+ const meta = readSogMeta(metaBytes);
205
+ const image = async (name) => {
206
+ const bytes = files.get(name);
207
+ if (bytes === undefined) {
208
+ throw new Error(`splatSog: the manifest names \`${name}\` and the capture does not carry it. ` +
209
+ `It carries: ${[...files.keys()].join(', ')}`);
210
+ }
211
+ return decode(bytes);
212
+ };
213
+ const [meansLow, meansHigh, scales, quats, sh0] = await Promise.all([
214
+ image(meta.means.files[0] ?? 'means_l.webp'),
215
+ image(meta.means.files[1] ?? 'means_u.webp'),
216
+ image(meta.scales.files[0] ?? 'scales.webp'),
217
+ image(meta.quats.files[0] ?? 'quats.webp'),
218
+ image(meta.sh0.files[0] ?? 'sh0.webp'),
219
+ ]);
220
+ const count = meta.count;
221
+ const positions = new Float32Array(count * 3);
222
+ const scaleOut = new Float32Array(count * 3);
223
+ const rotations = new Float32Array(count * 4);
224
+ const colors = new Float32Array(count * 3);
225
+ const opacities = new Float32Array(count);
226
+ const scaleBook = meta.scales.codebook;
227
+ const colourBook = meta.sh0.codebook;
228
+ const mins = meta.means.mins;
229
+ const maxs = meta.means.maxs;
230
+ for (let i = 0; i < count; i++) {
231
+ const t = i * 4;
232
+ for (let axis = 0; axis < 3; axis++) {
233
+ /* Sixteen bits per axis, split across two images: the low byte and the high one. */
234
+ const q = ((meansHigh.rgba[t + axis] ?? 0) << 8) | (meansLow.rgba[t + axis] ?? 0);
235
+ const lo = mins[axis] ?? 0;
236
+ const hi = maxs[axis] ?? 0;
237
+ positions[i * 3 + axis] = unlog(lo + (hi - lo) * (q / 65535));
238
+ /* The codebook is in the log domain, exactly as a `.ply`'s `scale_n` is. */
239
+ scaleOut[i * 3 + axis] = Math.exp(scaleBook[scales.rgba[t + axis] ?? 0] ?? 0);
240
+ /* And the colour's codebook is a DC coefficient, so it takes the band constant. */
241
+ colors[i * 3 + axis] = 0.5 + (colourBook[sh0.rgba[t + axis] ?? 0] ?? 0) * SH_C0;
242
+ }
243
+ unpackQuaternion(quats.rgba[t] ?? 0, quats.rgba[t + 1] ?? 0, quats.rgba[t + 2] ?? 0, quats.rgba[t + 3] ?? 252, rotations, i * 4);
244
+ /* Opacity is linear in alpha here, where a `.ply` stores its logit. */
245
+ opacities[i] = (sh0.rgba[t + 3] ?? 0) / 255;
246
+ }
247
+ const source = { count, positions, scales: scaleOut, rotations, colors, opacities };
248
+ const sh1 = await readSh1(meta, image, count);
249
+ return sh1 === null ? source : { ...source, sh1 };
250
+ }
251
+ /**
252
+ * The l=1 band, out of the palette the container stores it in, or null where there is none.
253
+ *
254
+ * **A capture's higher bands are a palette and an index per Gaussian**, not a value per Gaussian:
255
+ * `shN_labels` is a sixteen-bit index into `shN_centroids`, whose rows hold 64 palette entries and
256
+ * whose width is the band count times three. Only the l=1 coefficients are read, because that is
257
+ * what `SplatSource.sh1` carries and what this engine's shader evaluates — degrees 2 and 3 are
258
+ * declined against a measured bandwidth figure, which `docs/IMPROVEMENTS.md` records with its
259
+ * number.
260
+ */
261
+ async function readSh1(meta, image, count) {
262
+ const shN = meta.shN;
263
+ if (shN === undefined || shN.bands < 1)
264
+ return null;
265
+ const centroids = await image(shN.files[0] ?? 'shN_centroids.webp');
266
+ const labels = await image(shN.files[1] ?? 'shN_labels.webp');
267
+ const book = shN.codebook;
268
+ /* Three coefficients a band for l=1, one palette entry per row of `coefficients` pixels. */
269
+ const coefficients = shN.bands * (shN.bands + 2);
270
+ const out = new Float32Array(count * SPLAT_SH1_COEFFICIENTS);
271
+ for (let i = 0; i < count; i++) {
272
+ const label = (labels.rgba[i * 4] ?? 0) | ((labels.rgba[i * 4 + 1] ?? 0) << 8);
273
+ const entry = label * coefficients;
274
+ /* Interleaved by basis function and then by channel, which is what `SplatSource.sh1` takes and
275
+ is not how the palette stores it: the palette is one pixel per coefficient, RGB in it. */
276
+ for (let basis = 0; basis < 3; basis++) {
277
+ const at = (entry + basis) * 4;
278
+ for (let channel = 0; channel < 3; channel++) {
279
+ out[i * SPLAT_SH1_COEFFICIENTS + basis * 3 + channel] =
280
+ book[centroids.rgba[at + channel] ?? 0] ?? 0;
281
+ }
282
+ }
283
+ }
284
+ return out;
285
+ }
@@ -0,0 +1,26 @@
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
+ import type { WebpDecoder } from './splatSog.ts';
12
+ /**
13
+ * The ordinary decoder: `@driftengine/core`'s exact texel read.
14
+ *
15
+ * **A thin forward, and the reason it is not implemented here is worth reading before anybody
16
+ * inlines it.** Every browser API that hands back an image's pixels without a GPU premultiplies
17
+ * on the way through — a 2D canvas by storing premultiplied, WebCodecs by decoding a WebP with
18
+ * alpha to a premultiplied `BGRA` frame — so every RGB value comes back through
19
+ * `round(round(c * a / 255) * 255 / a)`. On the committed fixture that is 3 of 255 out on
20
+ * `sh0.webp`, whose RGB are *codebook indices* and whose alpha is the opacity: an index several
21
+ * entries away wherever a Gaussian is transparent. `createImageTexelDecoder` reads the same file
22
+ * exactly, and it lives in core because it is raw WebGL and `AGENTS.md` allows that only there.
23
+ *
24
+ * A consumer with its own decoder passes it instead; that is what the parameter is for.
25
+ */
26
+ export declare function browserWebpDecoder(): WebpDecoder;