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