Skip to content

Commit 1b0275d

Browse files
committed
Introduce SPZ transcoding for viewer example
1 parent b6338f4 commit 1b0275d

File tree

5 files changed

+536
-4
lines changed

5 files changed

+536
-4
lines changed

examples/viewer/index.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@
185185
</script>
186186
<script type="module">
187187
import * as THREE from "three";
188-
import { SplatLoader } from "@sparkjsdev/spark";
188+
import { SplatLoader, transcodeSpz } from "@sparkjsdev/spark";
189189
import { SparkControls } from "/examples/js/controls.js";
190190
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
191191

@@ -272,7 +272,8 @@
272272
async function loadSplatFile(splatFile) {
273273
fileBytes = await splatFile.arrayBuffer();
274274
fileName = splatFile.name;
275-
setSplatFile(fileBytes, fileName);
275+
// Create copy of fileBytes to transfer to the worker
276+
setSplatFile(fileBytes.slice(), fileName);
276277
}
277278

278279
var loadedSplat;
@@ -317,7 +318,7 @@
317318

318319
document.querySelector('.spz-button').addEventListener('click', async function () {
319320
const fileInfo = {
320-
fileBytes: fileBytes,
321+
fileBytes: new Uint8Array(fileBytes),
321322
pathOrUrl: fileName,
322323
};
323324

src/Splat.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,6 @@ export class Splat extends THREE.Mesh<SplatGeometry, THREE.ShaderMaterial> {
327327
lastOriginToCamera: new THREE.Matrix4(),
328328
sortJob: null,
329329
ordering: new Uint32Array(this.splatData.maxSplats),
330-
// FIXME: Use FreeList instead of double buffering
331330
pendingOrdering: new Uint32Array(this.splatData.maxSplats),
332331
activeSplats: 0,
333332
orderingId: globalOrderingId++,

src/formats/spz.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as THREE from "three";
12
import { SH_C0, SH_DEGREE_TO_NUM_COEFF } from "../defines";
23
import type { SplatEncoder, UnpackResult } from "../encoding/encoder";
34
import { GunzipReader, fromHalf } from "../utils";
@@ -232,3 +233,201 @@ export class SpzReader {
232233
}
233234
}
234235
}
236+
237+
// SPZ file format writer
238+
239+
export const SPZ_MAGIC = 0x5053474e; // NGSP = Niantic gaussian splat
240+
export const SPZ_VERSION = 3;
241+
export const FLAG_ANTIALIASED = 0x1;
242+
243+
export class SpzWriter {
244+
private buffer: ArrayBuffer;
245+
private view: DataView;
246+
private numSplats: number;
247+
readonly shDegree: number;
248+
private fractionalBits: number;
249+
private fraction: number;
250+
private flagAntiAlias: boolean;
251+
clippedCount = 0;
252+
253+
constructor({
254+
numSplats,
255+
shDegree,
256+
fractionalBits = 12,
257+
flagAntiAlias = true,
258+
}: {
259+
numSplats: number;
260+
shDegree: number;
261+
fractionalBits?: number;
262+
flagAntiAlias?: boolean;
263+
}) {
264+
const splatSize =
265+
9 + // Position
266+
1 + // Opacity
267+
3 + // Scale
268+
3 + // DC-rgb
269+
4 + // Rotation
270+
SH_DEGREE_TO_NUM_COEFF[shDegree];
271+
const bufferSize = 16 + numSplats * splatSize;
272+
this.buffer = new ArrayBuffer(bufferSize);
273+
this.view = new DataView(this.buffer);
274+
275+
this.view.setUint32(0, SPZ_MAGIC, true); // NGSP
276+
this.view.setUint32(4, SPZ_VERSION, true);
277+
this.view.setUint32(8, numSplats, true);
278+
this.view.setUint8(12, shDegree);
279+
this.view.setUint8(13, fractionalBits);
280+
this.view.setUint8(14, flagAntiAlias ? FLAG_ANTIALIASED : 0);
281+
this.view.setUint8(15, 0); // Reserved
282+
283+
this.numSplats = numSplats;
284+
this.shDegree = shDegree;
285+
this.fractionalBits = fractionalBits;
286+
this.fraction = 1 << fractionalBits;
287+
this.flagAntiAlias = flagAntiAlias;
288+
}
289+
290+
setCenter(index: number, x: number, y: number, z: number) {
291+
// Divide by this.fraction and round to nearest integer,
292+
// then write as 3-bytes per x then y then z.
293+
const xRounded = Math.round(x * this.fraction);
294+
const xInt = Math.max(-0x7fffff, Math.min(0x7fffff, xRounded));
295+
const yRounded = Math.round(y * this.fraction);
296+
const yInt = Math.max(-0x7fffff, Math.min(0x7fffff, yRounded));
297+
const zRounded = Math.round(z * this.fraction);
298+
const zInt = Math.max(-0x7fffff, Math.min(0x7fffff, zRounded));
299+
const clipped = xRounded !== xInt || yRounded !== yInt || zRounded !== zInt;
300+
if (clipped) {
301+
this.clippedCount += 1;
302+
}
303+
const i9 = index * 9;
304+
const base = 16 + i9;
305+
this.view.setUint8(base, xInt & 0xff);
306+
this.view.setUint8(base + 1, (xInt >> 8) & 0xff);
307+
this.view.setUint8(base + 2, (xInt >> 16) & 0xff);
308+
this.view.setUint8(base + 3, yInt & 0xff);
309+
this.view.setUint8(base + 4, (yInt >> 8) & 0xff);
310+
this.view.setUint8(base + 5, (yInt >> 16) & 0xff);
311+
this.view.setUint8(base + 6, zInt & 0xff);
312+
this.view.setUint8(base + 7, (zInt >> 8) & 0xff);
313+
this.view.setUint8(base + 8, (zInt >> 16) & 0xff);
314+
}
315+
316+
setAlpha(index: number, alpha: number) {
317+
const base = 16 + this.numSplats * 9 + index;
318+
this.view.setUint8(
319+
base,
320+
Math.max(0, Math.min(255, Math.round(alpha * 255))),
321+
);
322+
}
323+
324+
static scaleRgb(r: number) {
325+
const v = ((r - 0.5) / (SH_C0 / 0.15) + 0.5) * 255;
326+
return Math.max(0, Math.min(255, Math.round(v)));
327+
}
328+
329+
setRgb(index: number, r: number, g: number, b: number) {
330+
const base = 16 + this.numSplats * 10 + index * 3;
331+
this.view.setUint8(base, SpzWriter.scaleRgb(r));
332+
this.view.setUint8(base + 1, SpzWriter.scaleRgb(g));
333+
this.view.setUint8(base + 2, SpzWriter.scaleRgb(b));
334+
}
335+
336+
setScale(index: number, scaleX: number, scaleY: number, scaleZ: number) {
337+
const base = 16 + this.numSplats * 13 + index * 3;
338+
this.view.setUint8(
339+
base,
340+
Math.max(0, Math.min(255, Math.round((Math.log(scaleX) + 10) * 16))),
341+
);
342+
this.view.setUint8(
343+
base + 1,
344+
Math.max(0, Math.min(255, Math.round((Math.log(scaleY) + 10) * 16))),
345+
);
346+
this.view.setUint8(
347+
base + 2,
348+
Math.max(0, Math.min(255, Math.round((Math.log(scaleZ) + 10) * 16))),
349+
);
350+
}
351+
352+
setQuat(
353+
index: number,
354+
...q: [number, number, number, number] // x, y, z, w
355+
) {
356+
const base = 16 + this.numSplats * 16 + index * 4;
357+
358+
const quat = normalize(q);
359+
360+
// Find largest component
361+
let iLargest = 0;
362+
for (let i = 1; i < 4; ++i) {
363+
if (Math.abs(quat[i]) > Math.abs(quat[iLargest])) {
364+
iLargest = i;
365+
}
366+
}
367+
368+
// Since -quat represents the same rotation as quat, transform the quaternion so the largest element
369+
// is positive. This avoids having to send its sign bit.
370+
const negate = quat[iLargest] < 0 ? 1 : 0;
371+
372+
// Do compression using sign bit and 9-bit precision per element.
373+
let comp = iLargest;
374+
for (let i = 0; i < 4; ++i) {
375+
if (i !== iLargest) {
376+
const negbit = (quat[i] < 0 ? 1 : 0) ^ negate;
377+
const mag = Math.floor(
378+
((1 << 9) - 1) * (Math.abs(quat[i]) / Math.SQRT1_2) + 0.5,
379+
);
380+
comp = (comp << 10) | (negbit << 9) | mag;
381+
}
382+
}
383+
384+
this.view.setUint8(base, comp & 0xff);
385+
this.view.setUint8(base + 1, (comp >> 8) & 0xff);
386+
this.view.setUint8(base + 2, (comp >> 16) & 0xff);
387+
this.view.setUint8(base + 3, (comp >>> 24) & 0xff);
388+
}
389+
390+
static quantizeSh(sh: number, bits: number) {
391+
const value = Math.round(sh * 128) + 128;
392+
const bucketSize = 1 << (8 - bits);
393+
const quantized =
394+
Math.floor((value + bucketSize / 2) / bucketSize) * bucketSize;
395+
return Math.max(0, Math.min(255, quantized));
396+
}
397+
398+
setSh(index: number, sh: ArrayLike<number>) {
399+
const base =
400+
16 + this.numSplats * 20 + index * SH_DEGREE_TO_NUM_COEFF[this.shDegree];
401+
for (let i = 0; i < SH_DEGREE_TO_NUM_COEFF[this.shDegree]; ++i) {
402+
this.view.setUint8(base + i, SpzWriter.quantizeSh(sh[i], i >= 9 ? 4 : 5));
403+
}
404+
}
405+
406+
async finalize(): Promise<Uint8Array> {
407+
const input = new Uint8Array(this.buffer);
408+
const stream = new ReadableStream({
409+
async start(controller) {
410+
controller.enqueue(input);
411+
controller.close();
412+
},
413+
});
414+
const compressed = stream.pipeThrough(new CompressionStream("gzip"));
415+
const response = new Response(compressed);
416+
const buffer = await response.arrayBuffer();
417+
console.log(
418+
"Compressed",
419+
input.length,
420+
"bytes to",
421+
buffer.byteLength,
422+
"bytes",
423+
);
424+
return new Uint8Array(buffer);
425+
}
426+
}
427+
428+
const tempQuat = new THREE.Quaternion();
429+
function normalize(
430+
quat: [number, number, number, number],
431+
): [number, number, number, number] {
432+
return tempQuat.fromArray(quat).normalize().toArray(quat);
433+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ export * from "./raycast";
1111
export type { SplatEncoder, ResizableSplatEncoder } from "./encoding/encoder";
1212
export { PackedSplats } from "./encoding/PackedSplats";
1313
export { ExtendedSplats } from "./encoding/ExtendedSplats";
14+
15+
export { transcodeSpz } from "./transcode";

0 commit comments

Comments
 (0)