Skip to content

Commit 156bbf4

Browse files
asundquidmarcos
authored andcommitted
SPZ writing, exporting, & viewer. Creates a new npm run to-spz which runs scripts/to-spz.js to convert any Gsplat input file to compress .spz. Also includes a configurable viewer examples/viewer that can load splats via drag-n-drop, from url query param, the URL text list, can edit sets of SplatMesh, and export composite .SPZ with some filters. Refactored some loading code from worker.ts to loader files so they can be called from Nodejs. Created api transcodeSpz with options to combine multiple source Gsplat files into one without losing coding quality along the way.
1 parent b4257bc commit 156bbf4

19 files changed

Lines changed: 2601 additions & 504 deletions

examples/viewer/index.html

Lines changed: 661 additions & 12 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"build:wasm": "node rust/build_wasm.js",
1212
"build:watch": "onchange 'src/**/*.{ts,glsl}' -- npm run build",
1313
"clean": "rm -rf dist/ && rm -rf node_modules/ && rm -rf rust/target/ && rm -rf rust/forge-internal-rs/pkg/ && npm run assets:clean",
14+
"to-spz": "node scripts/to-spz.js",
1415
"dev": "npm run build && (vite --host & npm run build:watch)",
1516
"deploy": "node scripts/deploy.js",
1617
"docs": "mkdocs serve",

