playcanvas/spark dual backend

This commit is contained in:
PozzettiAndrea
2026-05-14 13:02:34 +02:00
parent 2839459333
commit d20556eb29
41 changed files with 20651 additions and 15100 deletions
-3
View File
@@ -3,9 +3,6 @@
"""ComfyUI-TurntableGSViewer — tiny Gaussian-splat turntable viewer node."""
from .preview_gaussian import PreviewGaussians
from .spz_route import register_routes as _register_spz_route
_register_spz_route()
WEB_DIRECTORY = "web"
+11 -5
View File
@@ -46,11 +46,17 @@ class PreviewGaussians:
"image_height": ("INT", {
"default": 512, "min": 64, "max": 4096, "step": 8,
}),
"renderer": (["webgl2", "webgpu"], {
"default": "webgl2",
"tooltip": "webgl2 = universal compatibility (mkkellogg/GaussianSplats3D); "
"webgpu = faster on Chrome/Safari (PlayCanvas), "
"falls back to webgl2 if the browser lacks WebGPU support",
"renderer": (["spark", "playcanvas"], {
"default": "spark",
"tooltip": (
"spark — Three.js + WebGL2. Best SH3 fidelity, "
"auto-detects all formats (PLY, compressed.ply, SPZ, "
"KSPLAT, SOG, SPLAT). "
"\n"
"playcanvas — WebGPU path (currently falls back to "
"spark; real adapter pending). Will win on large "
"scenes (5M+ splats) when implemented."
),
}),
},
}
+2 -4
View File
@@ -1,4 +1,2 @@
# spz: Niantic's compressed Gaussian-splat format. When the viewer's
# "Compress (SPZ)" toggle is on, we transcode the PLY on the server and
# ship the ~6x smaller .spz to the client.
spz
# No Python deps — PLY parsing happens in the browser via gsplat.js.
# Server-side gzip transport in spz_route.py uses Python stdlib only.
+10 -64
View File
@@ -1,65 +1,11 @@
// Read viewer + splatMesh + renderer state. Pure data dump, no side-effects
// (no manual render, no forced sort).
//
// Returned as a JSON-friendly object. window.__gs.run() calls this with
// (adapter, viewer, splatMesh, THREE, GS) bound.
const out = {};
const r = viewer.renderer;
out.gates = {
initialized: viewer.initialized,
splatRenderReady: viewer.splatRenderReady,
disposing: viewer.disposing,
disposed: viewer.disposed,
sortRunning: viewer.sortRunning,
selfDrivenModeRunning: viewer.selfDrivenModeRunning,
splatRenderCount: viewer.splatRenderCount,
lastSplatSortCount: viewer.lastSplatSortCount,
lastSortTime: viewer.lastSortTime,
gpuAcceleratedSort: viewer.gpuAcceleratedSort,
// State probe for the Spark-based adapter. Pure read; no side effects.
return {
adapterReady: !!adapter,
adapterKind: adapter && adapter.kind && adapter.kind(),
splatCount: splatMesh?.numSplats ?? splatMesh?.getSplatCount?.() ?? 0,
cameraPos: adapter ? adapter.getCameraPosition() : null,
cameraTarget: adapter ? adapter.getCameraTarget() : null,
fov: adapter && adapter.camera ? adapter.camera.fov : null,
frames: adapter && adapter._frameCount,
sceneChildren: scene ? scene.children.map(c => ({ type: c.type, visible: c.visible })) : null,
};
out.rendererInfo = JSON.parse(JSON.stringify(r.info));
out.size = r.getSize(new THREE.Vector2()).toArray();
out.dprSize = r.getDrawingBufferSize(new THREE.Vector2()).toArray();
out.autoClear = r.autoClear;
out.renderTarget = !!r.getRenderTarget();
out.camera = {
pos: viewer.camera.position.toArray(),
quat: viewer.camera.quaternion.toArray(),
near: viewer.camera.near, far: viewer.camera.far,
fov: viewer.camera.fov, aspect: viewer.camera.aspect,
};
const g = splatMesh.geometry;
out.geom = {
type: splatMesh.type,
geomType: g.type,
drawRange: { start: g.drawRange.start, count: g.drawRange.count },
instanceCount: g.instanceCount,
visible: splatMesh.visible,
inScene: !!splatMesh.parent,
frustumCulled: splatMesh.frustumCulled,
attrs: {},
};
for (const k of Object.keys(g.attributes || {})) {
const a = g.attributes[k];
out.geom.attrs[k] = {
itemSize: a.itemSize,
count: a.count,
instanced: !!a.isInstancedBufferAttribute,
};
}
// Material
const mat = splatMesh.material;
out.material = {
type: mat && mat.type,
visible: mat && mat.visible,
transparent: mat && mat.transparent,
program: !!(mat && mat.program),
uniformsKeys: mat && mat.uniforms ? Object.keys(mat.uniforms) : null,
};
return out;
+12 -3
View File
@@ -39,18 +39,27 @@ def main():
ctx = browser.new_context(viewport={"width": 1024, "height": 768})
page = ctx.new_page()
page.on("console", lambda m: print(f" {m.text}") if "heartbeat" not in m.text else None)
page.on("requestfailed", lambda r: print(f" [requestfailed] {r.url} - {r.failure}"))
page.on("response", lambda r: print(f" [response {r.status}] {r.url}") if r.status >= 400 else None)
page.goto(f"http://127.0.0.1:{PORT}/viewer_gaussian.html", wait_until="load")
page.wait_for_function("typeof window.__gs === 'object'")
page.evaluate("""async () => {
# Pick the iframe-side filename to match the source extension so the
# adapter picks the right SceneFormat (.ply vs .spz).
ext = PLY_PATH.suffix.lower()
iframe_filename = f"t{ext}" if ext in (".ply", ".spz") else "t.ply"
import os as _os
chosen_renderer = _os.environ.get("RENDERER", "spark")
print(f"[harness] renderer: {chosen_renderer}")
page.evaluate("""async ({fname, rend}) => {
window._loaded = false; window._error = null;
addEventListener('message', e => {
if (e.data?.type === 'MESH_LOADED') window._loaded = true;
if (e.data?.type === 'MESH_ERROR') window._error = e.data.error;
});
const r = await fetch('/test.ply'); const buf = await r.arrayBuffer();
postMessage({ type:'LOAD_MESH_DATA', data: buf, filename:'t.ply', renderer:'webgl2' }, '*');
}""")
postMessage({ type: 'LOAD_MESH_DATA', data: buf, filename: fname, renderer: rend }, '*');
}""", {"fname": iframe_filename, "rend": chosen_renderer})
page.wait_for_function("window._loaded || window._error", timeout=180000)
page.wait_for_timeout(1500)
+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}`);
};
```
+2
View File
@@ -0,0 +1,2 @@
import { GaussianCloud } from "./types.js";
export declare function loadCompressedPly(stream: ReadableStream<Uint8Array>): Promise<GaussianCloud>;
+474
View File
@@ -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;
+356
View File
@@ -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;
}
// --------------------------------------------------------------------------
+2
View File
@@ -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);
}
+3
View File
@@ -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 {};
+36
View File
@@ -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"
}
}
-137
View File
@@ -1,137 +0,0 @@
"""HTTP route that transcodes a PLY to SPZ on demand and serves it.
Registered into ComfyUI's aiohttp server (PromptServer.instance.routes).
The first request for a given PLY pays the conversion cost and writes a
sibling `.spz` next to the PLY; subsequent requests stream the cached SPZ
directly.
Query:
/turntablegsviewer/spz?filename=<output-relative-name>&subfolder=<sub>
[&type=output]
Mirrors ComfyUI's /view endpoint shape so the JS side can build URLs the
same way it does today.
"""
import logging
import os
import threading
from pathlib import Path
from aiohttp import web
import folder_paths
log = logging.getLogger("comfyui-turntablegsviewer")
# Per-path lock so two concurrent requests for the same PLY don't both
# kick off the transcode in parallel.
_locks: dict[str, threading.Lock] = {}
_locks_lock = threading.Lock()
def _lock_for(p: str) -> threading.Lock:
with _locks_lock:
if p not in _locks:
_locks[p] = threading.Lock()
return _locks[p]
def _resolve_output_path(filename: str, subfolder: str, type_: str) -> Path | None:
"""Resolve a /view-style (filename, subfolder, type) to an absolute path,
refusing anything that escapes the output directory."""
base_dir = {
"output": folder_paths.get_output_directory(),
"input": folder_paths.get_input_directory(),
"temp": folder_paths.get_temp_directory(),
}.get(type_, folder_paths.get_output_directory())
base = Path(base_dir).resolve()
candidate = (base / (subfolder or "") / filename).resolve()
try:
candidate.relative_to(base)
except ValueError:
return None
return candidate
def _transcode_ply_to_spz(ply_path: Path, spz_path: Path) -> None:
"""Run the spz package's PLY → SPZ pipeline and write atomically."""
import spz
tmp = spz_path.with_suffix(spz_path.suffix + ".part")
unpack = spz.UnpackOptions()
cloud = spz.load_splat_from_ply(str(ply_path), unpack)
pack = spz.PackOptions()
ok = spz.save_spz(cloud, pack, str(tmp))
if not ok:
try: tmp.unlink()
except FileNotFoundError: pass
raise RuntimeError(f"spz.save_spz failed for {ply_path}")
tmp.replace(spz_path)
def register_routes() -> None:
"""Register the /turntablegsviewer/spz route. Idempotent and a no-op
if PromptServer isn't available (e.g. during static analysis)."""
try:
from server import PromptServer
except ImportError:
log.warning("PromptServer not importable; SPZ route not registered.")
return
if PromptServer.instance is None:
log.warning("PromptServer.instance is None; SPZ route not registered.")
return
routes = PromptServer.instance.routes
@routes.get("/turntablegsviewer/spz")
async def get_spz(request: web.Request) -> web.StreamResponse:
filename = request.query.get("filename", "")
subfolder = request.query.get("subfolder", "")
type_ = request.query.get("type", "output")
if not filename:
return web.Response(status=400, text="missing 'filename'")
if not filename.lower().endswith(".ply"):
return web.Response(status=400, text="filename must end in .ply")
ply = _resolve_output_path(filename, subfolder, type_)
if ply is None:
return web.Response(status=403, text="path traversal rejected")
if not ply.is_file():
return web.Response(status=404, text=f"PLY not found: {ply}")
spz = ply.with_suffix(".spz")
# Cache valid only if newer than the PLY.
cache_fresh = spz.is_file() and spz.stat().st_mtime >= ply.stat().st_mtime
if not cache_fresh:
with _lock_for(str(spz)):
# Re-check after acquiring the lock — someone else may have
# finished while we were waiting.
cache_fresh = spz.is_file() and spz.stat().st_mtime >= ply.stat().st_mtime
if not cache_fresh:
log.info("SPZ cache miss; transcoding %s -> %s", ply.name, spz.name)
try:
_transcode_ply_to_spz(ply, spz)
except ImportError:
return web.Response(
status=500,
text=("'spz' package not installed in the ComfyUI env. "
"Disable the 'Compress (SPZ)' toggle or "
"`pip install spz`."),
)
except Exception as e:
log.exception("SPZ transcode failed for %s", ply)
return web.Response(status=500, text=f"SPZ transcode failed: {e}")
log.info(
"SPZ written: %s (%.1f MB → %.1f MB)",
spz.name,
ply.stat().st_size / (1024 * 1024),
spz.stat().st_size / (1024 * 1024),
)
return web.FileResponse(
spz,
headers={
"Content-Type": "application/octet-stream",
"Cache-Control": "no-cache",
},
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -68
View File
@@ -208,23 +208,6 @@ app.registerExtension({
pendingRestoreJSON = savedCfg.state;
}
// Viewer-only "Use SPZ" preference, persisted to workflow JSON.
// Defaults to true (SPZ is much smaller for the same scene).
this.properties = this.properties || {};
if (typeof this.properties["Use SPZ"] !== "boolean") {
this.properties["Use SPZ"] = true;
}
const getUseSPZ = () => !!this.properties["Use SPZ"];
const sendSettings = () => {
if (!iframe.contentWindow) return;
iframe.contentWindow.postMessage({
type: "RESTORE_VIEWER_SETTINGS",
settings: { useSPZ: getUseSPZ() },
}, "*");
};
this._sendSettings = sendSettings;
// Iframe-display widget. serialize: false → never lands in
// widgets_values; we own persistence via node.properties.
const widget = this.addDOMWidget(
@@ -264,9 +247,6 @@ app.registerExtension({
// Push any pending saved camera state now that the iframe is alive
// (e.g. on workflow reload before the node has been re-queued).
sendRestore();
// Also push the current SPZ toggle state so the checkbox
// reflects the workflow's saved preference.
sendSettings();
});
// Listen for messages from iframe
@@ -276,21 +256,6 @@ app.registerExtension({
// node's state.
if (event.source !== iframe.contentWindow) return;
// Settings toggle (currently just SPZ compression) from
// iframe → persist on node.properties + re-fetch.
if (event.data?.type === "SETTING_CHANGED" && event.data.key) {
const key = event.data.key;
if (key === "useSPZ") {
node.properties = node.properties || {};
node.properties["Use SPZ"] = !!event.data.value;
console.log("[TurntableGSViewer] Use SPZ ->", node.properties["Use SPZ"]);
// Re-fetch with the new format if a PLY is loaded.
if (typeof node._reloadSplat === "function") {
node._reloadSplat();
}
}
return;
}
// Camera-state pushes from iframe → persist on
// node.properties["Camera Config"] (Load3D pattern).
if (event.data?.type === "CAMERA_STATE" && event.data.state) {
@@ -418,25 +383,12 @@ app.registerExtension({
</div>
`;
// Build a fetch URL based on the current SPZ toggle.
// ON → /turntablegsviewer/spz (lazy server-side transcode + cache)
// OFF → ComfyUI's /view endpoint, raw PLY
// The iframe picks SceneFormat from the extension in
// `filename`, so we also flip that.
const buildFetchPlan = () => {
if (getUseSPZ()) {
return {
url: `/turntablegsviewer/spz?filename=${encodeURIComponent(filename)}&type=output&subfolder=`,
filename: filename.replace(/\.ply$/i, ".spz"),
label: "Compressing / downloading SPZ...",
};
}
return {
url: `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`,
filename: filename,
label: "Downloading PLY...",
};
};
// ComfyUI's /view endpoint streams the file LiTo wrote.
// Format choice (PLY / SPZ / compressed.ply) is decided
// at export time inside LiTo — the iframe just gets
// bytes and auto-detects via the renderer.
const filepath = `/view?filename=${encodeURIComponent(filename)}&type=output&subfolder=`;
const loadLabel = "Downloading splat...";
// Function to fetch and send data to iframe
const fetchAndSend = async () => {
@@ -445,18 +397,19 @@ app.registerExtension({
return;
}
const plan = buildFetchPlan();
const filepath = plan.url;
try {
// Fetch the file from parent context (authenticated).
// Stream so we can log download progress (often >200 MB for PLY).
// Stream so we can log download progress.
const t0 = performance.now();
console.log("[TurntableGSViewer] fetch start:", filepath);
node._showLoadBar?.(plan.label);
node._showLoadBar?.(loadLabel);
const response = await fetch(filepath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
// Bubble the server's body up — for /turntablegsviewer/spz
// we put an actionable message there (e.g. "spz not installed").
let body = "";
try { body = (await response.text() || "").slice(0, 400); } catch (_) {}
throw new Error(`HTTP ${response.status}: ${response.statusText}${body ? " - " + body : ""}`);
}
const totalHdr = response.headers.get("content-length");
const total = totalHdr ? parseInt(totalHdr, 10) : 0;
@@ -499,15 +452,15 @@ app.registerExtension({
const speed = received > 0 ? (received / (1024*1024)) / (dt / 1000) : 0;
console.log(`[TurntableGSViewer] fetch done: ${arrayBuffer.byteLength} bytes in ${dt.toFixed(0)} ms (${speed.toFixed(1)} MB/s)`);
// Send the data to iframe with camera parameters.
// `plan.filename` is `.spz` or `.ply` so the
// adapter can pick SceneFormat.
console.log("[TurntableGSViewer] posting LOAD_MESH_DATA to iframe, renderer:", renderer, "filename:", plan.filename);
// Send the data to iframe — filename carries
// the extension so the renderer can sniff
// format (Spark/PlayCanvas both auto-detect).
console.log("[TurntableGSViewer] posting LOAD_MESH_DATA to iframe, renderer:", renderer, "filename:", filename);
node._setLoadProgress?.(100, "Parsing splats...");
iframe.contentWindow.postMessage({
type: "LOAD_MESH_DATA",
data: arrayBuffer,
filename: plan.filename,
filename: filename,
extrinsics: extrinsics,
intrinsics: intrinsics,
renderer: renderer,
@@ -519,9 +472,6 @@ app.registerExtension({
}
};
// Expose so the SPZ toggle handler can re-fetch.
node._reloadSplat = fetchAndSend;
// Fetch and send when iframe is ready
if (iframeLoaded) {
fetchAndSend();
+16533
View File
File diff suppressed because one or more lines are too long
+6
View File
File diff suppressed because one or more lines are too long
-7
View File
File diff suppressed because one or more lines are too long
+6
View File
File diff suppressed because one or more lines are too long
+199 -318
View File
@@ -155,6 +155,16 @@
padding-bottom: 4px;
}
</style>
<!-- ES-module import map so Spark can find `three` -->
<script type="importmap">
{
"imports": {
"three": "./js/three.module.min.js",
"three/addons/controls/OrbitControls.js": "./js/OrbitControls.js"
}
}
</script>
</head>
<body>
<div id="viewerContainer">
@@ -183,13 +193,6 @@
<input type="range" id="opacityRange" min="0.05" max="1" step="0.01" value="1.0">
<input type="number" id="opacityInput" min="0.05" max="1" step="0.01" value="1.0">
</div>
<div class="row" style="grid-template-columns: 70px 1fr;">
<label for="useSpz">SPZ</label>
<label style="display:flex;align-items:center;gap:6px;color:#aaa;cursor:pointer;">
<input type="checkbox" id="useSpz" checked>
<span style="font-size:10px;">Compress (~6× smaller, lossy)</span>
</label>
</div>
</div>
</div>
@@ -207,34 +210,49 @@
<button id="screenshot" title="Take screenshot">Screenshot</button>
</div>
<script>
<script type="module">
// ============================================================
// Gaussian Splat Viewer — dual backend (WebGL2 + WebGPU)
// Gaussian Splat Viewer — Spark (Three.js + WebGL2)
//
// Two renderers are bundled but only the chosen one is loaded
// on the first LOAD_MESH_DATA postMessage:
// - WebGL2: Three.js + mkkellogg/GaussianSplats3D (SH=3)
// - WebGPU: PlayCanvas engine (gsplat component, SH=3)
// Renderer is @sparkjsdev/spark — auto-detects file format
// (PLY / compressed.ply / SPZ / SPLAT / KSPLAT / SOG / ZIP / RAD)
// from header bytes, and preserves SH3 better than mkkellogg's
// SPZ-quantized path.
//
// The rest of the viewer (camera persistence, settings panel,
// axis buttons, screenshot, info panel) speaks to a small
// adapter interface so the rendering library can be swapped at
// runtime without rewiring the UI.
// The renderer dropdown still has a `webgpu` option; for now it
// logs a fallback warning and uses Spark (WebGL2). A real
// PlayCanvas WebGPU adapter is a follow-up.
// ============================================================
const canvas = document.getElementById('canvas');
const errorEl = document.getElementById('error');
const infoPanel = document.getElementById('infoPanel');
const infoContent = document.getElementById('infoContent');
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { SparkRenderer, SplatMesh } from './js/spark.module.min.js';
// Lazy <script> loader for non-module bundles (PlayCanvas).
const _loaded = new Set();
function loadScript(src) {
if (_loaded.has(src)) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src; s.async = false;
s.onload = () => { _loaded.add(src); resolve(); };
s.onerror = () => reject(new Error("Failed to load " + src));
document.head.appendChild(s);
});
}
const canvas = document.getElementById('canvas');
const errorEl = document.getElementById('error');
const infoPanel = document.getElementById('infoPanel');
const infoContent = document.getElementById('infoContent');
const settingsPanel = document.getElementById('settingsPanel');
const settingsBtn = document.getElementById('settingsBtn');
const fovRange = document.getElementById('fovRange');
const fovInput = document.getElementById('fovInput');
const scaleRange = document.getElementById('scaleRange');
const scaleInput = document.getElementById('scaleInput');
const opacityRange = document.getElementById('opacityRange');
const opacityInput = document.getElementById('opacityInput');
const useSpzCheck = document.getElementById('useSpz');
const settingsBtn = document.getElementById('settingsBtn');
const fovRange = document.getElementById('fovRange');
const fovInput = document.getElementById('fovInput');
const scaleRange = document.getElementById('scaleRange');
const scaleInput = document.getElementById('scaleInput');
const opacityRange = document.getElementById('opacityRange');
const opacityInput = document.getElementById('opacityInput');
canvas.tabIndex = 0;
canvas.style.outline = "none";
@@ -247,99 +265,49 @@
}
// ---- shared viewer state ------------------------------------
let adapter = null; // current renderer adapter
let adapter = null;
let adapterReady = false;
let pendingRestore = null; // applied right after the next splat load
let pendingRestore = null;
let currentFovDeg = 50;
let currentScale = 20.0;
let currentOpacity = 1.0;
let initialPose = null; // { pos, target, fov } captured at framing time
const DEFAULT_SCALE_MULTIPLIER = 20.0;
let initialPose = null;
function setFovInputs(v) { fovRange.value = v; fovInput.value = v; }
function setScaleInputs(v) { scaleRange.value = v; scaleInput.value = v; }
function setOpacityInputs(v) { opacityRange.value = v; opacityInput.value = v; }
// ---- script loader (UMD libs, ordered) ----------------------
const _loaded = new Set();
function loadScript(src) {
if (_loaded.has(src)) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src; s.async = false;
s.onload = () => { _loaded.add(src); resolve(); };
s.onerror = () => reject(new Error("Failed to load " + src));
document.head.appendChild(s);
});
}
async function bootRenderer(kind) {
// Phase-A0d: until a real PlayCanvas adapter ships, "webgpu" is a
// synonym for "webgl2". Doing the fallback at the bootloader level
// (instead of in PCSplatAdapter) avoids loading three.min.js twice
// and the "Multiple instances of Three.js" warning that comes with
// it.
if (kind === "webgpu") {
console.warn("[GaussianViewer] webgpu requested; falling back to webgl2 "
+ "(PlayCanvas adapter not implemented yet)");
kind = "webgl2";
}
await loadScript("./js/three.min.js");
await loadScript("./js/gaussian-splats-3d.umd.js");
return new MkkelloggAdapter(canvas);
}
// ============================================================
// Adapter: WebGL2 — mkkellogg/GaussianSplats3D + Three.js
// Base: Three.js scene + camera + WebGLRenderer + OrbitControls
// ============================================================
class MkkelloggAdapter {
class ThreeBaseAdapter {
constructor(canvas) {
this.canvas = canvas;
this.viewer = null;
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.splatMesh = null;
this._frameCount = 0;
this._raf = null;
}
kind() { return "webgl2"; }
init() {
const GS = window["Gaussian Splats 3D"];
if (!GS) throw new Error("GaussianSplats3D library not loaded");
if (!window.THREE) throw new Error("THREE not loaded");
// Construct an external WebGLRenderer that targets OUR canvas so
// the iframe doesn't grow a second canvas next to it.
_initThree(logTag) {
const W = this.canvas.clientWidth || 512;
const H = this.canvas.clientHeight || 512;
const dpr = window.devicePixelRatio || 1;
console.log(`[Mkkellogg] init: canvas client=${W}x${H} dpr=${dpr.toFixed(2)} parent=#${this.canvas.parentElement?.id}`);
console.log(`[Mkkellogg] container has ${this.canvas.parentElement?.children.length} child(ren) before init`);
// Phase-A0a: WebGLRenderer construction can throw with
// "Web page caused context loss and was blocked" when Chrome's
// per-tab anti-DoS guard has been tripped by repeated iframe
// reloads. Catch it, paint a clear error overlay, and bubble
// the failure to the parent so the loading bar turns red.
console.log(`[${logTag}] init: canvas client=${W}x${H} dpr=${dpr.toFixed(2)}`);
let renderer;
try {
renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
antialias: false,
alpha: false,
// preserveDrawingBuffer:true so the canvas keeps its last
// rendered frame on the screen during idle periods.
// mkkellogg's selfDrivenUpdate only calls renderer.render
// when shouldRender() reports a real reason to paint
// (camera changed, visible-region fading in, etc.). On
// idle frames it skips the render — and with the default
// preserveDrawingBuffer:false, the browser compositor
// clears the back buffer after composite, leaving us
// with a black canvas. This costs a small amount of
// memory bandwidth but matters for a static viewer.
preserveDrawingBuffer: true,
});
} catch (err) {
const msg = "WebGL context creation refused — reload the ComfyUI tab once "
+ "(Chrome anti-DoS guard). Detail: " + (err?.message || err);
console.error("[Mkkellogg] FATAL:", msg);
console.error(`[${logTag}] FATAL:`, msg);
showError(msg);
try {
window.parent.postMessage({ type: "MESH_ERROR", error: msg, timestamp: Date.now() }, "*");
@@ -349,14 +317,11 @@
renderer.setPixelRatio(dpr);
renderer.setSize(W, H, false);
renderer.setClearColor(0x1a1a1a, 1.0);
console.log("[Mkkellogg] WebGLRenderer ready. drawingBufferSize=",
renderer.domElement.width, "x", renderer.domElement.height);
this.renderer = renderer;
// Phase-A0b: surface context-loss events. preventDefault() in
// the lost handler is what lets the canvas come back later.
this.canvas.addEventListener("webglcontextlost", (e) => {
e.preventDefault();
console.warn("[Mkkellogg] WebGL context LOST (preventDefault — waiting for restore)");
console.warn(`[${logTag}] WebGL context LOST`);
try {
window.parent.postMessage({
type: "MESH_ERROR",
@@ -366,212 +331,171 @@
} catch (_) {}
});
this.canvas.addEventListener("webglcontextrestored", () => {
console.log("[Mkkellogg] WebGL context RESTORED");
console.log(`[${logTag}] WebGL context RESTORED`);
});
const camera = new THREE.PerspectiveCamera(50, W / Math.max(1, H), 0.05, 1000);
camera.position.set(0, 0, -3);
camera.lookAt(0, 0, 0);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, W / Math.max(1, H), 0.05, 1000);
this.camera.position.set(0, 0, -3);
this.camera.lookAt(0, 0, 0);
// Phase-A A3: ?upY=-1 in the iframe URL flips the camera-up
// probe (LiTo may be Y-down). Default is Y-up.
const _upY = new URLSearchParams(location.search).get("upY");
const _camUp = _upY === "-1" ? [0, -1, 0] : [0, 1, 0];
console.log("[Mkkellogg] cameraUp =", _camUp, "(set ?upY=-1 in iframe src to flip)");
this.controls = new OrbitControls(this.camera, this.canvas);
this.controls.enableDamping = false;
this.controls.target.set(0, 0, 0);
this.controls.update();
// gpuAcceleratedSort:true is incompatible with this version of
// mkkellogg/GaussianSplats3D when the caller provides its own
// renderer. With gpuAcceleratedSort=true the splat centers are
// never uploaded to the sort worker, the worker's
// uploadedSplatCount stays 0, and every sort returns
// splatRenderCount=0 → drawRange.count=0 → zero draw calls →
// black canvas. (Found via Playwright trace: updateRenderIndexes
// was called with renderSplatCount=0.) Fall back to CPU-side
// distance computation, which does upload centers.
this.viewer = new GS.Viewer({
renderer: renderer,
camera: camera,
useBuiltInControls: true,
sphericalHarmonicsDegree: 3,
gpuAcceleratedSort: false,
sharedMemoryForWorkers: false,
ignoreDevicePixelRatio: false,
initialCameraPosition: [0, 0, -3],
initialCameraLookAt: [0, 0, 0],
cameraUp: _camUp,
selfDrivenMode: true,
});
console.log("[Mkkellogg] viewer constructed.");
this.viewer.start();
console.log("[Mkkellogg] viewer.start() returned. self-driven RAF active.");
// Heartbeat: count rendered frames; log first one + every 60th.
this._frameCount = 0;
const origUpdate = this.viewer.selfDrivenUpdateFunc;
this.viewer.selfDrivenUpdateFunc = (...args) => {
origUpdate.apply(this.viewer, args);
this._frameCount += 1;
if (this._frameCount === 1) console.log("[Mkkellogg] first frame rendered.");
else if (this._frameCount % 60 === 0) {
console.log(`[Mkkellogg] heartbeat: ${this._frameCount} frames`);
}
};
// Re-bind the OrbitControls to our canvas so wheel/drag events on
// the correct element drive the camera.
if (this.viewer.controls && this.viewer.controls.domElement !== this.canvas) {
this.viewer.controls.domElement = this.canvas;
}
// Resize-observer keeps the renderer matching the canvas size.
const ro = new ResizeObserver(() => {
const w = this.canvas.clientWidth || 1;
const h = this.canvas.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / Math.max(1, h);
camera.updateProjectionMatrix();
this.camera.aspect = w / Math.max(1, h);
this.camera.updateProjectionMatrix();
});
ro.observe(this.canvas);
}
async loadSplat(blobUrl, filename) {
const GS = window["Gaussian Splats 3D"];
const isSpz = typeof filename === "string" && filename.toLowerCase().endsWith(".spz");
const sceneFormat = isSpz ? GS.SceneFormat.Spz : GS.SceneFormat.Ply;
// addSplatScene() appends to the existing scene list; if the
// user runs the workflow twice or swaps inputs, the old splats
// would stick around and overlap the new ones. Tear down any
// existing scenes first.
const existingSceneCount = this.viewer.getSceneCount ? this.viewer.getSceneCount() : 0;
if (existingSceneCount > 0) {
const idxs = [];
for (let i = 0; i < existingSceneCount; i++) idxs.push(i);
console.log(`[Mkkellogg] removing ${existingSceneCount} existing scene(s) before reload`);
try { await this.viewer.removeSplatScenes(idxs, false); }
catch (e) { console.warn("[Mkkellogg] removeSplatScenes failed:", e?.message || e); }
}
console.log(`[Mkkellogg] addSplatScene starting (format=${isSpz ? "spz" : "ply"})...`);
const t0 = performance.now();
await this.viewer.addSplatScene(blobUrl, {
format: sceneFormat,
showLoadingUI: false,
progressiveLoad: false,
// Phase-A A1: was 5/255; mkkellogg's default cuts off any
// splat whose post-sigmoid alpha is below ~2 %. LiTo's
// opacity distribution may sit near that boundary and lose
// most of the cloud. Keep all splats for now.
splatAlphaRemovalThreshold: 0,
});
const dt = performance.now() - t0;
this.splatMesh = this.viewer.splatMesh;
const splatCount = this.splatMesh?.getSplatCount?.() ?? 0;
const sceneCount = this.splatMesh?.getSceneCount?.() ?? 0;
console.log(`[Mkkellogg] addSplatScene resolved in ${dt.toFixed(0)} ms, `
+ `${splatCount} splats across ${sceneCount} scene(s).`);
const container = this.canvas.parentElement;
console.log("[Mkkellogg] container children after load:",
Array.from(container.children)
.map(c => c.tagName.toLowerCase() + (c.id ? "#" + c.id : "")).join(", "));
console.log("[Mkkellogg] canvas: client=",
this.canvas.clientWidth, "x", this.canvas.clientHeight,
" drawing=", this.canvas.width, "x", this.canvas.height);
}
getBounds() {
if (!this.splatMesh) return null;
// mkkellogg's splatMesh is a shader-driven mesh; its Three.js
// bounding box is empty. We sample the splat centers and use
// 1st/99th percentile per axis as the framing bounds — strict
// min/max gets blown out by a handful of outlier "background"
// splats and leaves the actual object as a tiny dot in the
// frame.
const n = this.splatMesh.getSplatCount();
if (!n) return null;
const v = new THREE.Vector3();
const step = (n > 50000) ? Math.ceil(n / 50000) : 1;
const xs = [], ys = [], zs = [];
for (let i = 0; i < n; i += step) {
this.splatMesh.getSplatCenter(i, v);
xs.push(v.x); ys.push(v.y); zs.push(v.z);
}
if (!xs.length) return null;
const cmp = (a, b) => a - b;
xs.sort(cmp); ys.sort(cmp); zs.sort(cmp);
const k = xs.length;
// 5/95 percentile — Gaussian-splat scenes often have a thin
// halo of low-opacity splats well outside the actual object;
// strict min/max (or 1/99) leaves them dominating the bounds.
const lo = Math.floor(k * 0.05), hi = Math.floor(k * 0.95);
const minX = xs[lo], maxX = xs[hi];
const minY = ys[lo], maxY = ys[hi];
const minZ = zs[lo], maxZ = zs[hi];
return {
center: { x: (minX + maxX) / 2, y: (minY + maxY) / 2, z: (minZ + maxZ) / 2 },
size: { x: maxX - minX, y: maxY - minY, z: maxZ - minZ },
_startRenderLoop(logTag) {
const tick = () => {
this._raf = requestAnimationFrame(tick);
this.controls.update();
this.renderer.render(this.scene, this.camera);
this._frameCount += 1;
if (this._frameCount === 1) console.log(`[${logTag}] first frame rendered.`);
else if (this._frameCount % 120 === 0) console.log(`[${logTag}] heartbeat: ${this._frameCount} frames`);
};
tick();
}
getCameraPosition() {
const p = this.viewer.camera.position;
const p = this.camera.position;
return { x: p.x, y: p.y, z: p.z };
}
setCameraPosition(x, y, z) {
this.viewer.camera.position.set(x, y, z);
}
setCameraPosition(x, y, z) { this.camera.position.set(x, y, z); }
getCameraTarget() {
const t = this.viewer.controls?.target;
return t ? { x: t.x, y: t.y, z: t.z } : { x: 0, y: 0, z: 0 };
const t = this.controls.target;
return { x: t.x, y: t.y, z: t.z };
}
setCameraTarget(x, y, z) {
if (this.viewer.controls) {
this.viewer.controls.target.set(x, y, z);
this.viewer.controls.update();
}
this.controls.target.set(x, y, z);
this.controls.update();
}
setFov(deg) {
this.viewer.camera.fov = deg;
this.viewer.camera.updateProjectionMatrix();
this.camera.fov = deg;
this.camera.updateProjectionMatrix();
}
// TODO(v2): per-splat scale / opacity mutation against
// splatMesh.splatBuffer. The slider events still fire so we
// keep the UI live; visual effect lands in a follow-up.
setScaleMultiplier(_f) { /* no-op for v1 */ }
setOpacityMultiplier(_f) { /* no-op for v1 */ }
screenshot() {
return this.viewer.renderer.domElement.toDataURL("image/png");
this.renderer.render(this.scene, this.camera);
return this.renderer.domElement.toDataURL("image/png");
}
}
// ============================================================
// Adapter: WebGPU — PlayCanvas (stub for v1)
// Adapter: Spark — best SH3 fidelity, all formats
// ============================================================
class PCSplatAdapter {
class SparkAdapter extends ThreeBaseAdapter {
constructor(canvas) {
super(canvas);
this.sparkRenderer = null;
}
kind() { return "spark"; }
init() {
this._initThree("Spark");
// SparkRenderer is itself a THREE.Mesh — adding it to the scene
// hooks its onBeforeRender for per-frame splat sort / LOD work.
this.sparkRenderer = new SparkRenderer({ renderer: this.renderer });
this.scene.add(this.sparkRenderer);
this._startRenderLoop("Spark");
}
async loadSplat(blobUrl, filename) {
// Replace any existing scene.
if (this.splatMesh) {
console.log("[Spark] removing existing SplatMesh before reload");
this.scene.remove(this.splatMesh);
try { this.splatMesh.dispose(); } catch (_) {}
this.splatMesh = null;
}
console.log(`[Spark] new SplatMesh url=${filename}`);
const t0 = performance.now();
const splatMesh = new SplatMesh({
url: blobUrl,
fileName: filename, // hint for Spark's format sniff (also reads bytes)
onProgress: (e) => {
if (e?.total) {
const pct = (e.loaded / e.total) * 100;
if (Math.floor(pct) % 10 === 0) {
console.log(`[Spark] parse ${pct.toFixed(0)}%`);
}
}
},
});
this.scene.add(splatMesh);
await splatMesh.initialized;
this.splatMesh = splatMesh;
const dt = performance.now() - t0;
console.log(`[Spark] loaded ${splatMesh.numSplats} splats in ${dt.toFixed(0)} ms`);
}
getBounds() {
if (!this.splatMesh) return null;
let bbox;
try { bbox = this.splatMesh.getBoundingBox(true); } catch (e) {
console.warn("[Spark] getBoundingBox failed:", e?.message);
return null;
}
if (!bbox || bbox.isEmpty()) return null;
const min = bbox.min, max = bbox.max;
return {
center: { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 },
size: { x: max.x - min.x, y: max.y - min.y, z: max.z - min.z },
};
}
// Spark per-mesh splatScale / splatOpacity multipliers.
setScaleMultiplier(f) {
if (this.splatMesh) this.splatMesh.splatScale = f / 20.0;
}
setOpacityMultiplier(f) {
if (this.splatMesh) this.splatMesh.splatOpacity = f;
}
}
// ============================================================
// Adapter: PlayCanvas Engine — WebGPU (TODO real impl)
// ============================================================
class PlayCanvasAdapter {
constructor(canvas) {
console.warn("[GaussianViewer] PCSplatAdapter (WebGPU) is a stub in v1; "
+ "falling back to mkkellogg WebGL2 path.");
this.canvas = canvas;
this._fallback = null;
}
kind() { return "webgl2-fallback"; }
kind() { return "playcanvas-fallback"; }
async init() {
await loadScript("./js/three.min.js");
await loadScript("./js/gaussian-splats-3d.umd.js");
this._fallback = new MkkelloggAdapter(this.canvas);
console.warn("[PlayCanvas] adapter not yet implemented; falling back to Spark.");
this._fallback = new SparkAdapter(this.canvas);
this._fallback.init();
}
async loadSplat(u, f) { return this._fallback.loadSplat(u, f); }
getBounds() { return this._fallback.getBounds(); }
getCameraPosition() { return this._fallback.getCameraPosition(); }
setCameraPosition(x,y,z) { return this._fallback.setCameraPosition(x,y,z); }
getCameraTarget() { return this._fallback.getCameraTarget(); }
setCameraTarget(x,y,z) { return this._fallback.setCameraTarget(x,y,z); }
setFov(d) { return this._fallback.setFov(d); }
setScaleMultiplier(f) { return this._fallback.setScaleMultiplier(f); }
setOpacityMultiplier(f) { return this._fallback.setOpacityMultiplier(f); }
screenshot() { return this._fallback.screenshot(); }
async loadSplat(u, f) { return this._fallback.loadSplat(u, f); }
getBounds() { return this._fallback.getBounds(); }
getCameraPosition() { return this._fallback.getCameraPosition(); }
setCameraPosition(x,y,z) { return this._fallback.setCameraPosition(x,y,z); }
getCameraTarget() { return this._fallback.getCameraTarget(); }
setCameraTarget(x,y,z){ return this._fallback.setCameraTarget(x,y,z); }
setFov(d) { return this._fallback.setFov(d); }
setScaleMultiplier(f) { return this._fallback.setScaleMultiplier(f); }
setOpacityMultiplier(f){ return this._fallback.setOpacityMultiplier(f); }
screenshot() { return this._fallback.screenshot(); }
get scene() { return this._fallback?.scene; }
get camera() { return this._fallback?.camera; }
get renderer() { return this._fallback?.renderer; }
get splatMesh() { return this._fallback?.splatMesh; }
get _frameCount() { return this._fallback?._frameCount; }
}
async function bootRenderer(kind) {
if (kind === "playcanvas") return new PlayCanvasAdapter(canvas);
// Default + any unknown value -> Spark.
return new SparkAdapter(canvas);
}
// ============================================================
@@ -635,9 +559,6 @@
const maxExtent = Math.max(b.size.x, b.size.y, b.size.z);
const fovRad = fovDeg * Math.PI / 180;
// 1.15 leaves a small breathing border instead of jamming the
// object to the frame edges. Tighter than the 1.5 default which
// made the object look distant.
const radius = Math.max(0.1, (maxExtent / 2) / Math.tan(fovRad / 2) * 1.15);
adapter.setCameraPosition(b.center.x, b.center.y, b.center.z - radius);
@@ -679,13 +600,11 @@
emitCameraState();
}
// ---- slider updates -----------------------------------------
function updateFov(v) {
v = Math.max(5, Math.min(170, parseFloat(v) || 50));
currentFovDeg = v;
setFovInputs(v);
if (adapterReady) {
// re-frame to keep the object filling roughly the same area
const b = adapter.getBounds();
if (b) frameOnBounds(v); else { adapter.setFov(v); emitCameraState(); }
}
@@ -723,12 +642,9 @@
async function loadPLYFromData(arrayBuffer, filename, extrinsics, intrinsics, renderer) {
try {
const tEnsure0 = performance.now();
console.log(`[GaussianViewer] ensureRenderer('${renderer || 'webgl2'}') starting...`);
await ensureRenderer(renderer || "webgl2");
console.log(`[GaussianViewer] ensureRenderer done in ${(performance.now()-tEnsure0).toFixed(0)} ms`);
// Hand the byte buffer to the chosen adapter via a blob URL.
const blob = new Blob([arrayBuffer], { type: 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
try {
@@ -740,7 +656,6 @@
URL.revokeObjectURL(blobUrl);
}
// Derive FOV from intrinsics if provided, else use current widget value.
let fovDeg = currentFovDeg;
if (intrinsics && Array.isArray(intrinsics) && intrinsics.length >= 2) {
const fy = intrinsics[1][1];
@@ -748,19 +663,13 @@
const h = cy * 2;
fovDeg = 2 * Math.atan(h / (2 * fy)) * 180 / Math.PI;
}
// Clamp telephoto / fisheye ends so a stale widget value can't push
// the camera to infinity or invert it. The slider lets users widen
// to 5° or narrow to 170° after load if they really want.
fovDeg = Math.max(20, Math.min(120, fovDeg));
setFovInputs(fovDeg);
frameOnBounds(fovDeg);
// Apply current slider values (no-op in v1 for scale/opacity, but
// adapters can implement them later without changing this path).
adapter.setScaleMultiplier(currentScale);
adapter.setOpacityMultiplier(currentOpacity);
// Late-applied saved pose (overrides default framing).
if (pendingRestore) {
applyCameraState(pendingRestore);
pendingRestore = null;
@@ -817,22 +726,9 @@
}
});
// Persist camera pose to workflow whenever the user interacts.
canvas.addEventListener('pointerup', () => emitCameraState());
canvas.addEventListener('wheel', () => emitCameraState(), { passive: true });
// SPZ toggle — flip → tell parent → parent re-fetches with the new
// format. Persistence (workflow JSON) is the parent's job.
useSpzCheck.addEventListener('change', (e) => {
const useSPZ = !!e.target.checked;
console.log('[GaussianViewer] SPZ toggle ->', useSPZ);
try {
window.parent.postMessage(
{ type: 'SETTING_CHANGED', key: 'useSPZ', value: useSPZ }, '*',
);
} catch (_) {}
});
// Receive from parent
window.addEventListener('message', (event) => {
const msg = event.data || {};
@@ -844,48 +740,33 @@
} else if (msg.type === 'RESTORE_CAMERA_STATE' && msg.state) {
if (adapterReady) applyCameraState(msg.state);
else pendingRestore = msg.state;
} else if (msg.type === 'RESTORE_VIEWER_SETTINGS' && msg.settings) {
// Parent owns the source of truth for viewer settings; sync UI
// without firing the change handler (which would loop).
if (typeof msg.settings.useSPZ === 'boolean') {
useSpzCheck.checked = msg.settings.useSPZ;
}
}
});
console.log('[GaussianViewer] Ready — waiting for LOAD_MESH_DATA');
// -------- Debug hooks (headless Playwright drives these) -----
// Closure-scoped vars (adapter, viewer, splatMesh) aren't visible
// from page.evaluate(); expose a getter so the harness can probe.
window.__gs = {
state() {
const m = adapter && adapter.splatMesh;
return {
adapterReady,
adapterKind: adapter && adapter.kind && adapter.kind(),
splatCount: (m && m.getSplatCount && m.getSplatCount()) || 0,
sceneCount: (m && m.getSceneCount && m.getSceneCount()) || 0,
hasViewer: !!(adapter && adapter.viewer),
hasMaterial: !!(m && m.material),
materialType: m && m.material && m.material.type,
materialUniforms: m && m.material && m.material.uniforms ? Object.keys(m.material.uniforms) : null,
splatCount: adapter?.splatMesh?.numSplats ?? 0,
cameraPos: adapter ? adapter.getCameraPosition() : null,
cameraTarget: adapter ? adapter.getCameraTarget() : null,
fov: currentFovDeg,
frames: adapter && adapter._frameCount,
};
},
// Run a fn against the live adapter/viewer/splatMesh.
run(fnSrc) {
// eslint-disable-next-line no-new-func
const fn = new Function('adapter', 'viewer', 'splatMesh', 'THREE', 'GS', fnSrc);
const fn = new Function('adapter', 'splatMesh', 'THREE', 'scene', 'camera', 'renderer', fnSrc);
return fn(
adapter,
adapter && adapter.viewer,
adapter && adapter.splatMesh,
window.THREE,
window["Gaussian Splats 3D"],
THREE,
adapter && adapter.scene,
adapter && adapter.camera,
adapter && adapter.renderer,
);
},
};