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:
@@ -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),
|
||||
}));
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
[](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 };
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { GaussianCloud } from './types.js';
|
||||
export declare function loadPly(readable: ReadableStream<Uint8Array>): Promise<GaussianCloud>;
|
||||
+184
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
+26
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user