scripts/to-spz.js

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
#!/usr/bin/env node
2+
3+
import { execSync } from "node:child_process";
4+
import fs from "node:fs/promises";
5+
import path from "node:path";
6+
import { URL } from "node:url";
7+
8+
// Import directly from source to avoid worker system
9+
import { transcodeSpz } from "../dist/forge.module.js";
10+
11+
async function main() {
12+
const args = process.argv.slice(2);
13+
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
14+
console.log(`Usage: to-spz.js [options] <file_or_url>...
15+
16+
Convert Gaussian Splat files to optimized .spz format.
17+
18+
Supported input formats:
19+
.ply PLY Gaussian Splat files
20+
.wlg WorldLabs Gaussian format
21+
.spz SPZ format (for reprocessing/filtering)
22+
.splat AntiSplat format
23+
.ksplat KSplat format
24+
http(s) Direct URLs to splat files
25+
26+
Options:
27+
--filter-zero Filter out splats with zero opacity
28+
--filter-opacity N Filter out splats with opacity <= N (0.0-1.0)
29+
--min x,y,z Minimum AABB (e.g. --min 0,0,0)
30+
--max x,y,z Maximum AABB (e.g. --max 1,1,1)
31+
--min-x N Minimum X coordinate
32+
--max-x N Maximum X coordinate
33+
--min-y N Minimum Y coordinate
34+
--max-y N Maximum Y coordinate
35+
--min-z N Minimum Z coordinate
36+
--max-z N Maximum Z coordinate
37+
--x-range min,max X coordinate range (e.g. --x-range -1,1)
38+
--y-range min,max Y coordinate range (e.g. --y-range -1,1)
39+
--z-range min,max Z coordinate range (e.g. --z-range -1,1)
40+
--max-sh N Maximum SH degree to output (0-3, default: auto-detect from input)
41+
--fractional-bits N Fractional bits for coordinate precision (default: 12, range: 6-24)
42+
`);
43+
process.exit(0);
44+
}
45+
46+
let opacityThreshold = null; // null = no filtering
47+
let minAABB = [
48+
Number.NEGATIVE_INFINITY,
49+
Number.NEGATIVE_INFINITY,
50+
Number.NEGATIVE_INFINITY,
51+
];
52+
let maxAABB = [
53+
Number.POSITIVE_INFINITY,
54+
Number.POSITIVE_INFINITY,
55+
Number.POSITIVE_INFINITY,
56+
];
57+
let maxShDegree = null; // null = auto-detect
58+
let fractionalBits = 12; // default value
59+
const inputs = [];
60+
61+
for (let i = 0; i < args.length; i++) {
62+
const arg = args[i];
63+
if (arg === "--filter-zero") {
64+
opacityThreshold = 0;
65+
} else if (arg === "--filter-opacity") {
66+
const val = Number(args[++i]);
67+
if (Number.isNaN(val) || val < 0 || val > 1) {
68+
throw new Error("Invalid --filter-opacity value, expected 0.0-1.0");
69+
}
70+
opacityThreshold = val;
71+
} else if (arg === "--min") {
72+
const val = args[++i];
73+
minAABB = val.split(",").map(Number);
74+
if (minAABB.length !== 3 || minAABB.some(Number.isNaN)) {
75+
throw new Error("Invalid --min value, expected comma-separated x,y,z");
76+
}
77+
} else if (arg === "--max") {
78+
const val = args[++i];
79+
maxAABB = val.split(",").map(Number);
80+
if (maxAABB.length !== 3 || maxAABB.some(Number.isNaN)) {
81+
throw new Error("Invalid --max value, expected comma-separated x,y,z");
82+
}
83+
} else if (arg === "--max-sh") {
84+
const val = Number(args[++i]);
85+
if (Number.isNaN(val) || val < 0 || val > 3) {
86+
throw new Error("Invalid --max-sh value, expected 0-3");
87+
}
88+
maxShDegree = val;
89+
} else if (arg === "--fractional-bits") {
90+
const val = Number(args[++i]);
91+
if (Number.isNaN(val) || val < 6 || val > 24) {
92+
throw new Error("Invalid --fractional-bits value, expected 6-24");
93+
}
94+
fractionalBits = val;
95+
} else if (arg === "--min-x") {
96+
const val = Number(args[++i]);
97+
if (Number.isNaN(val)) {
98+
throw new Error("Invalid --min-x value, expected a number");
99+
}
100+
minAABB[0] = val;
101+
} else if (arg === "--max-x") {
102+
const val = Number(args[++i]);
103+
if (Number.isNaN(val)) {
104+
throw new Error("Invalid --max-x value, expected a number");
105+
}
106+
maxAABB[0] = val;
107+
} else if (arg === "--min-y") {
108+
const val = Number(args[++i]);
109+
if (Number.isNaN(val)) {
110+
throw new Error("Invalid --min-y value, expected a number");
111+
}
112+
minAABB[1] = val;
113+
} else if (arg === "--max-y") {
114+
const val = Number(args[++i]);
115+
if (Number.isNaN(val)) {
116+
throw new Error("Invalid --max-y value, expected a number");
117+
}
118+
maxAABB[1] = val;
119+
} else if (arg === "--min-z") {
120+
const val = Number(args[++i]);
121+
if (Number.isNaN(val)) {
122+
throw new Error("Invalid --min-z value, expected a number");
123+
}
124+
minAABB[2] = val;
125+
} else if (arg === "--max-z") {
126+
const val = Number(args[++i]);
127+
if (Number.isNaN(val)) {
128+
throw new Error("Invalid --max-z value, expected a number");
129+
}
130+
maxAABB[2] = val;
131+
} else if (arg === "--x-range") {
132+
const val = args[++i];
133+
const range = val.split(",").map(Number);
134+
if (range.length !== 2 || range.some(Number.isNaN)) {
135+
throw new Error("Invalid --x-range value, expected min,max");
136+
}
137+
minAABB[0] = range[0];
138+
maxAABB[0] = range[1];
139+
} else if (arg === "--y-range") {
140+
const val = args[++i];
141+
const range = val.split(",").map(Number);
142+
if (range.length !== 2 || range.some(Number.isNaN)) {
143+
throw new Error("Invalid --y-range value, expected min,max");
144+
}
145+
minAABB[1] = range[0];
146+
maxAABB[1] = range[1];
147+
} else if (arg === "--z-range") {
148+
const val = args[++i];
149+
const range = val.split(",").map(Number);
150+
if (range.length !== 2 || range.some(Number.isNaN)) {
151+
throw new Error("Invalid --z-range value, expected min,max");
152+
}
153+
minAABB[2] = range[0];
154+
maxAABB[2] = range[1];
155+
} else {
156+
inputs.push(arg);
157+
}
158+
}
159+
160+
if (inputs.length === 0) {
161+
console.error("No input files or URLs specified.");
162+
process.exit(1);
163+
}
164+
165+
// Collect all file inputs first
166+
const transcodeInputs = [];
167+
168+
for (const input of inputs) {
169+
console.log(`Loading ${input}...`);
170+
let fileBytes;
171+
let pathOrUrl;
172+
173+
if (input.startsWith("http://") || input.startsWith("https://")) {
174+
const res = await fetch(input);
175+
if (!res.ok) {
176+
console.error(
177+
`Failed to fetch ${input}: ${res.status} ${res.statusText}`,
178+
);
179+
continue;
180+
}
181+
const buffer = await res.arrayBuffer();
182+
fileBytes = new Uint8Array(buffer);
183+
pathOrUrl = input;
184+
} else {
185+
const data = await fs.readFile(input);
186+
fileBytes = new Uint8Array(data);
187+
pathOrUrl = input;
188+
}
189+
190+
transcodeInputs.push({
191+
fileBytes: fileBytes.slice(),
192+
pathOrUrl,
193+
transform: {
194+
translate: [0, 0, 0],
195+
quaternion: [0, 0, 0, 1],
196+
scale: 1,
197+
},
198+
});
199+
}
200+
201+
if (transcodeInputs.length === 0) {
202+
console.error("No valid inputs to process.");
203+
process.exit(1);
204+
}
205+
206+
// Setup clipping bounds if specified
207+
let clipXyz = undefined;
208+
if (
209+
minAABB[0] !== Number.NEGATIVE_INFINITY ||
210+
minAABB[1] !== Number.NEGATIVE_INFINITY ||
211+
minAABB[2] !== Number.NEGATIVE_INFINITY ||
212+
maxAABB[0] !== Number.POSITIVE_INFINITY ||
213+
maxAABB[1] !== Number.POSITIVE_INFINITY ||
214+
maxAABB[2] !== Number.POSITIVE_INFINITY
215+
) {
216+
clipXyz = {
217+
min: minAABB,
218+
max: maxAABB,
219+
};
220+
}
221+
222+
// Setup opacity threshold filtering
223+
if (opacityThreshold !== null) {
224+
console.log(
225+
`Applying opacity filtering: removing splats with opacity <= ${opacityThreshold}`,
226+
);
227+
}
228+
229+
console.log(
230+
`Processing ${transcodeInputs.length} input file(s) with transcodeSpz...`,
231+
);
232+
const transcode = await transcodeSpz({
233+
inputs: transcodeInputs,
234+
maxSh: maxShDegree,
235+
clipXyz,
236+
fractionalBits,
237+
opacityThreshold,
238+
});
239+
240+
// Write output file
241+
const firstInput = transcodeInputs[0];
242+
const baseName = path.basename(firstInput.pathOrUrl);
243+
const nameNoExt = baseName.replace(/\.[^.]+$/, "");
244+
const outDir =
245+
firstInput.pathOrUrl.startsWith("http://") ||
246+
firstInput.pathOrUrl.startsWith("https://")
247+
? process.cwd()
248+
: path.dirname(firstInput.pathOrUrl);
249+
250+
const outputName =
251+
transcodeInputs.length === 1
252+
? `${nameNoExt}.spz`
253+
: `combined_${transcodeInputs.length}_files.spz`;
254+
255+
const outPath = path.join(outDir, outputName);
256+
await fs.writeFile(outPath, transcode.fileBytes);
257+
console.log(`Wrote ${outPath} (${transcode.fileBytes.length} bytes)`);
258+
259+
if (transcode.clippedCount && transcode.clippedCount > 0) {
260+
console.log(`Clipped ${transcode.clippedCount} splats.`);
261+
console.log(
262+
`Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`,
263+
);
264+
}
265+
}
266+
267+
main().catch((err) => {
268+
console.error(err);
269+
process.exit(1);
270+
});

