Add SPZ v3 smallest 3 quaternion encoding support (#171)

Co-authored-by: George Ash <george@nianticspatial.com>
This commit is contained in:
Greg Ash
2025-09-22 22:38:18 +01:00
committed by GitHub
parent bd4ac3665f
commit ccd2dd1243
2 changed files with 48 additions and 38 deletions
+42 -38
View File
@@ -6,7 +6,7 @@ import {
getSplatFileType, getSplatFileType,
getSplatFileTypeFromPath, getSplatFileTypeFromPath,
} from "./SplatLoader"; } from "./SplatLoader";
import { GunzipReader, fromHalf, unpackSplat } from "./utils"; import { GunzipReader, fromHalf, normalize, unpackSplat } from "./utils";
import { decodeAntiSplat } from "./antisplat"; import { decodeAntiSplat } from "./antisplat";
import { decodeKsplat } from "./ksplat"; import { decodeKsplat } from "./ksplat";
@@ -149,9 +149,7 @@ export class SpzReader {
} }
if (this.version === 3) { if (this.version === 3) {
// Version 3 uses a trick called "smallest three" to compress the rotation quaternions // Version 3 uses a trick called "smallest three" to compress the rotation quaternions
// achieving better precision. // achieving better precision. "Optimizing orientation" section at https://gafferongames.com/post/snapshot_compression/ A quaternion length must be 1: x^2+y^2+z^2+w^2 = 1
// "Optimizing orientation" section at https://gafferongames.com/post/snapshot_compression/
// A quaternion length must be 1: x^2+y^2+z^2+w^2 = 1
// We can drop one component and reconstruct it with the identity above. // We can drop one component and reconstruct it with the identity above.
// Largest component is dropped for best numerical precision. // Largest component is dropped for best numerical precision.
// Quaternion stored in 32 bits // Quaternion stored in 32 bits
@@ -262,7 +260,7 @@ const SH_DEGREE_TO_VECS: Record<number, number> = { 1: 3, 2: 8, 3: 15 };
const SH_C0 = 0.28209479177387814; const SH_C0 = 0.28209479177387814;
export const SPZ_MAGIC = 0x5053474e; // NGSP = Niantic gaussian splat export const SPZ_MAGIC = 0x5053474e; // NGSP = Niantic gaussian splat
export const SPZ_VERSION = 2; export const SPZ_VERSION = 3;
export const FLAG_ANTIALIASED = 0x1; export const FLAG_ANTIALIASED = 0x1;
export class SpzWriter { export class SpzWriter {
@@ -287,11 +285,11 @@ export class SpzWriter {
flagAntiAlias?: boolean; flagAntiAlias?: boolean;
}) { }) {
const splatSize = const splatSize =
9 + 9 + // Position
1 + 1 + // Opacity
3 + 3 + // Scale
3 + 3 + // DC-rgb
3 + 4 + // Rotation
(shDegree >= 1 ? 9 : 0) + (shDegree >= 1 ? 9 : 0) +
(shDegree >= 2 ? 15 : 0) + (shDegree >= 2 ? 15 : 0) +
(shDegree >= 3 ? 21 : 0); (shDegree >= 3 ? 21 : 0);
@@ -382,34 +380,40 @@ export class SpzWriter {
setQuat( setQuat(
index: number, index: number,
quatX: number, ...q: [number, number, number, number] // x, y, z, w
quatY: number,
quatZ: number,
quatW: number,
) { ) {
const base = 16 + this.numSplats * 16 + index * 3; const base = 16 + this.numSplats * 16 + index * 4;
const quatNeg = quatW < 0;
this.view.setUint8( const quat = normalize(q);
base,
Math.max( // Find largest component
0, let iLargest = 0;
Math.min(255, Math.round(((quatNeg ? -quatX : quatX) + 1) * 127.5)), for (let i = 1; i < 4; ++i) {
), if (Math.abs(quat[i]) > Math.abs(quat[iLargest])) {
); iLargest = i;
this.view.setUint8( }
base + 1, }
Math.max(
0, // Since -quat represents the same rotation as quat, transform the quaternion so the largest element
Math.min(255, Math.round(((quatNeg ? -quatY : quatY) + 1) * 127.5)), // is positive. This avoids having to send its sign bit.
), const negate = quat[iLargest] < 0 ? 1 : 0;
);
this.view.setUint8( // Do compression using sign bit and 9-bit precision per element.
base + 2, let comp = iLargest;
Math.max( for (let i = 0; i < 4; ++i) {
0, if (i !== iLargest) {
Math.min(255, Math.round(((quatNeg ? -quatZ : quatZ) + 1) * 127.5)), const negbit = (quat[i] < 0 ? 1 : 0) ^ negate;
), const mag = Math.floor(
); ((1 << 9) - 1) * (Math.abs(quat[i]) / Math.SQRT1_2) + 0.5,
);
comp = (comp << 10) | (negbit << 9) | mag;
}
}
this.view.setUint8(base, comp & 0xff);
this.view.setUint8(base + 1, (comp >> 8) & 0xff);
this.view.setUint8(base + 2, (comp >> 16) & 0xff);
this.view.setUint8(base + 3, (comp >>> 24) & 0xff);
} }
static quantizeSh(sh: number, bits: number) { static quantizeSh(sh: number, bits: number) {
@@ -427,7 +431,7 @@ export class SpzWriter {
sh3?: Float32Array, sh3?: Float32Array,
) { ) {
const shVecs = SH_DEGREE_TO_VECS[this.shDegree] || 0; const shVecs = SH_DEGREE_TO_VECS[this.shDegree] || 0;
const base1 = 16 + this.numSplats * 19 + index * shVecs * 3; const base1 = 16 + this.numSplats * 20 + index * shVecs * 3;
for (let j = 0; j < 9; ++j) { for (let j = 0; j < 9; ++j) {
this.view.setUint8(base1 + j, SpzWriter.quantizeSh(sh1[j], 5)); this.view.setUint8(base1 + j, SpzWriter.quantizeSh(sh1[j], 5));
} }
+6
View File
@@ -21,6 +21,12 @@ const f16buffer = supportsFloat16Array
: null; : null;
const u16buffer = new Uint16Array(f16buffer?.buffer); const u16buffer = new Uint16Array(f16buffer?.buffer);
// Returns a normalized array of numbers
export function normalize(vec: number[]) {
const norm = Math.sqrt(vec.reduce((acc, v) => acc + v * v, 0));
return vec.map((v) => v / norm);
}
// Reinterpret the bits of a float32 as a uint32 // Reinterpret the bits of a float32 as a uint32
export function floatBitsToUint(f: number): number { export function floatBitsToUint(f: number): number {
f32buffer[0] = f; f32buffer[0] = f;