@solidrt/3d 0.0.63 → 0.0.65

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.
@@ -0,0 +1,617 @@
1
+ // @solidrt/3d/splat - the splat DATA side of the package without the
2
+ // runtime: the capture parsers (.ply, .splat, gunzipped .spz), the
3
+ // covariance bake and the .srts container, for `srt tool 3d/splat` and any
4
+ // app bake script under bun. The package root imports `flux:*` and so
5
+ // loads only on the runtime; this entry imports nothing that does (the
6
+ // model-data rule; tests/splat-data.test.ts imports it on the bare flux
7
+ // binary and fails the moment a gui or srt: import creeps into the chain).
8
+ //
9
+ // import { encodeSplat, parseSplat } from "@solidrt/3d/splat"
10
+ // writeFileSync("assets/scan.srts", encodeSplat(parseSplat(bytes)))
11
+ //
12
+ // loadSplat / createSplatMesh on the runtime read what it writes: a .srts
13
+ // record IS the texel data the splat shader fetches (two rgba32ui texels
14
+ // per splat, SPLAT_ATTRIBUTES as a byte layout) and the record the core
15
+ // sorts by depth, so runtime loading is a header parse plus byte views -
16
+ // the 3D covariance is precomputed here, deleting the quaternion decode,
17
+ // the rotation build and the M*Mt from the vertex stage (the measured
18
+ // per-splat wall no bake-less viewer can avoid), the spherical-harmonic
19
+ // bands beyond DC pack into a second texel block, and the y-up bake
20
+ // deletes the orientation footgun every viewer pushes onto the app.
21
+
22
+ import type { VertexAttribute } from "@solidrt/core/gpu"
23
+
24
+ /** One baked splat record, 32 bytes = two rgba32ui texels: center
25
+ * float32x3 (12) + sRGB color and opacity unorm8x4 (4) | 3D covariance
26
+ * upper triangle as six float16 (12) + a spare u32 (4). Centers stay
27
+ * float32 deliberately - half positions are a known precision mistake on
28
+ * large scenes; covariance quantizes to fp16 robustly. */
29
+ export const SPLAT_RECORD_BYTES = 32
30
+ /** Texels of the record texture per splat (SPLAT_RECORD_BYTES / 16). */
31
+ export const SPLAT_RECORD_TEXELS = 2
32
+ /** Bytes per rgba32ui texel. */
33
+ export const SPLAT_TEXEL_BYTES = 16
34
+ /** Texels of the SH texture per splat for each degree 0..3: the
35
+ * 3 x ((d + 1)^2 - 1) halves beyond DC packed pairwise into u32s, eight
36
+ * halves per texel (degree 1 = 9 halves in 2 texels, 2 = 24 in 3,
37
+ * 3 = 45 in 6). */
38
+ export const SPLAT_SH_TEXELS = [0, 2, 3, 6]
39
+
40
+ /** The record as a byte layout, in texel order: the center and packed
41
+ * color (texel A), then the covariance halves - iCovA (xx, xy, xz, yy),
42
+ * iCovB (yz, zz) - and the spare word (texel B). The core's depth sort
43
+ * keys on iCenter at this stride; the shader reads the same bytes as two
44
+ * uvec4 texels. */
45
+ export const SPLAT_ATTRIBUTES: VertexAttribute[] = [
46
+ { name: "iCenter", format: "float32x3" },
47
+ { name: "iColor", format: "unorm8x4" },
48
+ { name: "iCovA", format: "float16x4" },
49
+ { name: "iCovB", format: "float16x2" },
50
+ { name: "iSpare", format: "uint32" },
51
+ ]
52
+
53
+ /** A parsed or loaded splat cloud: `records` is the interleaved record
54
+ * block (SPLAT_ATTRIBUTES, SPLAT_RECORD_BYTES per splat, `count` of
55
+ * them), importance-sorted at bake so the first n records are a usable
56
+ * scene at any n; `sh` the matching SH texel block (SPLAT_SH_TEXELS per
57
+ * splat, in the same order) or null at degree 0. From decodeSplat both
58
+ * VIEW the container's bytes. */
59
+ export type SplatData = {
60
+ count: number
61
+ records: Uint8Array
62
+ /** [minX, minY, minZ, maxX, maxY, maxZ] of the splat centers. */
63
+ bounds: Float32Array
64
+ /** Spherical-harmonic bands baked beyond the base color (0..3). */
65
+ shDegree: number
66
+ /** The SH texel block: count x SPLAT_SH_TEXELS[shDegree] texels of
67
+ * SPLAT_TEXEL_BYTES, each splat's halves beyond DC coefficient-major
68
+ * with rgb interleaved (coefficient k of channel c at half 3k + c),
69
+ * zero-padded to its texels. null at degree 0. */
70
+ sh: Uint8Array | null
71
+ }
72
+
73
+ export type SplatFormat = "ply" | "splat" | "spz"
74
+
75
+ export type ParseSplatOptions = {
76
+ /** The input format; sniffed from the bytes when omitted (.ply and .spz
77
+ * carry magic; anything else must be whole 32-byte .splat records). */
78
+ format?: SplatFormat
79
+ /** Keep the capture's own orientation. By default a y-down capture
80
+ * (.ply and .splat are COLMAP-framed) is stood up to y-up at bake -
81
+ * exact and free, positions, covariances and SH coefficients rotated
82
+ * together - so a baked splat drops into a scene like any model; .spz
83
+ * is y-up already and never flips. */
84
+ keepOrientation?: boolean
85
+ /** Spherical-harmonic bands to bake beyond the base color, 0..3,
86
+ * capped at what the capture holds (.splat has none). Default 0: the
87
+ * view-dependent bands cost 32/48/96 bytes per splat of texture at
88
+ * degree 1/2/3, so a bake opts in. */
89
+ shDegree?: number
90
+ }
91
+
92
+ // The zeroth spherical harmonic: color = 0.5 + SH_C0 * f_dc, the 3DGS
93
+ // DC-band to base-color mapping every viewer applies.
94
+ const SH_C0 = 0.28209479177387814
95
+ // Coefficients per color channel beyond DC at SH degree 0..3:
96
+ // (d + 1)^2 - 1.
97
+ const SH_COEFFICIENTS = [0, 3, 8, 15]
98
+ // The y-up flip (a half turn about x: y and z negate) on the 15 basis
99
+ // functions beyond DC, in the 3DGS order (y, z, x | xy, yz, 2zz-xx-yy, xz,
100
+ // xx-yy | y(3xx-yy), xyz, y(4zz-xx-yy), z(2zz-3xx-3yy), x(4zz-xx-yy),
101
+ // z(xx-yy), x(xx-3yy)): a basis odd in (y, z) together flips sign, so its
102
+ // coefficient negates to keep the color at the rotated direction.
103
+ const SH_FLIP_SIGN = [-1, -1, 1, -1, 1, 1, -1, 1, -1, 1, -1, -1, 1, -1, 1]
104
+ // spz stores an SH coefficient as a byte around 128: (byte - 128) / 128.
105
+ const SPZ_SH_SCALE = 128
106
+ // Largest finite float16; covariance entries are clamped into this range
107
+ // (a monster background splat past it would otherwise bake to infinity).
108
+ const F16_MAX = 65504
109
+ // spz packs the DC band as u8 around 0.5 with this scale (Niantic's
110
+ // load-spz.cc colorScale): dc = (byte / 255 - 0.5) / SPZ_COLOR_SCALE.
111
+ const SPZ_COLOR_SCALE = 0.15
112
+ // spz packs log scales as u8 sixteenths offset by -10: exp(byte / 16 - 10).
113
+ const SPZ_SCALE_OFFSET = 10
114
+ const SPZ_SCALE_STEP = 16
115
+
116
+ /** "SRTS" read as a little-endian u32. */
117
+ const MAGIC = 0x53545253
118
+ // Version 2: the 32-byte two-texel record, importance-sorted, y-up unless
119
+ // the bake kept orientation, plus the optional SH texel block; the header
120
+ // carries count, shDegree, bounds and both block ranges. (Version 1 was
121
+ // the 28-byte instance-attribute record of the first viewer.)
122
+ const VERSION = 2
123
+
124
+ /** The de-facto .splat interchange record (antimatter15): position
125
+ * float32x3, scale float32x3, sRGB color + opacity unorm8x4, rotation
126
+ * quaternion (w, x, y, z) as unorm8 around 128. */
127
+ const SPLAT_IN_BYTES = 32
128
+
129
+ // spz (gunzipped) header: magic, version, count as u32, then shDegree,
130
+ // fractionalBits, flags, reserved as bytes.
131
+ const SPZ_MAGIC = 0x5053474e
132
+ const SPZ_HEADER_BYTES = 16
133
+ // spz version 2 packs rotations as the quaternion's first three (x, y, z
134
+ // around 127.5, w derived); version 3 as smallest-three in four bytes.
135
+ // Version 1 (float16 positions) was never released and version 4 moved to
136
+ // zstd streams; both are rejected by name.
137
+ const SPZ_VERSIONS = [2, 3]
138
+ // Smallest-three fields: 9-bit magnitudes scaled by 1/sqrt(2), a sign bit
139
+ // each, the largest component's index in the top two bits.
140
+ const SPZ_ROT_MAG_BITS = 9
141
+ // Coefficients per color channel for SH degree 1..3 (bands beyond DC).
142
+ const SPZ_SH_DIM = [0, 3, 8, 15]
143
+
144
+ // The parsers' common product, before the bake: linear scales, (w,x,y,z)
145
+ // rotations (normalized at bake), sRGB color + opacity bytes, and the SH
146
+ // coefficients beyond DC at `shDegree` (coefficient-major, rgb
147
+ // interleaved: sh[(i * D + k) * 3 + c], D = SH_COEFFICIENTS[shDegree]).
148
+ // `flip` marks a y-down source the bake stands up.
149
+ type Gaussians = {
150
+ count: number
151
+ positions: Float32Array
152
+ scales: Float32Array
153
+ rotations: Float32Array
154
+ colors: Uint8Array
155
+ shDegree: number
156
+ sh: Float32Array | null
157
+ flip: boolean
158
+ }
159
+
160
+ let sigmoid = (x: number) => 1 / (1 + Math.exp(-x))
161
+ let colorByte = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255)
162
+
163
+ /**
164
+ * Parse a captured splat cloud - a trainer's .ply, the de-facto .splat
165
+ * interchange, or a gunzipped .spz (Niantic; the container is
166
+ * gzip-compressed, and this entry stays decompression-free, so hand it
167
+ * the inflated bytes - `srt tool 3d/splat` does) - into baked SplatData:
168
+ * covariance precomputed, y-up by default, importance-sorted (size times
169
+ * opacity), bounds measured. Runs anywhere (bun, the runtime); for a
170
+ * capture of real size run it at pack time and ship encodeSplat's .srts.
171
+ */
172
+ export function parseSplat(bytes: Uint8Array, opts?: ParseSplatOptions): SplatData {
173
+ let format = opts?.format ?? sniffFormat(bytes)
174
+ let degree = opts?.shDegree ?? 0
175
+ if (!(Number.isInteger(degree) && degree >= 0 && degree <= 3)) throw new Error("parseSplat: shDegree must be an integer 0..3, got " + degree)
176
+ let raw = format === "ply" ? parsePly(bytes, degree) : format === "spz" ? parseSpz(bytes, degree) : parseDotSplat(bytes)
177
+ if (opts?.keepOrientation === true) raw.flip = false
178
+ return bake(raw)
179
+ }
180
+
181
+ function sniffFormat(bytes: Uint8Array): SplatFormat {
182
+ if (bytes.length >= 4 && bytes[0] === 0x70 && bytes[1] === 0x6c && bytes[2] === 0x79 && (bytes[3] === 0x0a || bytes[3] === 0x0d)) return "ply"
183
+ if (bytes.length >= 4) {
184
+ let dv = new DataView(bytes.buffer, bytes.byteOffset, 4)
185
+ if (dv.getUint32(0, true) === SPZ_MAGIC) return "spz"
186
+ }
187
+ if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
188
+ throw new Error("parseSplat: gzip-compressed input (.spz files are); gunzip the bytes first - srt tool 3d/splat does")
189
+ }
190
+ if (bytes.length > 0 && bytes.length % SPLAT_IN_BYTES === 0) return "splat"
191
+ throw new Error("parseSplat: unrecognized input - not a .ply, not a gunzipped .spz, and not whole 32-byte .splat records")
192
+ }
193
+
194
+ // One property of a .ply vertex element: its byte offset in a record and
195
+ // whether it is a 32-bit float (the only type the trainer fields use).
196
+ type PlyProperty = { offset: number; float: boolean }
197
+
198
+ // A 3DGS trainer's .ply: binary little-endian, a leading `vertex` element
199
+ // whose properties carry raw training values - log scales, pre-sigmoid
200
+ // opacity, the DC band as SH coefficients, the quaternion (w, x, y, z)
201
+ // in rot_0..3, and the higher bands as f_rest_0..(3D - 1) laid out
202
+ // channel-major (coefficient k of channel c at f_rest_[c * D + k], D the
203
+ // file's coefficients per channel). `degree` caps the bands read.
204
+ function parsePly(bytes: Uint8Array, degree: number): Gaussians {
205
+ // The header is ASCII up to "end_header" and its newline.
206
+ let probe = new TextDecoder().decode(bytes.subarray(0, Math.min(bytes.length, 65536)))
207
+ let mark = probe.indexOf("end_header")
208
+ if (mark < 0) throw new Error("parseSplat: .ply header end not found (or a header past 64 KiB)")
209
+ let headerEnd = probe.indexOf("\n", mark)
210
+ if (headerEnd < 0) throw new Error("parseSplat: .ply header end not terminated")
211
+ let lines = probe
212
+ .slice(0, headerEnd)
213
+ .split("\n")
214
+ .map(l => l.trim())
215
+ if (lines[0] !== "ply") throw new Error("parseSplat: not a .ply file")
216
+ let format = lines.find(l => l.startsWith("format "))
217
+ if (format !== "format binary_little_endian 1.0") {
218
+ throw new Error("parseSplat: .ply format must be binary_little_endian 1.0, got '" + (format ?? "none") + "'")
219
+ }
220
+
221
+ const SIZES: Record<string, number> = { char: 1, int8: 1, uchar: 1, uint8: 1, short: 2, int16: 2, ushort: 2, uint16: 2, int: 4, int32: 4, uint: 4, uint32: 4, float: 4, float32: 4, double: 8, float64: 8 }
222
+ let count = -1
223
+ let stride = 0
224
+ let properties = new Map<string, PlyProperty>()
225
+ let element: string | null = null
226
+ for (let line of lines) {
227
+ let words = line.split(/\s+/)
228
+ if (words[0] === "element") {
229
+ if (element === "vertex") break
230
+ element = words[1] ?? null
231
+ if (element === "vertex") count = Number(words[2])
232
+ } else if (words[0] === "property" && element === "vertex") {
233
+ if (words[1] === "list") throw new Error("parseSplat: .ply vertex element has a list property; records must be fixed-size")
234
+ let size = SIZES[words[1]!]
235
+ let name = words[2]
236
+ if (size === undefined || name === undefined) throw new Error("parseSplat: malformed .ply property '" + line + "'")
237
+ properties.set(name, { offset: stride, float: words[1] === "float" || words[1] === "float32" })
238
+ stride += size
239
+ } else if (words[0] === "property" && element !== null && element !== "vertex") {
240
+ // Data of an element BEFORE vertex would shift the vertex block by
241
+ // an amount only its data knows; trainers write vertex first.
242
+ throw new Error("parseSplat: .ply has element '" + element + "' before vertex; only vertex-first files are supported")
243
+ }
244
+ }
245
+ if (count < 0) throw new Error("parseSplat: .ply has no vertex element")
246
+
247
+ const FIELDS = ["x", "y", "z", "f_dc_0", "f_dc_1", "f_dc_2", "opacity", "scale_0", "scale_1", "scale_2", "rot_0", "rot_1", "rot_2", "rot_3"]
248
+ let at: number[] = FIELDS.map(name => {
249
+ let p = properties.get(name)
250
+ if (p === undefined) throw new Error("parseSplat: .ply vertex element is missing '" + name + "' - not a 3DGS capture?")
251
+ if (!p.float) throw new Error("parseSplat: .ply property '" + name + "' must be float")
252
+ return p.offset
253
+ })
254
+
255
+ // The higher bands: however many f_rest_* the file carries (0, 9, 24
256
+ // or 45), read down to the requested degree.
257
+ let restCount = 0
258
+ while (properties.has("f_rest_" + restCount)) restCount++
259
+ if (restCount % 3 !== 0 || !SH_COEFFICIENTS.includes(restCount / 3)) {
260
+ throw new Error("parseSplat: .ply carries " + restCount + " f_rest properties; expected 0, 9, 24 or 45")
261
+ }
262
+ let fileD = restCount / 3
263
+ let shDegree = Math.min(degree, SH_COEFFICIENTS.indexOf(fileD))
264
+ let D = SH_COEFFICIENTS[shDegree]!
265
+ let restAt: number[] = []
266
+ for (let c = 0; c < 3; c++) {
267
+ for (let k = 0; k < D; k++) {
268
+ let p = properties.get("f_rest_" + (c * fileD + k))!
269
+ if (!p.float) throw new Error("parseSplat: .ply property 'f_rest_" + (c * fileD + k) + "' must be float")
270
+ restAt.push(p.offset)
271
+ }
272
+ }
273
+
274
+ let base = headerEnd + 1
275
+ if (bytes.length < base + count * stride) throw new Error("parseSplat: .ply shorter than its " + count + " vertices")
276
+ let dv = new DataView(bytes.buffer, bytes.byteOffset + base, count * stride)
277
+ let out = emptyGaussians(count, true, shDegree)
278
+ let rec = 0
279
+ let read = (f: number) => dv.getFloat32(rec + at[f]!, true)
280
+ for (let i = 0; i < count; i++) {
281
+ rec = i * stride
282
+ if (out.sh !== null) {
283
+ for (let c = 0; c < 3; c++) {
284
+ for (let k = 0; k < D; k++) out.sh[(i * D + k) * 3 + c] = dv.getFloat32(rec + restAt[c * D + k]!, true)
285
+ }
286
+ }
287
+ out.positions[i * 3] = read(0)
288
+ out.positions[i * 3 + 1] = read(1)
289
+ out.positions[i * 3 + 2] = read(2)
290
+ out.colors[i * 4] = colorByte(0.5 + SH_C0 * read(3))
291
+ out.colors[i * 4 + 1] = colorByte(0.5 + SH_C0 * read(4))
292
+ out.colors[i * 4 + 2] = colorByte(0.5 + SH_C0 * read(5))
293
+ out.colors[i * 4 + 3] = colorByte(sigmoid(read(6)))
294
+ out.scales[i * 3] = Math.exp(read(7))
295
+ out.scales[i * 3 + 1] = Math.exp(read(8))
296
+ out.scales[i * 3 + 2] = Math.exp(read(9))
297
+ out.rotations[i * 4] = read(10)
298
+ out.rotations[i * 4 + 1] = read(11)
299
+ out.rotations[i * 4 + 2] = read(12)
300
+ out.rotations[i * 4 + 3] = read(13)
301
+ }
302
+ return out
303
+ }
304
+
305
+ // The .splat record: everything already activated (linear scales, sRGB
306
+ // color + opacity bytes), the quaternion (w, x, y, z) as unorm8 around 128.
307
+ function parseDotSplat(bytes: Uint8Array): Gaussians {
308
+ if (bytes.length % SPLAT_IN_BYTES !== 0) throw new Error("parseSplat: .splat length is not whole 32-byte records")
309
+ let count = bytes.length / SPLAT_IN_BYTES
310
+ let dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
311
+ let out = emptyGaussians(count, true, 0)
312
+ for (let i = 0; i < count; i++) {
313
+ let rec = i * SPLAT_IN_BYTES
314
+ for (let k = 0; k < 3; k++) {
315
+ out.positions[i * 3 + k] = dv.getFloat32(rec + k * 4, true)
316
+ out.scales[i * 3 + k] = dv.getFloat32(rec + 12 + k * 4, true)
317
+ }
318
+ for (let k = 0; k < 4; k++) {
319
+ out.colors[i * 4 + k] = bytes[rec + 24 + k]!
320
+ out.rotations[i * 4 + k] = (bytes[rec + 28 + k]! - 128) / 128
321
+ }
322
+ }
323
+ return out
324
+ }
325
+
326
+ // A gunzipped .spz: 16-byte header, then per-attribute blocks in file
327
+ // order - positions (24-bit fixed point), alphas (sigmoid as u8), colors
328
+ // (DC band around 0.5), scales (log sixteenths), rotations (version 2:
329
+ // first-three; version 3: smallest-three), SH (a byte per coefficient
330
+ // around 128, coefficient-major with rgb interleaved: coefficient k of
331
+ // channel c of splat i at (i * shDim + k) * 3 + c). spz stores y-up (RUB)
332
+ // already, so it never flips. `degree` caps the bands read.
333
+ function parseSpz(bytes: Uint8Array, degree: number): Gaussians {
334
+ if (bytes.length < SPZ_HEADER_BYTES) throw new Error("parseSplat: .spz too short for its header")
335
+ let dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
336
+ if (dv.getUint32(0, true) !== SPZ_MAGIC) throw new Error("parseSplat: bad .spz magic")
337
+ let version = dv.getUint32(4, true)
338
+ if (!SPZ_VERSIONS.includes(version)) {
339
+ throw new Error("parseSplat: .spz version " + version + " is not supported (version 2 or 3; 4+ moved to zstd streams)")
340
+ }
341
+ let count = dv.getUint32(8, true)
342
+ let shDegree = bytes[12]!
343
+ let fractionalBits = bytes[13]!
344
+ if (shDegree > 3) throw new Error("parseSplat: .spz SH degree " + shDegree + " out of range")
345
+ let rotBytes = version === 2 ? 3 : 4
346
+ let positionsAt = SPZ_HEADER_BYTES
347
+ let alphasAt = positionsAt + count * 9
348
+ let colorsAt = alphasAt + count
349
+ let scalesAt = colorsAt + count * 3
350
+ let rotationsAt = scalesAt + count * 3
351
+ let shAt = rotationsAt + count * rotBytes
352
+ let end = shAt + count * SPZ_SH_DIM[shDegree]! * 3
353
+ if (bytes.length < end) throw new Error("parseSplat: .spz shorter than its " + count + " splats")
354
+
355
+ let scale = 1 / (1 << fractionalBits)
356
+ let bakedDegree = Math.min(degree, shDegree)
357
+ let fileD = SPZ_SH_DIM[shDegree]!
358
+ let D = SH_COEFFICIENTS[bakedDegree]!
359
+ let out = emptyGaussians(count, false, bakedDegree)
360
+ let xyzw = [0, 0, 0, 0]
361
+ for (let i = 0; i < count; i++) {
362
+ if (out.sh !== null) {
363
+ for (let k = 0; k < D; k++) {
364
+ for (let c = 0; c < 3; c++) out.sh[(i * D + k) * 3 + c] = (bytes[shAt + (i * fileD + k) * 3 + c]! - SPZ_SH_SCALE) / SPZ_SH_SCALE
365
+ }
366
+ }
367
+ for (let k = 0; k < 3; k++) {
368
+ let at = positionsAt + (i * 3 + k) * 3
369
+ let fixed = bytes[at]! | (bytes[at + 1]! << 8) | (bytes[at + 2]! << 16)
370
+ if (fixed & 0x800000) fixed |= -0x1000000
371
+ out.positions[i * 3 + k] = fixed * scale
372
+ out.scales[i * 3 + k] = Math.exp(bytes[scalesAt + i * 3 + k]! / SPZ_SCALE_STEP - SPZ_SCALE_OFFSET)
373
+ let dc = (bytes[colorsAt + i * 3 + k]! / 255 - 0.5) / SPZ_COLOR_SCALE
374
+ out.colors[i * 4 + k] = colorByte(0.5 + SH_C0 * dc)
375
+ }
376
+ out.colors[i * 4 + 3] = bytes[alphasAt + i]!
377
+ let q = out.rotations
378
+ if (version === 2) {
379
+ let x = bytes[rotationsAt + i * 3]! / 127.5 - 1
380
+ let y = bytes[rotationsAt + i * 3 + 1]! / 127.5 - 1
381
+ let z = bytes[rotationsAt + i * 3 + 2]! / 127.5 - 1
382
+ q[i * 4] = Math.sqrt(Math.max(0, 1 - x * x - y * y - z * z))
383
+ q[i * 4 + 1] = x
384
+ q[i * 4 + 2] = y
385
+ q[i * 4 + 3] = z
386
+ } else {
387
+ // Smallest-three, unpacked into (x, y, z, w) order then stored
388
+ // (w, x, y, z): top two bits index the largest component, then
389
+ // three sign + 9-bit magnitudes, highest component index first.
390
+ let comp = dv.getUint32(rotationsAt + i * 4, true)
391
+ let mask = (1 << SPZ_ROT_MAG_BITS) - 1
392
+ let largest = comp >>> 30
393
+ let sum = 0
394
+ for (let k = 3; k >= 0; k--) {
395
+ if (k === largest) continue
396
+ let mag = comp & mask
397
+ let neg = (comp >>> SPZ_ROT_MAG_BITS) & 1
398
+ comp = comp >>> (SPZ_ROT_MAG_BITS + 1)
399
+ let v = (Math.SQRT1_2 * mag) / mask
400
+ xyzw[k] = neg === 1 ? -v : v
401
+ sum += v * v
402
+ }
403
+ xyzw[largest] = Math.sqrt(Math.max(0, 1 - sum))
404
+ q[i * 4] = xyzw[3]!
405
+ q[i * 4 + 1] = xyzw[0]!
406
+ q[i * 4 + 2] = xyzw[1]!
407
+ q[i * 4 + 3] = xyzw[2]!
408
+ }
409
+ }
410
+ return out
411
+ }
412
+
413
+ function emptyGaussians(count: number, flip: boolean, shDegree: number): Gaussians {
414
+ let D = SH_COEFFICIENTS[shDegree]!
415
+ return {
416
+ count,
417
+ positions: new Float32Array(count * 3),
418
+ scales: new Float32Array(count * 3),
419
+ rotations: new Float32Array(count * 4),
420
+ colors: new Uint8Array(count * 4),
421
+ shDegree,
422
+ sh: D > 0 ? new Float32Array(count * D * 3) : null,
423
+ flip,
424
+ }
425
+ }
426
+
427
+ // The bake: importance-sort (size times opacity, descending - the .splat
428
+ // convention, so a count prefix is a usable scene at any n), compute each
429
+ // splat's 3D covariance R S St Rt once, stand a y-down capture up
430
+ // (positions (x, -y, -z); the covariance conjugated by the same half-turn
431
+ // negates its xy and xz entries; the SH coefficients odd in (y, z) negate),
432
+ // measure bounds, write the records and the SH texels.
433
+ function bake(g: Gaussians): SplatData {
434
+ let n = g.count
435
+ let importance = new Float32Array(n)
436
+ for (let i = 0; i < n; i++) {
437
+ importance[i] = g.scales[i * 3]! * g.scales[i * 3 + 1]! * g.scales[i * 3 + 2]! * g.colors[i * 4 + 3]!
438
+ }
439
+ let order = new Uint32Array(n)
440
+ for (let i = 0; i < n; i++) order[i] = i
441
+ order.sort((a, b) => importance[b]! - importance[a]!)
442
+
443
+ let sy = g.flip ? -1 : 1
444
+ let records = new Uint8Array(n * SPLAT_RECORD_BYTES)
445
+ let dv = new DataView(records.buffer)
446
+ let D = SH_COEFFICIENTS[g.shDegree]!
447
+ let shBytes = SPLAT_SH_TEXELS[g.shDegree]! * SPLAT_TEXEL_BYTES
448
+ let sh = D > 0 ? new Uint8Array(n * shBytes) : null
449
+ let shdv = sh === null ? null : new DataView(sh.buffer)
450
+ let bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
451
+ let clampHalf = (v: number) => Math.min(F16_MAX, Math.max(-F16_MAX, v))
452
+ let half = (at: number, v: number) => dv.setFloat16(at, clampHalf(v), true)
453
+ for (let slot = 0; slot < n; slot++) {
454
+ let i = order[slot]!
455
+ let w = g.rotations[i * 4]!
456
+ let x = g.rotations[i * 4 + 1]!
457
+ let y = g.rotations[i * 4 + 2]!
458
+ let z = g.rotations[i * 4 + 3]!
459
+ let norm = Math.hypot(w, x, y, z)
460
+ if (norm > 0) {
461
+ w /= norm
462
+ x /= norm
463
+ y /= norm
464
+ z /= norm
465
+ } else {
466
+ w = 1
467
+ }
468
+ let sx = g.scales[i * 3]!
469
+ let syc = g.scales[i * 3 + 1]!
470
+ let sz = g.scales[i * 3 + 2]!
471
+ // M = R * diag(s); sigma = M * Mt, upper triangle.
472
+ let m00 = (1 - 2 * (y * y + z * z)) * sx
473
+ let m01 = 2 * (x * y - w * z) * syc
474
+ let m02 = 2 * (x * z + w * y) * sz
475
+ let m10 = 2 * (x * y + w * z) * sx
476
+ let m11 = (1 - 2 * (x * x + z * z)) * syc
477
+ let m12 = 2 * (y * z - w * x) * sz
478
+ let m20 = 2 * (x * z - w * y) * sx
479
+ let m21 = 2 * (y * z + w * x) * syc
480
+ let m22 = (1 - 2 * (x * x + y * y)) * sz
481
+ let xx = m00 * m00 + m01 * m01 + m02 * m02
482
+ let xy = (m00 * m10 + m01 * m11 + m02 * m12) * sy
483
+ let xz = (m00 * m20 + m01 * m21 + m02 * m22) * sy
484
+ let yy = m10 * m10 + m11 * m11 + m12 * m12
485
+ let yz = m10 * m20 + m11 * m21 + m12 * m22
486
+ let zz = m20 * m20 + m21 * m21 + m22 * m22
487
+
488
+ // The + 0 folds a flip's -0 to 0, so the header JSON round-trips.
489
+ let px = g.positions[i * 3]! + 0
490
+ let py = g.positions[i * 3 + 1]! * sy + 0
491
+ let pz = g.positions[i * 3 + 2]! * sy + 0
492
+ if (px < bounds[0]!) bounds[0] = px
493
+ if (py < bounds[1]!) bounds[1] = py
494
+ if (pz < bounds[2]!) bounds[2] = pz
495
+ if (px > bounds[3]!) bounds[3] = px
496
+ if (py > bounds[4]!) bounds[4] = py
497
+ if (pz > bounds[5]!) bounds[5] = pz
498
+
499
+ // Texel A: the center and the packed color; texel B: the six
500
+ // covariance halves and the spare word (left 0).
501
+ let at = slot * SPLAT_RECORD_BYTES
502
+ dv.setFloat32(at, px, true)
503
+ dv.setFloat32(at + 4, py, true)
504
+ dv.setFloat32(at + 8, pz, true)
505
+ records[at + 12] = g.colors[i * 4]!
506
+ records[at + 13] = g.colors[i * 4 + 1]!
507
+ records[at + 14] = g.colors[i * 4 + 2]!
508
+ records[at + 15] = g.colors[i * 4 + 3]!
509
+ half(at + 16, xx)
510
+ half(at + 18, xy)
511
+ half(at + 20, xz)
512
+ half(at + 22, yy)
513
+ half(at + 24, yz)
514
+ half(at + 26, zz)
515
+ // The SH halves, consecutive = packed pairwise into the texels' u32s
516
+ // (the low half first, as unpackHalf2x16 reads them).
517
+ if (shdv !== null) {
518
+ let base = slot * shBytes
519
+ for (let k = 0; k < D; k++) {
520
+ let sign = g.flip ? SH_FLIP_SIGN[k]! : 1
521
+ for (let c = 0; c < 3; c++) shdv.setFloat16(base + (k * 3 + c) * 2, clampHalf(g.sh![(i * D + k) * 3 + c]! * sign), true)
522
+ }
523
+ }
524
+ }
525
+ if (n === 0) bounds.fill(0)
526
+ return { count: n, records, bounds, shDegree: g.shDegree, sh }
527
+ }
528
+
529
+ type Block = { offset: number; bytes: number }
530
+
531
+ type Header = { count: number; shDegree: number; bounds: number[]; records: Block; sh?: Block }
532
+
533
+ // The SH block's byte length for a cloud: its texels per splat times the
534
+ // count; 0 at degree 0.
535
+ function shBytesOf(count: number, shDegree: number): number {
536
+ return count * SPLAT_SH_TEXELS[shDegree]! * SPLAT_TEXEL_BYTES
537
+ }
538
+
539
+ /**
540
+ * Serialize a splat cloud into the .srts container, all little-endian:
541
+ * "SRTS" u32 | version u32 | jsonLength u32 | json (padded to 4) |
542
+ * payload. The JSON header carries count, shDegree, bounds and the byte
543
+ * range of the record block and (above degree 0) the SH block; the
544
+ * payload is both blocks verbatim.
545
+ */
546
+ export function encodeSplat(data: SplatData): Uint8Array {
547
+ if (data.records.byteLength !== data.count * SPLAT_RECORD_BYTES) {
548
+ throw new Error("encodeSplat: records hold " + data.records.byteLength + " bytes, not " + data.count + " whole splat records")
549
+ }
550
+ if (data.bounds.length !== 6) throw new Error("encodeSplat: bounds must be [minX, minY, minZ, maxX, maxY, maxZ]")
551
+ if (!(Number.isInteger(data.shDegree) && data.shDegree >= 0 && data.shDegree <= 3)) throw new Error("encodeSplat: shDegree must be 0..3, got " + data.shDegree)
552
+ let shBytes = shBytesOf(data.count, data.shDegree)
553
+ if ((data.sh?.byteLength ?? 0) !== shBytes) {
554
+ throw new Error("encodeSplat: the SH block holds " + (data.sh?.byteLength ?? 0) + " bytes; degree " + data.shDegree + " over " + data.count + " splats needs " + shBytes)
555
+ }
556
+ let header: Header = {
557
+ count: data.count,
558
+ shDegree: data.shDegree,
559
+ bounds: Array.from(data.bounds),
560
+ records: { offset: 0, bytes: data.records.byteLength },
561
+ }
562
+ if (shBytes > 0) header.sh = { offset: data.records.byteLength, bytes: shBytes }
563
+ let json = new TextEncoder().encode(JSON.stringify(header))
564
+ let jsonPadded = json.byteLength + ((4 - (json.byteLength % 4)) % 4)
565
+ let out = new Uint8Array(12 + jsonPadded + data.records.byteLength + shBytes)
566
+ let dv = new DataView(out.buffer)
567
+ dv.setUint32(0, MAGIC, true)
568
+ dv.setUint32(4, VERSION, true)
569
+ dv.setUint32(8, json.byteLength, true)
570
+ out.set(json, 12)
571
+ out.set(data.records, 12 + jsonPadded)
572
+ if (data.sh !== null && shBytes > 0) out.set(data.sh, 12 + jsonPadded + data.records.byteLength)
573
+ return out
574
+ }
575
+
576
+ /**
577
+ * Read a .srts container back into SplatData. The records and the SH
578
+ * block are VIEWS onto `bytes` (copied once only when the input is not
579
+ * 4-aligned), so the bytes must outlive the data - which is the point:
580
+ * loading a bake is a header parse, and the views upload as-is.
581
+ */
582
+ export function decodeSplat(bytes: Uint8Array): SplatData {
583
+ if (bytes.byteOffset % 4 !== 0) bytes = new Uint8Array(bytes)
584
+ if (bytes.byteLength < 12) throw new Error("decodeSplat: not a splat file (too short)")
585
+ let dv = new DataView(bytes.buffer, bytes.byteOffset, 12)
586
+ if (dv.getUint32(0, true) !== MAGIC) throw new Error("decodeSplat: not a splat file (bad magic)")
587
+ let version = dv.getUint32(4, true)
588
+ if (version !== VERSION) throw new Error("decodeSplat: version " + version + ", expected " + VERSION + " - re-bake with `srt tool 3d/splat`")
589
+ let jsonLength = dv.getUint32(8, true)
590
+ let header: Header = JSON.parse(new TextDecoder().decode(bytes.subarray(12, 12 + jsonLength)))
591
+ let payload = 12 + jsonLength + ((4 - (jsonLength % 4)) % 4)
592
+ if (header.records.bytes !== header.count * SPLAT_RECORD_BYTES) {
593
+ throw new Error("decodeSplat: record block holds " + header.records.bytes + " bytes, not " + header.count + " whole splat records")
594
+ }
595
+ if (bytes.byteLength < payload + header.records.offset + header.records.bytes) {
596
+ throw new Error("decodeSplat: shorter than its " + header.count + " records")
597
+ }
598
+ if (!(Number.isInteger(header.shDegree) && header.shDegree >= 0 && header.shDegree <= 3)) {
599
+ throw new Error("decodeSplat: shDegree " + header.shDegree + " out of range")
600
+ }
601
+ let shBytes = shBytesOf(header.count, header.shDegree)
602
+ let sh: Uint8Array | null = null
603
+ if (shBytes > 0) {
604
+ if (header.sh === undefined || header.sh.bytes !== shBytes) {
605
+ throw new Error("decodeSplat: SH block holds " + (header.sh?.bytes ?? 0) + " bytes; degree " + header.shDegree + " over " + header.count + " splats needs " + shBytes)
606
+ }
607
+ if (bytes.byteLength < payload + header.sh.offset + shBytes) throw new Error("decodeSplat: shorter than its SH block")
608
+ sh = bytes.subarray(payload + header.sh.offset, payload + header.sh.offset + shBytes)
609
+ }
610
+ return {
611
+ count: header.count,
612
+ records: bytes.subarray(payload + header.records.offset, payload + header.records.offset + header.records.bytes),
613
+ bounds: Float32Array.from(header.bounds),
614
+ shDegree: header.shDegree,
615
+ sh,
616
+ }
617
+ }