@solidrt/3d 0.0.62 → 0.0.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +198 -17
- package/package.json +3 -2
- package/src/camera.ts +6 -2
- package/src/components/directional-light.tsx +6 -2
- package/src/components/hemisphere-light.tsx +12 -4
- package/src/components/index.ts +2 -0
- package/src/components/instanced-mesh.tsx +6 -2
- package/src/components/mesh.tsx +12 -1
- package/src/components/point-light.tsx +6 -2
- package/src/components/record-mesh.tsx +14 -2
- package/src/components/scene.tsx +8 -1
- package/src/components/splat-mesh.tsx +50 -0
- package/src/components/spot-light.tsx +7 -3
- package/src/environment.ts +55 -0
- package/src/glsl.ts +68 -14
- package/src/index.ts +9 -4
- package/src/light.ts +56 -4
- package/src/material.ts +26 -1
- package/src/mesh.ts +361 -6
- package/src/node.ts +3 -0
- package/src/scene-shadows.ts +8 -7
- package/src/scene.ts +225 -104
- package/src/splat-data.ts +617 -0
- package/src/splat.ts +401 -0
- package/tools/splat.ts +70 -0
|
@@ -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 under bun and fails
|
|
7
|
+
// the moment a runtime 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
|
+
}
|