src/PackedSplats.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export type PackedSplatsOptions = {
2929
// auto-detected (.splat, .ksplat). (default: undefined auto-detects other
3030
// formats from file contents)
3131
fileType?: SplatFileType;
32+
// File name to use for type detection. (default: undefined)
33+
fileName?: string;
3234
// Reserve space for at least this many splats when constructing the collection
3335
// initially. The array will automatically resize past maxSplats so setting it is
3436
// an optional optimization. (default: 0)
@@ -135,7 +137,7 @@ export class PackedSplats {
135137
const unpacked = await unpackSplats({
136138
input: fileBytes,
137139
fileType: options.fileType,
138-
pathOrUrl: url,
140+
pathOrUrl: options.fileName ?? url,
139141
});
140142
this.initialize(unpacked);
141143
}

src/SplatGenerator.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
DynoVec4,
99
Gsplat,
1010
dynoBlock,
11+
transformDir,
1112
transformGsplat,
13+
transformPos,
1214
} from "./dyno";
1315

1416
// A GsplatGenerator is a dyno program that maps an index to a Gsplat's properties
@@ -81,8 +83,23 @@ export class SplatTransformer {
8183
});
8284
}
8385

86+
// Apply the transform to a Vec3 position in a dyno program.
87+
apply(position: DynoVal<"vec3">): DynoVal<"vec3"> {
88+
return transformPos(position, {
89+
scale: this.scale,
90+
rotate: this.rotate,
91+
translate: this.translate,
92+
});
93+
}
94+
95+
applyDir(dir: DynoVal<"vec3">): DynoVal<"vec3"> {
96+
return transformDir(dir, {
97+
rotate: this.rotate,
98+
});
99+
}
100+
84101
// Apply the transform to a Gsplat in a dyno program.
85-
modify(gsplat: DynoVal<typeof Gsplat>): DynoVal<typeof Gsplat> {
102+
applyGsplat(gsplat: DynoVal<typeof Gsplat>): DynoVal<typeof Gsplat> {
86103
return transformGsplat(gsplat, {
87104
scale: this.scale,
88105
rotate: this.rotate,

0 commit comments

Comments
 (0)