Serve frontend JS from javascript/, keep vendor bundles out of the main realm

Move web/ to javascript/ and declare it via [tool.comfy] web.

Rename the seven third-party bundles (playcanvas, spark, three.js, gsplat,
OrbitControls, TrackballControls) to .mjs. Every reference to them is inside the
HTML viewers, but ComfyUI auto-imports **/*.js under the web dir into the shared
browser realm, so ~8MB of vendor code was being evaluated in the main page on
every startup -- and with it 36 collision errors: document.body injection,
document-level listeners, unguarded message listeners and self.onmessage writes.
The importmap values, script srcs and three.module's internal ./three.core ref
are rewritten to match.

DisplayName becomes "GaussianPack": the namespace enforced on
registerExtension names is derived from it, and "ComfyUI-GaussianPack" made
that prefix invalid. The analysis extension is renamed to match.

js/gaussian_preview.js hooked "PlyPreviewLoadGaussianPLYEnhance", a node id
that appears nowhere in this repo, so the PLY dropdown auto-refresh had never
run; it now targets LoadPLY and LoadPLYOutput, the two nodes that actually carry
the ply_file widget. Guard its message listener on event.source.

Asset seeding resolves the input directory through
folder_paths.get_input_directory(), so it follows ComfyUI Desktop and
--input-directory instead of writing into the code tree.

comfy-test.toml moves to the current schema: [test.workflows] cuda (there is
no "gpu" key -- the accelerator is named by backend, and the old spelling parsed
to an empty list, so CUDA runners silently ran the full unfiltered set), an
explicit [test.platforms] platforms allowlist (per-platform booleans are no
longer supported), and the javascript level.
This commit is contained in:
PozzettiAndrea
2026-08-22 20:56:09 +02:00
parent 39e4b5ebcd
commit a342f20402
44 changed files with 66 additions and 45 deletions
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Convert a 3DGS PLY to SPZ (v2, gzipped — mkkellogg-compatible).
*
* usage: node ply_to_spz.mjs <input.ply> <output.spz>
*
* Uses the vendored spz-js library; no external npm install needed.
*/
import { createReadStream, writeFileSync, statSync } from "node:fs";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Import the sub-modules directly so we don't trip the `playcanvas`
// dep that the `compressed-ply-*` modules pull in (we don't need them).
const { loadPly } = await import(resolve(__dirname, "spz-js/dist/ply-loader.js"));
const { serializeSpz } = await import(resolve(__dirname, "spz-js/dist/spz-serializer.js"));
const [, , inPath, outPath] = process.argv;
if (!inPath || !outPath) {
console.error("usage: node ply_to_spz.mjs <input.ply> <output.spz>");
process.exit(2);
}
const t0 = Date.now();
const stream = createReadStream(inPath);
const web = Readable.toWeb(stream);
const gs = await loadPly(web);
const tLoad = (Date.now() - t0) / 1000;
const t1 = Date.now();
const buf = await serializeSpz(gs);
const tSer = (Date.now() - t1) / 1000;
writeFileSync(outPath, Buffer.from(buf));
const inMB = statSync(inPath).size / (1024 * 1024);
const outMB = statSync(outPath).size / (1024 * 1024);
console.log(JSON.stringify({
numPoints: gs.numPoints,
plyMB: +inMB.toFixed(2),
spzMB: +outMB.toFixed(2),
ratio: +(inMB / outMB).toFixed(2),
loadSec: +tLoad.toFixed(2),
serSec: +tSer.toFixed(2),
}));
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Stratum1 GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+75
View File
@@ -0,0 +1,75 @@
# Library to convert gaussian splats between PLY and SPZ formats
Based on the [original C++ library](https://github.com/nianticlabs/spz).
## Installation
[![NPM](https://nodei.co/npm/spz-js.png)](https://npmjs.org/package/spz-js)
```bash
npm install spz-js
```
## Example (Node.js)
```javascript
import {
createReadStream,
readFileSync,
writeFileSync,
unlinkSync
} from 'fs';
import { Readable } from 'stream';
import path from 'path';
import {
loadPly,
loadSpz,
serializePly,
serializeSpz
} from 'spz-js';
// Helper function to load either SPZ or PLY files
const loadFile = async (file) => {
const extension = path.extname(file);
if (extension === '.spz') {
const fileBuffer = readFileSync(file);
return await loadSpz(fileBuffer);
} else if (extension === '.ply') {
const fileStream = createReadStream(file);
const webStream = Readable.toWeb(fileStream);
return await loadPly(webStream);
}
throw new Error(`Unsupported file extension: ${extension}`);
};
const gs = await loadFile("gs.ply"); // or gs.spz
const plyData = serializePly(gs);
writeFileSync("gs.ply", Buffer.from(plyData));
const spzData = await serializeSpz(gs);
writeFileSync("gs.spz", Buffer.from(spzData));
```
## Example (Browser)
```javascript
import {
loadPly,
loadSpz
} from 'spz-js';
const loadFile = async (url) => {
const response = await fetch(url);
const extension = url.split('.').pop();
if (extension === 'spz') {
const buffer = await response.arrayBuffer();
return await loadSpz(buffer);
} else if (extension === 'ply') {
return await loadPly(response.body);
}
throw new Error(`Unsupported file extension: ${extension}`);
};
```
@@ -0,0 +1,2 @@
import { GaussianCloud } from "./types.js";
export declare function loadCompressedPly(stream: ReadableStream<Uint8Array>): Promise<GaussianCloud>;
@@ -0,0 +1,474 @@
// This file is largely based on the ply.js and gsplat-compressed-data.js files from "playcanvas".
import { Quat, Vec4, Vec3 } from "playcanvas";
import { dimForDegree, SH_C0 } from "./constant.js";
const magicBytes = new Uint8Array([112, 108, 121, 10]); // ply\n
const endHeaderBytes = new Uint8Array([10, 101, 110, 100, 95, 104, 101, 97, 100, 101, 114, 10]); // \nend_header\n
const dataTypeMap = new Map([
['char', Int8Array],
['uchar', Uint8Array],
['short', Int16Array],
['ushort', Uint16Array],
['int', Int32Array],
['uint', Uint32Array],
['float', Float32Array],
['double', Float64Array]
]);
// parse the ply header text and return an array of Element structures and a
// string containing the ply format
const parseHeader = (lines) => {
const elements = [];
let format = null;
for (let i = 1; i < lines.length; ++i) {
const words = lines[i].split(' ');
switch (words[0]) {
case 'format':
format = words[1];
break;
case 'element':
elements.push({
name: words[1],
count: parseInt(words[2], 10),
properties: []
});
break;
case 'property': {
if (!dataTypeMap.has(words[1])) {
throw new Error(`Unrecognized property data type '${words[1]}' in ply header`);
}
const element = elements[elements.length - 1];
element.properties.push({
type: words[1],
name: words[2],
storage: null,
byteSize: dataTypeMap.get(words[1]).BYTES_PER_ELEMENT
});
break;
}
default:
throw new Error(`Unrecognized header value '${words[0]}' in ply header`);
}
}
return { elements, format };
};
const isCompressedPly = (elements) => {
const chunkProperties = [
'min_x', 'min_y', 'min_z',
'max_x', 'max_y', 'max_z',
'min_scale_x', 'min_scale_y', 'min_scale_z',
'max_scale_x', 'max_scale_y', 'max_scale_z',
'min_r', 'min_g', 'min_b',
'max_r', 'max_g', 'max_b'
];
const vertexProperties = [
'packed_position', 'packed_rotation', 'packed_scale', 'packed_color'
];
const shProperties = new Array(45).fill('').map((_, i) => `f_rest_${i}`);
const hasBaseElements = () => {
return elements[0].name === 'chunk' &&
elements[0].properties.every((p, i) => p.name === chunkProperties[i] && p.type === 'float') &&
elements[1].name === 'vertex' &&
elements[1].properties.every((p, i) => p.name === vertexProperties[i] && p.type === 'uint');
};
const hasSHElements = () => {
return elements[2].name === 'sh' &&
[9, 24, 45].indexOf(elements[2].properties.length) !== -1 &&
elements[2].properties.every((p, i) => p.name === shProperties[i] && p.type === 'uchar');
};
return (elements.length === 2 && hasBaseElements()) || (elements.length === 3 && hasBaseElements() && hasSHElements());
};
// helper for streaming in chunks of data in a memory efficient way
class StreamBuf {
reader;
data;
view;
head = 0;
tail = 0;
constructor(reader) {
this.reader = reader;
}
// read the next chunk of data
async read() {
const { value, done } = await this.reader.read();
if (done) {
throw new Error('Stream finished before end of header');
}
this.push(value);
}
// append data to the buffer
push(data) {
if (!this.data) {
// first buffer
this.data = data;
this.view = new DataView(this.data.buffer);
this.tail = data.length;
}
else {
const remaining = this.tail - this.head;
const newSize = remaining + data.length;
if (this.data.length >= newSize) {
// buffer is large enough to contain combined data
if (this.head > 0) {
// shuffle existing data to index 0 and append the new data
this.data.copyWithin(0, this.head, this.tail);
this.data.set(data, remaining);
this.head = 0;
this.tail = newSize;
}
else {
// no shuffle needed, just append new data
this.data.set(data, this.tail);
this.tail += data.length;
}
}
else {
// buffer is too small and must grow
const tmp = new Uint8Array(newSize);
if (this.head > 0 || this.tail < this.data.length) {
// shuffle existing data to index 0 and append the new data
tmp.set(this.data.subarray(this.head, this.tail), 0);
}
else {
tmp.set(this.data, 0);
}
tmp.set(data, remaining);
this.data = tmp;
this.view = new DataView(this.data.buffer);
this.head = 0;
this.tail = newSize;
}
}
}
// remove the read data from the head of the buffer
compact() {
if (this.head > 0) {
this.data.copyWithin(0, this.head, this.tail);
this.tail -= this.head;
this.head = 0;
}
}
get remaining() {
return this.tail - this.head;
}
// helpers for extracting data from head
getInt8() {
const result = this.view.getInt8(this.head);
this.head++;
return result;
}
getUint8() {
const result = this.view.getUint8(this.head);
this.head++;
return result;
}
getInt16() {
const result = this.view.getInt16(this.head, true);
this.head += 2;
return result;
}
getUint16() {
const result = this.view.getUint16(this.head, true);
this.head += 2;
return result;
}
getInt32() {
const result = this.view.getInt32(this.head, true);
this.head += 4;
return result;
}
getUint32() {
const result = this.view.getUint32(this.head, true);
this.head += 4;
return result;
}
getFloat32() {
const result = this.view.getFloat32(this.head, true);
this.head += 4;
return result;
}
getFloat64() {
const result = this.view.getFloat64(this.head, true);
this.head += 8;
return result;
}
}
class SplatCompressedIterator {
read;
constructor(gsplatData, p, r, s, c, sh) {
const unpackUnorm = (value, bits) => {
const t = (1 << bits) - 1;
return (value & t) / t;
};
const unpack111011 = (result, value) => {
result.x = unpackUnorm(value >>> 21, 11);
result.y = unpackUnorm(value >>> 11, 10);
result.z = unpackUnorm(value, 11);
};
const unpack8888 = (result, value) => {
result.x = unpackUnorm(value >>> 24, 8);
result.y = unpackUnorm(value >>> 16, 8);
result.z = unpackUnorm(value >>> 8, 8);
result.w = unpackUnorm(value, 8);
};
// unpack quaternion with 2,10,10,10 format (largest element, 3x10bit element)
const unpackRot = (result, value) => {
const norm = 1.0 / (Math.sqrt(2) * 0.5);
const a = (unpackUnorm(value >>> 20, 10) - 0.5) * norm;
const b = (unpackUnorm(value >>> 10, 10) - 0.5) * norm;
const c = (unpackUnorm(value, 10) - 0.5) * norm;
const m = Math.sqrt(1.0 - (a * a + b * b + c * c));
switch (value >>> 30) {
case 0:
result.set(a, b, c, m);
break;
case 1:
result.set(m, b, c, a);
break;
case 2:
result.set(b, m, c, a);
break;
case 3:
result.set(b, c, m, a);
break;
}
};
const lerp = (a, b, t) => a * (1 - t) + b * t;
const { chunkData, vertexData, shData } = gsplatData.compressedData;
const { chunkSize, shBands } = gsplatData;
const shCoeffs = [3, 8, 15][shBands - 1];
this.read = (i) => {
const ci = Math.floor(i / 256) * chunkSize;
if (p) {
unpack111011(p, vertexData[i * 4 + 0]);
p.x = lerp(chunkData[ci + 0], chunkData[ci + 3], p.x);
p.y = lerp(chunkData[ci + 1], chunkData[ci + 4], p.y);
p.z = lerp(chunkData[ci + 2], chunkData[ci + 5], p.z);
}
if (r) {
unpackRot(r, vertexData[i * 4 + 1]);
}
if (s) {
unpack111011(s, vertexData[i * 4 + 2]);
s.x = lerp(chunkData[ci + 6], chunkData[ci + 9], s.x);
s.y = lerp(chunkData[ci + 7], chunkData[ci + 10], s.y);
s.z = lerp(chunkData[ci + 8], chunkData[ci + 11], s.z);
}
if (c) {
unpack8888(c, vertexData[i * 4 + 3]);
if (chunkSize > 12) {
c.x = lerp(chunkData[ci + 12], chunkData[ci + 15], c.x);
c.y = lerp(chunkData[ci + 13], chunkData[ci + 16], c.y);
c.z = lerp(chunkData[ci + 14], chunkData[ci + 17], c.z);
}
}
if (sh && shBands > 0) {
for (let j = 0; j < 3; ++j) {
for (let k = 0; k < 15; ++k) {
sh[j * 15 + k] = (k < shCoeffs) ? (shData[(i * 3 + j) * shCoeffs + k] * (8 / 255) - 4) : 0;
}
}
}
};
}
}
const decompress = (compressedData, elements) => {
const members = [
'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'
];
const getShBands = () => {
const sizes = {
3: 1,
8: 2,
15: 3
};
return sizes[compressedData.shData?.length / compressedData.numSplats / 3] ?? 0;
};
const getNumChunks = () => {
return Math.ceil(compressedData.numSplats / 256);
};
const getChunkSize = () => {
return compressedData.chunkData.length / getNumChunks();
};
const shBands = getShBands();
const numSplats = compressedData.numSplats;
// allocate spherical harmonics data
if (shBands > 0) {
const shMembers = [];
for (let i = 0; i < 45; ++i) {
shMembers.push(`f_rest_${i}`);
}
members.splice(members.indexOf('f_dc_0') + 1, 0, ...shMembers);
}
// allocate uncompressed data
const data = {
numPoints: numSplats,
shDegree: shBands,
antialiased: false,
positions: new Float32Array(numSplats * 3),
scales: new Float32Array(numSplats * 3),
rotations: new Float32Array(numSplats * 4),
alphas: new Float32Array(numSplats),
colors: new Float32Array(numSplats * 3),
sh: new Float32Array(numSplats * dimForDegree(shBands) * 3)
};
const p = new Vec3();
const r = new Quat();
const s = new Vec3();
const c = new Vec4();
const sh = shBands > 0 ? new Float32Array(45) : null;
const iter = new SplatCompressedIterator({
compressedData,
chunkSize: getChunkSize(),
shBands,
}, p, r, s, c, sh);
for (let i = 0; i < compressedData.numSplats; ++i) {
iter.read(i);
data.positions[i * 3 + 0] = p.x;
data.positions[i * 3 + 1] = p.y;
data.positions[i * 3 + 2] = p.z;
data.rotations[i * 4 + 0] = r.x;
data.rotations[i * 4 + 1] = r.y;
data.rotations[i * 4 + 2] = r.z;
data.rotations[i * 4 + 3] = r.w;
data.scales[i * 3 + 0] = s.x;
data.scales[i * 3 + 1] = s.y;
data.scales[i * 3 + 2] = s.z;
data.colors[i * 3 + 0] = (c.x - 0.5) / SH_C0;
data.colors[i * 3 + 1] = (c.y - 0.5) / SH_C0;
data.colors[i * 3 + 2] = (c.z - 0.5) / SH_C0;
// convert opacity to log sigmoid taking into account infinities at 0 and 1
data.alphas[i] = (c.w <= 0) ? -40 : (c.w >= 1) ? 40 : -Math.log(1 / c.w - 1);
const shDim = dimForDegree(shBands);
for (let j = 0; j < shBands; ++j) {
for (let k = 0; k < shDim; ++k) {
data.sh[(i * shDim + k) * 3 + j] = sh[j * shDim + k];
}
}
}
return data;
};
export async function loadCompressedPly(stream) {
const reader = stream.getReader();
// Read header
/**
* Searches for the first occurrence of a sequence within a buffer.
* @example
* find(new Uint8Array([1, 2, 3, 4]), new Uint8Array([3, 4])); // 2
* @param {Uint8Array} buf - The buffer in which to search.
* @param {Uint8Array} search - The sequence to search for.
* @returns {number} The index of the first occurrence of the search sequence in the buffer, or -1 if not found.
*/
const find = (buf, search) => {
const endIndex = buf.length - search.length;
let i, j;
for (i = 0; i <= endIndex; ++i) {
for (j = 0; j < search.length; ++j) {
if (buf[i + j] !== search[j]) {
break;
}
}
if (j === search.length) {
return i;
}
}
return -1;
};
/**
* Checks if array 'a' starts with the same elements as array 'b'.
* @example
* startsWith(new Uint8Array([1, 2, 3, 4]), new Uint8Array([1, 2])); // true
* @param {Uint8Array} a - The array to check against.
* @param {Uint8Array} b - The array of elements to look for at the start of 'a'.
* @returns {boolean} - True if 'a' starts with all elements of 'b', otherwise false.
*/
const startsWith = (a, b) => {
if (a.length < b.length) {
return false;
}
for (let i = 0; i < b.length; ++i) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const streamBuf = new StreamBuf(reader);
let headerLength;
while (true) {
// get the next chunk of data
/* eslint-disable no-await-in-loop */
await streamBuf.read();
// check magic bytes
if (streamBuf.tail >= magicBytes.length && !startsWith(streamBuf.data, magicBytes)) {
throw new Error('Invalid ply header');
}
// search for end-of-header marker
headerLength = find(streamBuf.data, endHeaderBytes);
if (headerLength !== -1) {
break;
}
}
// decode buffer header text and split into lines and remove comments
const lines = new TextDecoder()
.decode(streamBuf.data.subarray(0, headerLength))
.split('\n')
.filter(line => !line.startsWith('comment '));
// decode header and build element and property list
const { elements, format } = parseHeader(lines);
if (!isCompressedPly(elements)) {
throw new Error('Invalid ply header, not a compressed ply file.');
}
// check format is supported
if (format !== 'binary_little_endian' && format !== 'binary_big_endian') {
throw new Error('Unsupported ply format');
}
streamBuf.head = headerLength + endHeaderBytes.length;
streamBuf.compact();
// Read compressed ply data
const numChunks = elements[0].count;
const numChunkProperties = elements[0].properties.length;
const numVertices = elements[1].count;
// evaluate the storage size for the given count (this must match the
// texture size calculation in GSplatCompressed).
const evalStorageSize = (count) => {
const width = Math.ceil(Math.sqrt(count));
const height = Math.ceil(count / width);
return width * height;
};
// allocate result
const result = {
numSplats: numVertices,
chunkData: new Float32Array(numChunks * numChunkProperties),
vertexData: new Uint32Array(evalStorageSize(numVertices) * 4),
};
// read length bytes of data into buffer
const read = async (buffer, length) => {
const target = new Uint8Array(buffer);
let cursor = 0;
while (cursor < length) {
while (streamBuf.remaining === 0) {
/* eslint-disable no-await-in-loop */
await streamBuf.read();
}
const toCopy = Math.min(length - cursor, streamBuf.remaining);
const src = streamBuf.data;
for (let i = 0; i < toCopy; ++i) {
target[cursor++] = src[streamBuf.head++];
}
}
};
// read chunk data
await read(result.chunkData.buffer, numChunks * numChunkProperties * 4);
// read packed vertices
await read(result.vertexData.buffer, numVertices * 4 * 4);
// read sh data
if (elements.length === 3) {
result.shData = new Uint8Array(elements[2].count * elements[2].properties.length);
await read(result.shData.buffer, result.shData.byteLength);
}
// Decompress
const data = decompress(result, elements);
return data;
}
@@ -0,0 +1,8 @@
import { GaussianCloud } from './types.js';
/**
* Serialize a GaussianCloud to a compressed PLY file as used by the PlayCanvas engine.
*
* @param data - The GaussianCloud to serialize.
* @returns The serialized PLY file as an ArrayBuffer.
*/
export declare function serializeCompressedPly(data: GaussianCloud): Uint8Array;
@@ -0,0 +1,356 @@
// Parts of the code are taken from https://github.com/playcanvas/supersplat/blob/main/src/splat-serialize.ts
import { Quat } from 'playcanvas';
import { dimForDegree, SH_C0 } from './constant.js';
const generatedByString = 'spz-js package';
const shNames = new Array(45).fill('').map((_, i) => `f_rest_${i}`);
const shBandCoeffs = [0, 3, 8, 15];
/**
* Serialize a GaussianCloud to a compressed PLY file as used by the PlayCanvas engine.
*
* @param data - The GaussianCloud to serialize.
* @returns The serialized PLY file as an ArrayBuffer.
*/
export function serializeCompressedPly(data) {
const numSplats = data.numPoints;
const numChunks = Math.ceil(numSplats / 256);
const indices = [];
for (let i = 0; i < numSplats; ++i) {
indices.push(i);
}
const chunkProps = [
'min_x', 'min_y', 'min_z',
'max_x', 'max_y', 'max_z',
'min_scale_x', 'min_scale_y', 'min_scale_z',
'max_scale_x', 'max_scale_y', 'max_scale_z',
'min_r', 'min_g', 'min_b',
'max_r', 'max_g', 'max_b'
];
const vertexProps = [
'packed_position',
'packed_rotation',
'packed_scale',
'packed_color'
];
const outputSHBands = data.shDegree;
const outputSHCoeffs = shBandCoeffs[outputSHBands];
const shHeader = outputSHBands ? [
`element sh ${numSplats}`,
new Array(outputSHCoeffs * 3).fill('').map((_, i) => `property uchar f_rest_${i}`)
].flat() : [];
const headerText = [
'ply',
'format binary_little_endian 1.0',
`comment ${generatedByString}`,
`element chunk ${numChunks}`,
chunkProps.map(p => `property float ${p}`),
`element vertex ${numSplats}`,
vertexProps.map(p => `property uint ${p}`),
shHeader,
'end_header\n'
].flat().join('\n');
const header = (new TextEncoder()).encode(headerText);
const result = new Uint8Array(header.byteLength +
numChunks * chunkProps.length * 4 +
numSplats * vertexProps.length * 4 +
outputSHCoeffs * 3 * numSplats);
const dataView = new DataView(result.buffer);
result.set(header);
const chunkOffset = header.byteLength;
const vertexOffset = chunkOffset + numChunks * chunkProps.length * 4;
const shOffset = vertexOffset + numSplats * 4 * 4;
sortSplats(data, indices);
const chunk = new Chunk();
const singleSplat = new SingleSplat([
'x', 'y', 'z',
'scale_0', 'scale_1', 'scale_2',
'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity',
'rot_0', 'rot_1', 'rot_2', 'rot_3'
].concat(shNames.slice(0, outputSHCoeffs * 3)));
for (let i = 0; i < numChunks; ++i) {
const num = Math.min(numSplats, (i + 1) * 256) - i * 256;
for (let j = 0; j < num; ++j) {
const index = indices[i * 256 + j];
// read splat
singleSplat.read(data, index);
// update chunk
chunk.set(j, singleSplat);
// quantize and write sh data
let off = shOffset + (i * 256 + j) * outputSHCoeffs * 3;
for (let k = 0; k < outputSHCoeffs * 3; ++k) {
const nvalue = singleSplat.data[shNames[k]] / 8 + 0.5;
dataView.setUint8(off++, Math.max(0, Math.min(255, Math.trunc(nvalue * 256))));
}
}
const result = chunk.pack();
const off = chunkOffset + i * 18 * 4;
// write chunk data
dataView.setFloat32(off + 0, result.px.min, true);
dataView.setFloat32(off + 4, result.py.min, true);
dataView.setFloat32(off + 8, result.pz.min, true);
dataView.setFloat32(off + 12, result.px.max, true);
dataView.setFloat32(off + 16, result.py.max, true);
dataView.setFloat32(off + 20, result.pz.max, true);
dataView.setFloat32(off + 24, result.sx.min, true);
dataView.setFloat32(off + 28, result.sy.min, true);
dataView.setFloat32(off + 32, result.sz.min, true);
dataView.setFloat32(off + 36, result.sx.max, true);
dataView.setFloat32(off + 40, result.sy.max, true);
dataView.setFloat32(off + 44, result.sz.max, true);
dataView.setFloat32(off + 48, result.cr.min, true);
dataView.setFloat32(off + 52, result.cg.min, true);
dataView.setFloat32(off + 56, result.cb.min, true);
dataView.setFloat32(off + 60, result.cr.max, true);
dataView.setFloat32(off + 64, result.cg.max, true);
dataView.setFloat32(off + 68, result.cb.max, true);
// write splat data
const offset = vertexOffset + i * 256 * 4 * 4;
const chunkSplats = Math.min(numSplats, (i + 1) * 256) - i * 256;
for (let j = 0; j < chunkSplats; ++j) {
dataView.setUint32(offset + j * 4 * 4 + 0, chunk.position[j], true);
dataView.setUint32(offset + j * 4 * 4 + 4, chunk.rotation[j], true);
dataView.setUint32(offset + j * 4 * 4 + 8, chunk.scale[j], true);
dataView.setUint32(offset + j * 4 * 4 + 12, chunk.color[j], true);
}
}
return result;
}
function sortSplats(data, indices) {
// https://fgiesen.wordpress.com/2009/12/13/decoding-morton-codes/
const encodeMorton3 = (x, y, z) => {
const Part1By2 = (x) => {
x &= 0x000003ff;
x = (x ^ (x << 16)) & 0xff0000ff;
x = (x ^ (x << 8)) & 0x0300f00f;
x = (x ^ (x << 4)) & 0x030c30c3;
x = (x ^ (x << 2)) & 0x09249249;
return x;
};
return (Part1By2(z) << 2) + (Part1By2(y) << 1) + Part1By2(x);
};
let minx;
let miny;
let minz;
let maxx;
let maxy;
let maxz;
const centers = data.positions;
for (let i = 0; i < data.numPoints; ++i) {
const x = centers[i * 3 + 0];
const y = centers[i * 3 + 1];
const z = centers[i * 3 + 2];
if (minx === undefined) {
minx = maxx = x;
miny = maxy = y;
minz = maxz = z;
}
else {
if (x < minx)
minx = x;
else if (x > maxx)
maxx = x;
if (y < miny)
miny = y;
else if (y > maxy)
maxy = y;
if (z < minz)
minz = z;
else if (z > maxz)
maxz = z;
}
}
const xlen = maxx - minx;
const ylen = maxy - miny;
const zlen = maxz - minz;
const morton = new Uint32Array(data.numPoints);
let idx = 0;
for (let i = 0; i < data.numPoints; ++i) {
const x = centers[i * 3 + 0];
const y = centers[i * 3 + 1];
const z = centers[i * 3 + 2];
const ix = Math.floor(1024 * (x - minx) / xlen);
const iy = Math.floor(1024 * (y - miny) / ylen);
const iz = Math.floor(1024 * (z - minz) / zlen);
morton[idx++] = encodeMorton3(ix, iy, iz);
}
// order splats by morton code
indices.sort((a, b) => morton[a] - morton[b]);
}
class SingleSplat {
data = {};
constructor(members) {
members.forEach((name) => {
this.data[name] = 0;
});
}
read(cloud, i) {
// Direct mapping from GaussianCloud to SingleSplat format
if (this.data.hasOwnProperty('x')) {
this.data.x = cloud.positions[i * 3 + 0];
this.data.y = cloud.positions[i * 3 + 1];
this.data.z = cloud.positions[i * 3 + 2];
}
if (this.data.hasOwnProperty('scale_0')) {
this.data.scale_0 = cloud.scales[i * 3 + 0];
this.data.scale_1 = cloud.scales[i * 3 + 1];
this.data.scale_2 = cloud.scales[i * 3 + 2];
}
if (this.data.hasOwnProperty('rot_0')) {
this.data.rot_0 = cloud.rotations[i * 4 + 3];
this.data.rot_1 = cloud.rotations[i * 4 + 0];
this.data.rot_2 = cloud.rotations[i * 4 + 1];
this.data.rot_3 = cloud.rotations[i * 4 + 2];
}
if (this.data.hasOwnProperty('f_dc_0')) {
this.data.f_dc_0 = cloud.colors[i * 3 + 0];
this.data.f_dc_1 = cloud.colors[i * 3 + 1];
this.data.f_dc_2 = cloud.colors[i * 3 + 2];
}
if (this.data.hasOwnProperty('opacity')) {
this.data.opacity = cloud.alphas[i];
}
// Handle SH coefficients if present
const shDim = dimForDegree(cloud.shDegree);
for (let j = 0; j < shDim; j++) {
for (let c = 0; c < 3; c++) {
const name = `f_rest_${j + c * shDim}`;
if (this.data.hasOwnProperty(name)) {
this.data[name] = cloud.sh[(i * shDim + j) * 3 + c];
}
}
}
}
}
const q = new Quat();
// process and compress a chunk of 256 splats
class Chunk {
static members = [
'x', 'y', 'z',
'scale_0', 'scale_1', 'scale_2',
'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity',
'rot_0', 'rot_1', 'rot_2', 'rot_3'
];
size;
/// @ts-ignore
data = {};
// compressed data
position;
rotation;
scale;
color;
constructor(size = 256) {
this.size = size;
Chunk.members.forEach((m) => {
this.data[m] = new Float32Array(size);
});
this.position = new Uint32Array(size);
this.rotation = new Uint32Array(size);
this.scale = new Uint32Array(size);
this.color = new Uint32Array(size);
}
set(index, splat) {
Chunk.members.forEach((name) => {
this.data[name][index] = splat.data[name];
});
}
pack() {
const calcMinMax = (data) => {
let min;
let max;
min = max = data[0];
for (let i = 1; i < data.length; ++i) {
const v = data[i];
min = Math.min(min, v);
max = Math.max(max, v);
}
return { min, max };
};
const normalize = (x, min, max) => {
if (x <= min)
return 0;
if (x >= max)
return 1;
return (max - min < 0.00001) ? 0 : (x - min) / (max - min);
};
const data = this.data;
const x = data.x;
const y = data.y;
const z = data.z;
const scale_0 = data.scale_0;
const scale_1 = data.scale_1;
const scale_2 = data.scale_2;
const rot_0 = data.rot_0;
const rot_1 = data.rot_1;
const rot_2 = data.rot_2;
const rot_3 = data.rot_3;
const f_dc_0 = data.f_dc_0;
const f_dc_1 = data.f_dc_1;
const f_dc_2 = data.f_dc_2;
const opacity = data.opacity;
const px = calcMinMax(x);
const py = calcMinMax(y);
const pz = calcMinMax(z);
const sx = calcMinMax(scale_0);
const sy = calcMinMax(scale_1);
const sz = calcMinMax(scale_2);
// clamp scale because sometimes values are at infinity
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
sx.min = clamp(sx.min, -20, 20);
sx.max = clamp(sx.max, -20, 20);
sy.min = clamp(sy.min, -20, 20);
sy.max = clamp(sy.max, -20, 20);
sz.min = clamp(sz.min, -20, 20);
sz.max = clamp(sz.max, -20, 20);
// convert f_dc_ to colors before calculating min/max and packaging
for (let i = 0; i < f_dc_0.length; ++i) {
f_dc_0[i] = f_dc_0[i] * SH_C0 + 0.5;
f_dc_1[i] = f_dc_1[i] * SH_C0 + 0.5;
f_dc_2[i] = f_dc_2[i] * SH_C0 + 0.5;
}
const cr = calcMinMax(f_dc_0);
const cg = calcMinMax(f_dc_1);
const cb = calcMinMax(f_dc_2);
const packUnorm = (value, bits) => {
const t = (1 << bits) - 1;
return Math.max(0, Math.min(t, Math.floor(value * t + 0.5)));
};
const pack111011 = (x, y, z) => {
return packUnorm(x, 11) << 21 |
packUnorm(y, 10) << 11 |
packUnorm(z, 11);
};
const pack8888 = (x, y, z, w) => {
return packUnorm(x, 8) << 24 |
packUnorm(y, 8) << 16 |
packUnorm(z, 8) << 8 |
packUnorm(w, 8);
};
// pack quaternion into 2,10,10,10
const packRot = (x, y, z, w) => {
q.set(x, y, z, w).normalize();
const a = [q.x, q.y, q.z, q.w];
const largest = a.reduce((curr, v, i) => (Math.abs(v) > Math.abs(a[curr]) ? i : curr), 0);
if (a[largest] < 0) {
a[0] = -a[0];
a[1] = -a[1];
a[2] = -a[2];
a[3] = -a[3];
}
const norm = Math.sqrt(2) * 0.5;
let result = largest;
for (let i = 0; i < 4; ++i) {
if (i !== largest) {
result = (result << 10) | packUnorm(a[i] * norm + 0.5, 10);
}
}
return result;
};
// pack
for (let i = 0; i < this.size; ++i) {
this.position[i] = pack111011(normalize(x[i], px.min, px.max), normalize(y[i], py.min, py.max), normalize(z[i], pz.min, pz.max));
this.rotation[i] = packRot(rot_0[i], rot_1[i], rot_2[i], rot_3[i]);
this.scale[i] = pack111011(normalize(scale_0[i], sx.min, sx.max), normalize(scale_1[i], sy.min, sy.max), normalize(scale_2[i], sz.min, sz.max));
this.color[i] = pack8888(normalize(f_dc_0[i], cr.min, cr.max), normalize(f_dc_1[i], cg.min, cg.max), normalize(f_dc_2[i], cb.min, cb.max), 1 / (1 + Math.exp(-opacity[i])));
}
return { px, py, pz, sx, sy, sz, cr, cg, cb };
}
}
+3
View File
@@ -0,0 +1,3 @@
export declare function decompressGzipped(data: Uint8Array): Promise<Uint8Array>;
export declare function decompressGzipStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array>;
export declare function compressGzipped(data: Uint8Array): Promise<Uint8Array>;
+41
View File
@@ -0,0 +1,41 @@
const createStream = (data) => {
return new ReadableStream({
async start(controller) {
controller.enqueue(data);
controller.close();
},
});
};
export async function decompressGzipped(data) {
try {
const stream = createStream(data);
if (!stream)
throw new Error('Failed to create stream from data');
return await decompressGzipStream(stream);
}
catch (error) {
console.error('Error decompressing gzipped data:', error);
throw error;
}
}
export async function decompressGzipStream(stream) {
const decompressor = new DecompressionStream('gzip');
const decompressedStream = stream.pipeThrough(decompressor);
const response = new Response(decompressedStream);
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
}
export async function compressGzipped(data) {
try {
const stream = createStream(data);
const compressor = new CompressionStream('gzip');
const compressedStream = stream.pipeThrough(compressor);
const response = new Response(compressedStream);
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
}
catch (error) {
console.error('Error compressing gzipped data:', error);
throw error;
}
}
+7
View File
@@ -0,0 +1,7 @@
export declare const SPZ_MAGIC = 1347635022;
export declare const SPZ_VERSION = 2;
export declare const FLAG_ANTIALIASED = 1;
export declare const COLOR_SCALE = 0.15;
export declare const SH_C0 = 0.28209479177387814;
export declare function degreeForDim(dim: number): number;
export declare function dimForDegree(degree: number): number;
+25
View File
@@ -0,0 +1,25 @@
export const SPZ_MAGIC = 0x5053474e; // NGSP = Niantic gaussian splat
export const SPZ_VERSION = 2;
export const FLAG_ANTIALIASED = 0x1;
export const COLOR_SCALE = 0.15;
export const SH_C0 = 0.28209479177387814;
export function degreeForDim(dim) {
if (dim < 3)
return 0;
if (dim < 8)
return 1;
if (dim < 15)
return 2;
return 3;
}
export function dimForDegree(degree) {
switch (degree) {
case 0: return 0;
case 1: return 3;
case 2: return 8;
case 3: return 15;
default:
console.error(`[SPZ: ERROR] Unsupported SH degree: ${degree}`);
return 0;
}
}
+9
View File
@@ -0,0 +1,9 @@
export * from './types.js';
export * from './spz-serializer.js';
export * from './spz-loader.js';
export * from './constant.js';
export * from './compression.js';
export * from './ply-serializer.js';
export * from './ply-loader.js';
export * from './compressed-ply-serializer.js';
export * from './compressed-ply-loader.js';
+9
View File
@@ -0,0 +1,9 @@
export * from './types.js';
export * from './spz-serializer.js';
export * from './spz-loader.js';
export * from './constant.js';
export * from './compression.js';
export * from './ply-serializer.js';
export * from './ply-loader.js';
export * from './compressed-ply-serializer.js';
export * from './compressed-ply-loader.js';
+19
View File
@@ -0,0 +1,19 @@
import { Vec3f, Quat4f, Half } from './types.js';
export declare const PI: number;
export declare function halfToFloat(h: Half): number;
export declare function floatToHalf(f: number): Half;
export declare function axisAngleQuat(scaledAxis: Vec3f): Quat4f;
export declare function isVec3f(v: number[]): v is Vec3f;
export declare function isQuat4f(v: number[]): v is Quat4f;
export declare function vec3f(data: number[]): Vec3f;
export declare function quat4f(data: number[]): Quat4f;
export declare function dot(a: Vec3f, b: Vec3f): number;
export declare function squaredNorm(v: Vec3f | Quat4f): number;
export declare function norm(v: Vec3f | Quat4f): number;
export declare function normalized<T extends Vec3f | Quat4f>(v: T): T;
export declare function timesVec3(q: Quat4f, p: Vec3f): Vec3f;
export declare function timesQuat(a: Quat4f, b: Quat4f): Quat4f;
export declare function timesScalar<T extends Vec3f | Quat4f>(v: T, s: number): T;
export declare function plus<T extends Vec3f | Quat4f>(a: T, b: T): T;
export declare function vec3ToQuat4(v: Vec3f, w: number): Quat4f;
export declare function quatToVec3(q: Quat4f): Vec3f;
+125
View File
@@ -0,0 +1,125 @@
export const PI = Math.PI;
export function halfToFloat(h) {
const sgn = (h >> 15) & 0x1;
const exponent = (h >> 10) & 0x1f;
const mantissa = h & 0x3ff;
const signMul = sgn === 1 ? -1.0 : 1.0;
if (exponent === 0) {
return signMul * Math.pow(2, -14) * mantissa / 1024;
}
if (exponent === 31) {
return mantissa !== 0 ? NaN : signMul * Infinity;
}
return signMul * Math.pow(2, exponent - 15) * (1 + mantissa / 1024);
}
export function floatToHalf(f) {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setFloat32(0, f);
const f32 = view.getUint32(0);
const sign = (f32 >> 31) & 0x01;
const exponent = (f32 >> 23) & 0xff;
const mantissa = f32 & 0x7fffff;
if (exponent === 0xff) {
if (mantissa === 0) {
return (sign << 15) | 0x7c00;
}
return (sign << 15) | 0x7c01;
}
const centeredExp = exponent - 127;
if (centeredExp > 15) {
return (sign << 15) | 0x7c00;
}
if (centeredExp > -15) {
return (sign << 15) | ((centeredExp + 15) << 10) | (mantissa >> 13);
}
const fullMantissa = 0x800000 | mantissa;
const shift = -(centeredExp + 14);
const newMantissa = fullMantissa >> shift;
return (sign << 15) | (newMantissa >> 13);
}
export function axisAngleQuat(scaledAxis) {
const [a0, a1, a2] = scaledAxis;
const thetaSquared = a0 * a0 + a1 * a1 + a2 * a2;
if (thetaSquared > 0) {
const theta = Math.sqrt(thetaSquared);
const halfTheta = theta * 0.5;
const k = Math.sin(halfTheta) / theta;
return normalized([Math.cos(halfTheta), a0 * k, a1 * k, a2 * k]);
}
const k = 0.5;
return normalized([1.0, a0 * k, a1 * k, a2 * k]);
}
export function isVec3f(v) {
return v.length === 3;
}
export function isQuat4f(v) {
return v.length === 4;
}
export function vec3f(data) {
return [data[0], data[1], data[2]];
}
export function quat4f(data) {
return [data[0], data[1], data[2], data[3]];
}
export function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
export function squaredNorm(v) {
return v.reduce((sum, val) => sum + val * val, 0);
}
export function norm(v) {
return Math.sqrt(squaredNorm(v));
}
export function normalized(v) {
const n = norm(v);
return v.map(x => x / n);
}
// Quaternion multiplication with a vector
export function timesVec3(q, p) {
const [w, x, y, z] = q;
const [vx, vy, vz] = p;
const x2 = x + x;
const y2 = y + y;
const z2 = z + z;
const wx2 = w * x2;
const wy2 = w * y2;
const wz2 = w * z2;
const xx2 = x * x2;
const xy2 = x * y2;
const xz2 = x * z2;
const yy2 = y * y2;
const yz2 = y * z2;
const zz2 = z * z2;
return [
vx * (1.0 - (yy2 + zz2)) + vy * (xy2 - wz2) + vz * (xz2 + wy2),
vx * (xy2 + wz2) + vy * (1.0 - (xx2 + zz2)) + vz * (yz2 - wx2),
vx * (xz2 - wy2) + vy * (yz2 + wx2) + vz * (1.0 - (xx2 + yy2))
];
}
// Quaternion multiplication
export function timesQuat(a, b) {
const [w, x, y, z] = a;
const [qw, qx, qy, qz] = b;
return normalized([
w * qw - x * qx - y * qy - z * qz,
w * qx + x * qw + y * qz - z * qy,
w * qy - x * qz + y * qw + z * qx,
w * qz + x * qy - y * qx + z * qw
]);
}
// Scalar multiplication
export function timesScalar(v, s) {
return v.map(x => x * s);
}
// Vector/quaternion addition
export function plus(a, b) {
return a.map((x, i) => x + b[i]);
}
// Convert Vec3f to Quat4f with w component
export function vec3ToQuat4(v, w) {
return [w, v[0], v[1], v[2]]; // w first, then xyz
}
export function quatToVec3(q) {
return [q[1], q[2], q[3]]; // Skip w, take xyz
}
+2
View File
@@ -0,0 +1,2 @@
import { GaussianCloud } from './types.js';
export declare function loadPly(readable: ReadableStream<Uint8Array>): Promise<GaussianCloud>;
+184
View File
@@ -0,0 +1,184 @@
import { degreeForDim } from './constant.js';
/* ────────────────────────────── constants ─────────────────────────────── */
const END_MARKER = /\r?\nend_header\r?\n/; // handles \n and \r\n
/* ────────────────────────────── helpers ───────────────────────────────── */
function getFieldIndex(map, name) {
const idx = map.get(name);
if (idx === undefined) {
throw new Error(`[PLY] Missing required field "${name}"`);
}
return idx;
}
function byteSizeForType(type) {
switch (type) {
case 'float':
case 'int':
case 'uint':
case 'uint32':
case 'int32': return 4;
case 'double': return 8;
case 'uchar':
case 'char':
case 'uint8':
case 'int8': return 1;
case 'ushort':
case 'short':
case 'uint16':
case 'int16': return 2;
default:
throw new Error(`[PLY] Unsupported property type "${type}"`);
}
}
/* ────────────────────────────── parser ────────────────────────────────── */
export async function loadPly(readable) {
/* ---------- 1. Stream-safe header read -------------------------------- */
const reader = readable.getReader();
const decoder = new TextDecoder();
const headerChunks = [];
let headerText = '';
let binaryStartByteOffset = 0; // where vertex blob begins (byte index)
while (true) {
const { value, done } = await reader.read();
if (done)
throw new Error('[PLY] Unexpected EOF before end_header');
headerChunks.push(value);
headerText += decoder.decode(value, { stream: true });
const m = headerText.match(END_MARKER);
if (!m)
continue; // need more data
const headerChars = headerText.indexOf(m[0]) + m[0].length;
binaryStartByteOffset = new TextEncoder().encode(headerText.slice(0, headerChars)).length;
// Trim sentinel from headerText (makes later `.split` cleaner)
headerText = headerText.slice(0, headerChars - m[0].length);
break;
}
/* ---------- 2. Stitch chunks & grab first payload bytes --------------- */
const headerTotal = headerChunks.reduce((s, c) => s + c.length, 0);
const stitched = new Uint8Array(headerTotal);
let off = 0;
for (const c of headerChunks) {
stitched.set(c, off);
off += c.length;
}
const initialBinary = stitched.subarray(binaryStartByteOffset);
/* ---------- 3. Robust header parsing ---------------------------------- */
const lines = headerText.split(/\r?\n/).filter(Boolean);
if (lines[0] !== 'ply') {
throw new Error('[PLY] Not a PLY file');
}
let formatOK = false;
let numPoints = 0;
const propDefs = [];
let strideBytes = 0;
let parsingVertexProps = false;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('format binary_little_endian')) {
formatOK = true;
continue;
}
if (line.startsWith('comment') || line.startsWith('obj_info'))
continue;
if (line.startsWith('element')) {
const [, elem, count] = line.split(/\s+/);
parsingVertexProps = (elem === 'vertex');
if (elem === 'vertex')
numPoints = Number(count);
continue;
}
if (line.startsWith('property') && parsingVertexProps) {
const [, type, name] = line.split(/\s+/);
const size = byteSizeForType(type);
propDefs.push({ name, type, offsetBytes: strideBytes });
strideBytes += size;
continue;
}
}
if (!formatOK)
throw new Error('[PLY] Only binary_little_endian supported');
if (!numPoints)
throw new Error('[PLY] Vertex count missing or zero');
/* ---------- 4. Fast lookup tables ------------------------------------- */
const fieldOffsetMap = new Map();
propDefs.forEach(({ name, offsetBytes }) => fieldOffsetMap.set(name, offsetBytes / 4) // convert to float index
);
const posIdx = ['x', 'y', 'z'].map(n => getFieldIndex(fieldOffsetMap, n));
const scaleIdx = ['scale_0', 'scale_1', 'scale_2'].map(n => getFieldIndex(fieldOffsetMap, n));
const rotIdx = ['rot_1', 'rot_2', 'rot_3', 'rot_0'].map(n => getFieldIndex(fieldOffsetMap, n));
const alphaIdx = [getFieldIndex(fieldOffsetMap, 'opacity')];
const colorIdx = ['f_dc_0', 'f_dc_1', 'f_dc_2'].map(n => getFieldIndex(fieldOffsetMap, n));
const shIdx = [];
for (let i = 0;; i++) {
const idx = fieldOffsetMap.get(`f_rest_${i}`);
if (idx === undefined)
break;
shIdx.push(idx);
}
const shDim = Math.floor(shIdx.length / 3);
const strideFloats = strideBytes / 4;
/* ---------- 5. Stream binary payload directly into pre-alloc buffer --- */
const totalBinaryBytes = numPoints * strideBytes;
const buffer = new ArrayBuffer(totalBinaryBytes);
const dest = new Uint8Array(buffer);
// paste the bytes we already have
dest.set(initialBinary, 0);
let cursor = initialBinary.length;
while (cursor < totalBinaryBytes) {
const { value, done } = await reader.read();
if (done)
throw new Error('[PLY] Truncated binary section');
dest.set(value, cursor);
cursor += value.length;
}
reader.releaseLock();
/* ---------- 6. Decode vertices via Float32 view ----------------------- */
const f32 = new Float32Array(buffer);
const result = {
numPoints,
shDegree: degreeForDim(shDim),
antialiased: false,
positions: new Float32Array(numPoints * 3),
scales: new Float32Array(numPoints * 3),
rotations: new Float32Array(numPoints * 4),
alphas: new Float32Array(numPoints),
colors: new Float32Array(numPoints * 3),
sh: new Float32Array(numPoints * shDim * 3),
};
for (let i = 0; i < numPoints; i++) {
const base = i * strideFloats;
/* positions, scales, colours */
result.positions.set([
f32[base + posIdx[0]],
f32[base + posIdx[1]],
f32[base + posIdx[2]],
], i * 3);
result.scales.set([
f32[base + scaleIdx[0]],
f32[base + scaleIdx[1]],
f32[base + scaleIdx[2]],
], i * 3);
result.colors.set([
f32[base + colorIdx[0]],
f32[base + colorIdx[1]],
f32[base + colorIdx[2]],
], i * 3);
/* rotations (quat wxyz order rot_0..3) */
result.rotations.set([
f32[base + rotIdx[0]],
f32[base + rotIdx[1]],
f32[base + rotIdx[2]],
f32[base + rotIdx[3]],
], i * 4);
/* alpha */
result.alphas[i] = f32[base + alphaIdx[0]];
/* spherical harmonics */
for (let j = 0; j < shDim; j++) {
const dst = (i * shDim + j) * 3;
result.sh[dst] = f32[base + shIdx[j]]; // R
result.sh[dst + 1] = f32[base + shIdx[j + shDim]]; // G
result.sh[dst + 2] = f32[base + shIdx[j + 2 * shDim]]; // B
}
}
return result;
}
// --------------------------------------------------------------------------
@@ -0,0 +1,2 @@
import { GaussianCloud } from './types.js';
export declare function serializePly(data: GaussianCloud): ArrayBuffer;
+85
View File
@@ -0,0 +1,85 @@
export function serializePly(data) {
const N = data.numPoints;
// Validate sizes
if (data.positions.length !== N * 3 ||
data.scales.length !== N * 3 ||
data.rotations.length !== N * 4 ||
data.alphas.length !== N ||
data.colors.length !== N * 3) {
throw new Error('[PLY ERROR] Invalid data sizes');
}
const shDim = Math.floor(data.sh.length / N / 3);
const D = 17 + shDim * 3;
// Create header
const header = [
'ply',
'format binary_little_endian 1.0',
`element vertex ${N}`,
'property float x',
'property float y',
'property float z',
'property float nx',
'property float ny',
'property float nz',
'property float f_dc_0',
'property float f_dc_1',
'property float f_dc_2',
...Array.from({ length: shDim * 3 }, (_, i) => `property float f_rest_${i}`),
'property float opacity',
'property float scale_0',
'property float scale_1',
'property float scale_2',
'property float rot_0',
'property float rot_1',
'property float rot_2',
'property float rot_3',
'end_header\n'
].join('\n');
// Prepare values array
const values = new Float32Array(N * D);
let outIdx = 0, i3 = 0, i4 = 0;
for (let i = 0; i < N; i++) {
// Position (x, y, z)
values[outIdx++] = data.positions[i3 + 0];
values[outIdx++] = data.positions[i3 + 1];
values[outIdx++] = data.positions[i3 + 2];
// Normals (nx, ny, nz) - always zero
values[outIdx++] = 0;
values[outIdx++] = 0;
values[outIdx++] = 0;
// Colors (r, g, b)
values[outIdx++] = data.colors[i3 + 0];
values[outIdx++] = data.colors[i3 + 1];
values[outIdx++] = data.colors[i3 + 2];
// Spherical harmonics
for (let j = 0; j < shDim; j++) {
values[outIdx++] = data.sh[(i * shDim + j) * 3 + 0];
}
for (let j = 0; j < shDim; j++) {
values[outIdx++] = data.sh[(i * shDim + j) * 3 + 1];
}
for (let j = 0; j < shDim; j++) {
values[outIdx++] = data.sh[(i * shDim + j) * 3 + 2];
}
// Alpha
values[outIdx++] = data.alphas[i];
// Scale (sx, sy, sz)
values[outIdx++] = data.scales[i3 + 0];
values[outIdx++] = data.scales[i3 + 1];
values[outIdx++] = data.scales[i3 + 2];
// Rotation (qw, qx, qy, qz)
values[outIdx++] = data.rotations[i4 + 3];
values[outIdx++] = data.rotations[i4 + 0];
values[outIdx++] = data.rotations[i4 + 1];
values[outIdx++] = data.rotations[i4 + 2];
i3 += 3;
i4 += 4;
}
// Combine header and values into final buffer
const headerBuffer = new TextEncoder().encode(header);
const finalBuffer = new ArrayBuffer(headerBuffer.length + values.buffer.byteLength);
const finalView = new Uint8Array(finalBuffer);
finalView.set(headerBuffer, 0);
finalView.set(new Uint8Array(values.buffer), headerBuffer.length);
return finalBuffer;
}
+3
View File
@@ -0,0 +1,3 @@
import { GaussianCloud, PackedGaussians } from './types.js';
export declare function packGaussians(g: GaussianCloud): PackedGaussians;
export declare function loadSpz(buffer: Uint8Array): Promise<GaussianCloud>;
+282
View File
@@ -0,0 +1,282 @@
import { decompressGzipped } from './compression.js';
import { dimForDegree, FLAG_ANTIALIASED, SPZ_MAGIC } from './constant.js';
import { normalized, timesScalar, plus, halfToFloat } from './math.js';
const colorScale = 0.15;
function sigmoid(x) {
return 1.0 / (1.0 + Math.exp(-x));
}
function invSigmoid(x) {
return Math.log(x / (1.0 - x));
}
function toUint8(x) {
return Math.min(Math.max(Math.round(x), 0), 255);
}
function quantizeSH(x, bucketSize) {
let q = Math.round(x * 128.0 + 128.0);
q = Math.floor((q + bucketSize / 2) / bucketSize) * bucketSize;
return Math.min(Math.max(q, 0), 255);
}
function unquantizeSH(x) {
return (x - 128.0) / 128.0;
}
function checkSizes(g) {
const shDim = dimForDegree(g.shDegree);
if (g.positions.length !== g.numPoints * 3)
return false;
if (g.scales.length !== g.numPoints * 3)
return false;
if (g.rotations.length !== g.numPoints * 4)
return false;
if (g.alphas.length !== g.numPoints)
return false;
if (g.colors.length !== g.numPoints * 3)
return false;
if (g.sh.length !== g.numPoints * shDim * 3)
return false;
return true;
}
export function packGaussians(g) {
if (!checkSizes(g)) {
throw new Error("Invalid GaussianCloud");
}
const numPoints = g.numPoints;
const shDim = dimForDegree(g.shDegree);
// Use 12 bits for the fractional part of coordinates (~0.25 millimeter resolution)
const packed = {
numPoints: g.numPoints,
shDegree: g.shDegree,
fractionalBits: 12,
antialiased: g.antialiased,
positions: new Uint8Array(numPoints * 3 * 3),
scales: new Uint8Array(numPoints * 3),
rotations: new Uint8Array(numPoints * 3),
alphas: new Uint8Array(numPoints),
colors: new Uint8Array(numPoints * 3),
sh: new Uint8Array(numPoints * shDim * 3)
};
// Store coordinates as 24-bit fixed point values
const scale = 1 << packed.fractionalBits;
for (let i = 0; i < numPoints * 3; i++) {
const fixed32 = Math.round(g.positions[i] * scale);
packed.positions[i * 3 + 0] = fixed32 & 0xff;
packed.positions[i * 3 + 1] = (fixed32 >> 8) & 0xff;
packed.positions[i * 3 + 2] = (fixed32 >> 16) & 0xff;
}
for (let i = 0; i < numPoints * 3; i++) {
packed.scales[i] = toUint8((g.scales[i] + 10.0) * 16.0);
}
for (let i = 0; i < numPoints; i++) {
// Normalize the quaternion, make w positive, then store xyz
// NOTE: These are already in xyzw order
const q = normalized(Array.from(g.rotations.subarray(i * 4, i * 4 + 4)));
const scaledQ = timesScalar(q, q[3] < 0 ? -127.5 : 127.5);
const offsetQ = plus(scaledQ, [127.5, 127.5, 127.5, 127.5]);
packed.rotations[i * 3 + 0] = toUint8(offsetQ[0]);
packed.rotations[i * 3 + 1] = toUint8(offsetQ[1]);
packed.rotations[i * 3 + 2] = toUint8(offsetQ[2]);
}
for (let i = 0; i < numPoints; i++) {
// Apply sigmoid activation to alpha
packed.alphas[i] = toUint8(sigmoid(g.alphas[i]) * 255.0);
}
for (let i = 0; i < numPoints * 3; i++) {
// Convert SH DC component to wide RGB
packed.colors[i] = toUint8(g.colors[i] * (colorScale * 255.0) + (0.5 * 255.0));
}
if (g.shDegree > 0) {
// Spherical harmonics quantization parameters
const sh1Bits = 5;
const shRestBits = 4;
const shPerPoint = dimForDegree(g.shDegree) * 3;
for (let i = 0; i < numPoints * shPerPoint; i += shPerPoint) {
let j = 0;
for (; j < 9; j++) { // There are 9 coefficients for degree 1
packed.sh[i + j] = quantizeSH(g.sh[i + j], 1 << (8 - sh1Bits));
}
for (; j < shPerPoint; j++) {
packed.sh[i + j] = quantizeSH(g.sh[i + j], 1 << (8 - shRestBits));
}
}
}
return packed;
}
function unpackGaussians(packed) {
const numPoints = packed.numPoints;
const shDim = dimForDegree(packed.shDegree);
const usesFloat16 = packed.positions.length === numPoints * 3 * 2;
// Validate sizes
if (!checkSizes2(packed, numPoints, shDim, usesFloat16)) {
return null;
}
const result = {
numPoints: packed.numPoints,
shDegree: packed.shDegree,
antialiased: packed.antialiased,
positions: new Float32Array(numPoints * 3),
scales: new Float32Array(numPoints * 3),
rotations: new Float32Array(numPoints * 4),
alphas: new Float32Array(numPoints),
colors: new Float32Array(numPoints * 3),
sh: new Float32Array(numPoints * shDim * 3)
};
// Decode positions
if (usesFloat16) {
// Decode legacy float16 format
const halfData = new Uint16Array(packed.positions.buffer, packed.positions.byteOffset, numPoints * 3);
for (let i = 0; i < numPoints * 3; i++) {
result.positions[i] = halfToFloat(halfData[i]);
}
}
else {
// Decode 24-bit fixed point coordinates
const scale = 1.0 / (1 << packed.fractionalBits);
for (let i = 0; i < numPoints * 3; i++) {
let fixed32 = packed.positions[i * 3 + 0];
fixed32 |= packed.positions[i * 3 + 1] << 8;
fixed32 |= packed.positions[i * 3 + 2] << 16;
fixed32 |= (fixed32 & 0x800000) ? 0xff000000 : 0; // sign extension
result.positions[i] = fixed32 * scale;
}
}
// Decode scales
for (let i = 0; i < numPoints * 3; i++) {
result.scales[i] = packed.scales[i] / 16.0 - 10.0;
}
// Decode rotations
for (let i = 0; i < numPoints; i++) {
const r = packed.rotations.subarray(i * 3, i * 3 + 3);
// Create xyz vector and apply scaling and offset
const xyz = [
r[0] / 127.5 - 1.0,
r[1] / 127.5 - 1.0,
r[2] / 127.5 - 1.0
];
// Copy xyz components
result.rotations[i * 4] = xyz[0];
result.rotations[i * 4 + 1] = xyz[1];
result.rotations[i * 4 + 2] = xyz[2];
// Compute the real component (w) - quaternion is normalized and w is non-negative
const squaredNorm = xyz[0] * xyz[0] + xyz[1] * xyz[1] + xyz[2] * xyz[2];
result.rotations[i * 4 + 3] = Math.sqrt(Math.max(0.0, 1.0 - squaredNorm));
}
// Decode alphas
for (let i = 0; i < numPoints; i++) {
result.alphas[i] = invSigmoid(packed.alphas[i] / 255.0);
}
// Decode colors
for (let i = 0; i < numPoints * 3; i++) {
result.colors[i] = ((packed.colors[i] / 255.0) - 0.5) / colorScale;
}
// Decode spherical harmonics
for (let i = 0; i < packed.sh.length; i++) {
result.sh[i] = unquantizeSH(packed.sh[i]);
}
return result;
}
// Helper function to check sizes (matching C++ checkSizes function)
function checkSizes2(packed, numPoints, shDim, usesFloat16) {
if (packed.positions.length !== numPoints * 3 * (usesFloat16 ? 2 : 3))
return false;
if (packed.scales.length !== numPoints * 3)
return false;
if (packed.rotations.length !== numPoints * 3)
return false;
if (packed.alphas.length !== numPoints)
return false;
if (packed.colors.length !== numPoints * 3)
return false;
if (packed.sh.length !== numPoints * shDim * 3)
return false;
return true;
}
const HEADER_SIZE = 16; // 4 + 4 + 4 + 1 + 1 + 1 + 1 bytes
const MAX_POINTS_TO_READ = 10000000;
function deserializePackedGaussians(buffer) {
const view = new DataView(buffer);
let offset = 0;
// Read and validate header
const header = {
magic: view.getUint32(offset, true),
version: view.getUint32(offset + 4, true),
numPoints: view.getUint32(offset + 8, true),
shDegree: view.getUint8(offset + 12),
fractionalBits: view.getUint8(offset + 13),
flags: view.getUint8(offset + 14),
reserved: view.getUint8(offset + 15)
};
offset += HEADER_SIZE;
// Validate header
if (header.magic !== SPZ_MAGIC) {
console.error("[SPZ ERROR] deserializePackedGaussians: header not found");
return null;
}
if (header.version < 1 || header.version > 2) {
console.error(`[SPZ ERROR] deserializePackedGaussians: version not supported: ${header.version}`);
return null;
}
if (header.numPoints > MAX_POINTS_TO_READ) {
console.error(`[SPZ ERROR] deserializePackedGaussians: Too many points: ${header.numPoints}`);
return null;
}
if (header.shDegree > 3) {
console.error(`[SPZ ERROR] deserializePackedGaussians: Unsupported SH degree: ${header.shDegree}`);
return null;
}
const numPoints = header.numPoints;
const shDim = dimForDegree(header.shDegree);
const usesFloat16 = header.version === 1;
// Initialize result object
const result = {
numPoints,
shDegree: header.shDegree,
fractionalBits: header.fractionalBits,
antialiased: (header.flags & FLAG_ANTIALIASED) !== 0,
positions: new Uint8Array(numPoints * 3 * (usesFloat16 ? 2 : 3)),
scales: new Uint8Array(numPoints * 3),
rotations: new Uint8Array(numPoints * 3),
alphas: new Uint8Array(numPoints),
colors: new Uint8Array(numPoints * 3),
sh: new Uint8Array(numPoints * shDim * 3)
};
// Read data sections
try {
const uint8View = new Uint8Array(buffer);
let positionsSize = result.positions.length;
let currentOffset = offset;
result.positions.set(uint8View.slice(currentOffset, currentOffset + positionsSize));
currentOffset += positionsSize;
result.alphas.set(uint8View.slice(currentOffset, currentOffset + result.alphas.length));
currentOffset += result.alphas.length;
result.colors.set(uint8View.slice(currentOffset, currentOffset + result.colors.length));
currentOffset += result.colors.length;
result.scales.set(uint8View.slice(currentOffset, currentOffset + result.scales.length));
currentOffset += result.scales.length;
result.rotations.set(uint8View.slice(currentOffset, currentOffset + result.rotations.length));
currentOffset += result.rotations.length;
result.sh.set(uint8View.slice(currentOffset, currentOffset + result.sh.length));
// Verify we read the expected amount of data
if (currentOffset + result.sh.length !== buffer.byteLength) {
console.error("[SPZ ERROR] deserializePackedGaussians: incorrect buffer size");
return null;
}
}
catch (error) {
console.error("[SPZ ERROR] deserializePackedGaussians: read error", error);
return null;
}
return result;
}
async function loadSpzPacked(compressedData) {
try {
const decompressed = await decompressGzipped(compressedData);
return deserializePackedGaussians(decompressed.buffer);
}
catch (error) {
console.error("[SPZ ERROR] loadSpzPacked: decompression error", error);
return null;
}
}
export async function loadSpz(buffer) {
const packed = await loadSpzPacked(buffer);
return unpackGaussians(packed);
}
@@ -0,0 +1,3 @@
import { PackedGaussians, GaussianCloud } from './types.js';
export declare function serializePackedGaussians(packed: PackedGaussians): Promise<Uint8Array>;
export declare function serializeSpz(g: GaussianCloud): Promise<Uint8Array>;
+43
View File
@@ -0,0 +1,43 @@
import { SPZ_MAGIC, SPZ_VERSION, FLAG_ANTIALIASED } from './constant.js';
import { compressGzipped } from './compression.js';
import { packGaussians } from './spz-loader.js';
export async function serializePackedGaussians(packed) {
const headerSize = 16;
const totalSize = headerSize +
packed.positions.length +
packed.alphas.length +
packed.colors.length +
packed.scales.length +
packed.rotations.length +
packed.sh.length;
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const uint8View = new Uint8Array(buffer);
// Write header
view.setUint32(0, SPZ_MAGIC, true);
view.setUint32(4, SPZ_VERSION, true);
view.setUint32(8, packed.numPoints, true);
view.setUint8(12, packed.shDegree);
view.setUint8(13, packed.fractionalBits);
view.setUint8(14, packed.antialiased ? FLAG_ANTIALIASED : 0);
view.setUint8(15, 0); // reserved
// Write data
let offset = headerSize;
uint8View.set(packed.positions, offset);
offset += packed.positions.length;
uint8View.set(packed.alphas, offset);
offset += packed.alphas.length;
uint8View.set(packed.colors, offset);
offset += packed.colors.length;
uint8View.set(packed.scales, offset);
offset += packed.scales.length;
uint8View.set(packed.rotations, offset);
offset += packed.rotations.length;
uint8View.set(packed.sh, offset);
return uint8View;
}
export async function serializeSpz(g) {
const packed = packGaussians(g);
const serialized = await serializePackedGaussians(packed);
return compressGzipped(serialized);
}
+1
View File
@@ -0,0 +1 @@
export {};
+26
View File
@@ -0,0 +1,26 @@
export type Vec3f = [number, number, number];
export type Quat4f = [number, number, number, number];
export type Half = number;
export interface PackedGaussians {
numPoints: number;
shDegree: number;
fractionalBits: number;
antialiased: boolean;
positions: Uint8Array;
scales: Uint8Array;
rotations: Uint8Array;
alphas: Uint8Array;
colors: Uint8Array;
sh: Uint8Array;
}
export interface GaussianCloud {
numPoints: number;
shDegree: number;
antialiased: boolean;
positions: Float32Array;
scales: Float32Array;
rotations: Float32Array;
alphas: Float32Array;
colors: Float32Array;
sh: Float32Array;
}
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,36 @@
{
"name": "spz-js",
"email": "ludovit@arrival.space",
"author": "Ludovit Kramar",
"version": "1.2.5",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"/dist/**/*"
],
"scripts": {
"build": "tsc"
},
"keywords": [
"spz",
"ply",
"converter",
"splat",
"3dgs",
"gaussian-splatting"
],
"description": "Convert gaussian splats between SPZ and PLY formats.",
"devDependencies": {
"typescript": "^5.7.2"
},
"type": "module",
"homepage": "https://github.com/arrival-space/spz-js",
"bugs": {
"url": "https://github.com/arrival-space/spz-js/issues",
"email": "ludovit@arrival.space"
},
"license": "MIT",
"dependencies": {
"playcanvas": "^2.3.3"
}
}