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.

This commit is contained in:
Andreas Sundquist
2025-05-26 13:46:16 -07:00
committed by Diego Marcos Segura
parent b4257bcd64
commit 156bbf400e
19 changed files with 2605 additions and 508 deletions
+661 -12
View File
@@ -2,43 +2,692 @@
<head>
<meta charset="utf-8">
<title>Forge • Hello World</title>
<title>Forge • Viewer</title>
<style>
body {
margin: 0;
}
#canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
outline: none; /* Remove default focus outline */
}
#main-gui {
position: absolute;
top: 5px;
right: 5px;
}
#second-gui {
position: absolute;
top: 5px;
left: 5px;
}
#progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: rgba(0, 0, 0, 0.1);
z-index: 1000;
display: none;
}
#progress-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #45a049);
width: 0%;
transition: width 0.3s ease;
}
</style>
</head>
<body>
<div id="progress-bar">
<div id="progress-fill"></div>
</div>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"@forge-gfx/forge": "/dist/forge.module.js"
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@forge-gfx/forge": "/dist/forge.module.js",
"lil-gui": "../node_modules/lil-gui/dist/lil-gui.esm.js"
}
}
</script>
<canvas id="canvas" tabindex="0"></canvas>
<div id="main-gui"></div>
<div id="second-gui"></div>
<input id="file-input" type="file" accept=".ply,.spz,.splat,.ksplat,.wlg" multiple="true" style="display: none;" />
<script type="module">
import * as THREE from "three";
import { SplatMesh } from "@forge-gfx/forge";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "lil-gui";
import { constructGrid, ForgeControls, ForgeRenderer, SplatMesh, textSplats, dyno, transcodeSpz } from "@forge-gfx/forge";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement)
camera.position.set(0, 0, 1);
const splatURL = await getAssetFileURL("butterfly-wings-closed.spz");
const butterfly = new SplatMesh({ url: splatURL });
butterfly.quaternion.set(1, 0, 0, 0);
butterfly.position.set(0, -0.5, -3);
scene.add(butterfly);
const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas });
const forge = new ForgeRenderer({ renderer });
function handleResize() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
handleResize();
window.addEventListener("resize", handleResize);
const frame = new THREE.Group();
frame.quaternion.set(1, 0, 0, 0);
scene.add(frame);
// Keep track of original file bytes for each loaded splat mesh
const inputs = [];
const grid = new SplatMesh({
constructSplats: (splats) => constructGrid({
splats,
extents: new THREE.Box3(new THREE.Vector3(-10, -10, -10), new THREE.Vector3(10, 10, 10)),
}),
});
grid.opacity = 0;
scene.add(grid);
const gui = new GUI({
title: "Settings",
container: document.getElementById("main-gui")
}).close();
const secondGui = new GUI({
title: "Splats",
container: document.getElementById("second-gui")
}).close();
const controls = new ForgeControls({ canvas });
// Setup mouse controls to orbit the camera around
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enabled = false;
orbitControls.target.set(0, 0, 0);
orbitControls.minDistance = 0.1;
orbitControls.maxDistance = 10;
const fileInput = document.querySelector("#file-input");
fileInput.onchange = (event) => {
loadFiles([...event.target.files]);
};
const guiOptions = {
highDevicePixel: false,
resetOnLoad: true,
loadOffset: 0,
openCv: true,
autoRotate: true,
orbit: false,
reversePointerDir: false,
reversePointerSlide: false,
openFiles: () => {
fileInput.click();
},
loadFromText: "",
loadFromTextAction: () => {
if (guiOptions.loadFromText.trim()) {
const urls = parseURLsFromText(guiOptions.loadFromText);
if (urls.length > 0) {
loadFiles(urls);
guiOptions.loadFromText = ""; // Clear after loading
} else {
alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, or .ksplat");
}
}
},
resetPose: () => {
camera.position.set(0, 0, 1);
camera.quaternion.set(0, 0, 0, 1);
camera.fov = 75;
resetFrameQuaternion();
camera.updateProjectionMatrix();
},
};
function resetFrameQuaternion() {
if (guiOptions.openCv) {
frame.quaternion.set(1, 0, 0, 0);
} else {
frame.quaternion.set(0, 0, 0, 1);
}
}
// Parse URL query parameters for URLs to load
function parseURLsFromQuery() {
const urlParams = new URLSearchParams(window.location.search);
const urls = [];
// Get all "url" parameters
for (const [key, value] of urlParams.entries()) {
if (key === "url") {
urls.push(value);
}
}
return urls;
}
// Parse and apply camera parameters from URL
function applyCameraFromQuery() {
const urlParams = new URLSearchParams(window.location.search);
// Position parameters
const x = urlParams.get('x');
const y = urlParams.get('y');
const z = urlParams.get('z');
if (x !== null || y !== null || z !== null) {
camera.position.set(
x !== null ? parseFloat(x) : camera.position.x,
y !== null ? parseFloat(y) : camera.position.y,
z !== null ? parseFloat(z) : camera.position.z
);
}
// Quaternion parameters
const qx = urlParams.get('qx');
const qy = urlParams.get('qy');
const qz = urlParams.get('qz');
const qw = urlParams.get('qw');
if (qx !== null || qy !== null || qz !== null || qw !== null) {
camera.quaternion.set(
qx !== null ? parseFloat(qx) : camera.quaternion.x,
qy !== null ? parseFloat(qy) : camera.quaternion.y,
qz !== null ? parseFloat(qz) : camera.quaternion.z,
qw !== null ? parseFloat(qw) : camera.quaternion.w
).normalize();
}
}
// Apply camera parameters from URL
guiOptions.resetPose();
applyCameraFromQuery();
const cameraFolder = gui.addFolder("Camera");
cameraFolder.add(camera.position, "x", -10, 10, 0.01).name("X").listen();
cameraFolder.add(camera.position, "y", -10, 10, 0.01).name("Y").listen();
cameraFolder.add(camera.position, "z", -10, 10, 0.01).name("Z").listen();
const rotX = cameraFolder.add(camera.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
const rotY = cameraFolder.add(camera.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
const rotZ = cameraFolder.add(camera.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
cameraFolder.add(camera, "fov", 1, 179, 1).name("Fov Y degrees").listen().onChange((value) => {
camera.updateProjectionMatrix();
});
// Progress bar functions
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
function showProgress() {
progressBar.style.display = "block";
progressFill.style.width = "0%";
}
function updateProgress(progress) {
progressFill.style.width = `${Math.min(100, Math.max(0, progress * 100))}%`;
}
function hideProgress() {
progressBar.style.display = "none";
}
// Calculate progress when total size is unknown
function calculateUnknownProgress(loadedBytes) {
const midpointMB = 10 * 1024 * 1024; // 10 MB in bytes
return loadedBytes / (loadedBytes + midpointMB);
}
async function fetchWithProgress(url) {
try {
const response = await fetch(url, {
mode: "cors",
cache: "default",
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : null;
const reader = response.body.getReader();
const chunks = [];
let loadedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loadedBytes += value.length;
// Calculate progress
let progress;
if (total) {
progress = loadedBytes / total;
} else {
progress = calculateUnknownProgress(loadedBytes);
}
updateProgress(progress);
}
// Combine all chunks into a single Uint8Array
const allChunks = new Uint8Array(loadedBytes);
let offset = 0;
for (const chunk of chunks) {
allChunks.set(chunk, offset);
offset += chunk.length;
}
return allChunks.buffer;
} catch (error) {
// Hide progress bar on error
hideProgress();
// Detect CORS issues
if (error instanceof TypeError && (
error.message.includes('Failed to fetch') ||
error.message.includes('Network request failed') ||
error.message.includes('CORS') ||
error.message.includes('cross-origin')
)) {
alert(`CORS Error: Cannot load file from ${url}\n\nThe server doesn't allow cross-origin requests. The file needs to be:\n• Served from the same domain, or\n• The server must include CORS headers like:\n Access-Control-Allow-Origin: *`);
} else if (error.message.includes('HTTP error! status:')) {
const status = error.message.match(/status: (\d+)/)?.[1];
alert(`HTTP Error ${status}: Cannot load file from ${url}\n\n${error.message}`);
} else {
alert(`Error loading file from ${url}:\n\n${error.message}`);
}
throw error; // Re-throw so loadFiles can handle it
}
}
async function loadFiles(splatFiles) {
if (guiOptions.resetOnLoad) {
const toRemove = frame.children.filter((child) => child instanceof SplatMesh);
for (const child of toRemove) {
frame.remove(child);
}
// Clear original file bytes when resetting
inputs.length = 0;
splatsFolder.foldersRecursive().forEach((child) => child.destroy());
}
guiOptions.openCv = true;
guiOptions.autoRotate = false;
resetFrameQuaternion();
writeOptions.filename = "";
// Show progress bar if loading from URLs
const hasUrls = splatFiles.some(file => typeof file === "string");
if (hasUrls) {
showProgress();
}
let index = 0;
for (const splatFile of splatFiles) {
try {
let fileBytes;
let fileName;
// Check if splatFile is a URL string or a File object
if (typeof splatFile === "string") {
// It's a URL, fetch it
console.log(`Fetching ${splatFile}...`);
fileBytes = new Uint8Array(await fetchWithProgress(splatFile));
// Extract filename from URL
fileName = splatFile.split("/").pop().split("?")[0] || "downloaded-file";
} else {
// It's a File object
fileBytes = new Uint8Array(await splatFile.arrayBuffer());
fileName = splatFile.name;
}
if (writeOptions.filename === "") {
writeOptions.filename = fileName.split(".")[0];
}
const splatMesh = new SplatMesh({ fileBytes: fileBytes.slice(), fileName });
const translate = guiOptions.loadOffset * index
splatMesh.position.set(translate, 0.5 * translate, 0.1 * translate);
splatMesh.enableWorldToView = true;
splatMesh.worldModifier = makeWorldModifier(splatMesh);
await splatMesh.initialized;
inputs.push({ fileBytes, pathOrUrl: fileName, object: splatMesh });
frame.add(splatMesh);
console.log(`Loaded ${fileName} with ${splatMesh.numSplats} splats`);
const splatFolder = splatsFolder.addFolder(fileName).close();
splatFolder.add(splatMesh, "opacity", 0, 1, 0.01).name("Opacity").listen();
splatFolder.add(splatMesh.position, "x", -10, 10, 0.01).name("X").listen();
splatFolder.add(splatMesh.position, "y", -10, 10, 0.01).name("Y").listen();
splatFolder.add(splatMesh.position, "z", -10, 10, 0.01).name("Z").listen();
splatFolder.add(splatMesh.scale, "x", 0.01, 4, 0.01).name("Scale").listen().onChange((value) => {
splatMesh.scale.setScalar(value);
});
splatFolder.add(splatMesh.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
splatFolder.add(splatMesh.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
splatFolder.add(splatMesh.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
splatFolder.add(splatMesh, "maxSh", 0, 3, 1).name("Max SH").listen().onChange(() => {
splatMesh.updateGenerator();
});
} catch (error) {
console.error("Error loading splat file:", error);
}
index += 1;
}
// Hide progress bar when done
if (hasUrls) {
hideProgress();
}
// Restore focus to canvas for keyboard controls
canvas.focus();
}
secondGui.add(guiOptions, "resetOnLoad").name("Reset on load");
secondGui.add(guiOptions, "loadOffset", -2, 2, 0.01).name("Loading offset");
secondGui.add(guiOptions, "openFiles").name("Select Files");
secondGui.add(guiOptions, "loadFromText").name("Paste URL(s) here");
secondGui.add(guiOptions, "loadFromTextAction").name("Load from URL(s)");
cameraFolder.add(guiOptions, "resetPose").name("Reset pose");
cameraFolder.add(guiOptions, "openCv").name("OpenCV coordinates")
.listen()
.onChange(resetFrameQuaternion);
cameraFolder.add(guiOptions, "autoRotate").name("Auto rotate")
.listen()
.onChange((value) => {
if (value) {
frame.rotation.y = 0;
}
});
cameraFolder.add(guiOptions, "orbit").name("Orbit controls")
.listen()
.onChange((value) => {
orbitControls.enabled = value;
canvas.focus();
rotX.enable(!value);
rotY.enable(!value);
rotZ.enable(!value);
});
// .setValue(false);
cameraFolder.add(guiOptions, "reversePointerDir").name("Reverse ptr direction")
.onChange((value) => {
controls.pointerControls.reverseRotate = value;
controls.pointerControls.reverseScroll = value;
});
cameraFolder.add(guiOptions, "reversePointerSlide").name("Reverse ptr slide")
.onChange((value) => {
controls.pointerControls.reverseSlide = value;
controls.pointerControls.reverseSwipe = value;
});
gui.add(guiOptions, "highDevicePixel").name("High DPI").onChange((value) => {
renderer.setPixelRatio(value ? window.devicePixelRatio : 1);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
console.log("Render size", canvas.width, canvas.height);
});
gui.add(grid, "opacity", 0, 1, 0.01).name("Grid opacity").listen();
const normalColor = dyno.dynoBool(false);
gui.add(normalColor, "value").name("Normal color").onChange(() => updateFrameSplats());
const splatsFolder = secondGui.addFolder("Files");
const editFolder = gui.addFolder("Edit Splats").close();
editFolder.add(forge, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
editFolder.add(forge, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
function updateFrameSplats() {
frame.children.forEach((child) => {
if (child instanceof SplatMesh) {
child.updateVersion();
}
});
}
const clipEnable = dyno.dynoBool(false);
const clipMinX = dyno.dynoFloat(-5);
const clipMaxX = dyno.dynoFloat(5);
const clipMinY = dyno.dynoFloat(-5);
const clipMaxY = dyno.dynoFloat(5);
const clipMinZ = dyno.dynoFloat(-5);
const clipMaxZ = dyno.dynoFloat(5);
editFolder.add(clipEnable, "value").name("Enable clip").onChange(() => updateFrameSplats());
editFolder.add(clipMinX, "value", -5, 5, 0.01).name("Min X").onChange(() => updateFrameSplats());
editFolder.add(clipMaxX, "value", -5, 5, 0.01).name("Max X").onChange(() => updateFrameSplats());
editFolder.add(clipMinY, "value", -5, 5, 0.01).name("Min Y").onChange(() => updateFrameSplats());
editFolder.add(clipMaxY, "value", -5, 5, 0.01).name("Max Y").onChange(() => updateFrameSplats());
editFolder.add(clipMinZ, "value", -5, 5, 0.01).name("Min Z").onChange(() => updateFrameSplats());
editFolder.add(clipMaxZ, "value", -5, 5, 0.01).name("Max Z").onChange(() => updateFrameSplats());
function makeWorldModifier(mesh) {
const context = mesh.context;
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
// Color by world normal if it's enabled
let worldNormal = dyno.gsplatNormal(gsplat);
let { rgb, center, opacity } = dyno.splitGsplat(gsplat).outputs;
// Compute vector from view to object coordinate in gsplat
const worldToView = context.worldToView;
const viewGsplat = worldToView.applyGsplat(gsplat);
const viewCenter = dyno.splitGsplat(viewGsplat).outputs.center;
let normal = dyno.gsplatNormal(viewGsplat);
const dot = dyno.dot(viewCenter, normal);
const sameDir = dyno.greaterThanEqual(dot, dyno.dynoConst("float", 0));
normal = dyno.select(sameDir, dyno.neg(worldNormal), worldNormal);
const normalRgb = dyno.add(dyno.mul(normal, dyno.dynoConst("float", 0.5)), dyno.dynoConst("float", 0.5));
rgb = dyno.select(normalColor, normalRgb, rgb);
// Zero out opacity if outside clip bounds
const { x, y, z } = dyno.split(center).outputs;
const xWithin = dyno.and(dyno.greaterThanEqual(x, clipMinX), dyno.lessThanEqual(x, clipMaxX));
const yWithin = dyno.and(dyno.greaterThanEqual(y, clipMinY), dyno.lessThanEqual(y, clipMaxY));
const zWithin = dyno.and(dyno.greaterThanEqual(z, clipMinZ), dyno.lessThanEqual(z, clipMaxZ));
const within = dyno.and(dyno.and(xWithin, yWithin), zWithin);
const splatEnabled = dyno.or(
dyno.not(clipEnable),
within,
);
opacity = dyno.select(splatEnabled, opacity, dyno.dynoConst("float", 0));
gsplat = dyno.combineGsplat({
gsplat,
rgb,
opacity,
})
return { gsplat };
});
}
const writeFolder = secondGui.addFolder("Write Gsplats").close();
const writeOptions = {
filename: "gsplats",
trimOpacity: true,
trimOpacityThreshold: 0,
maxSh: 3,
fractionalBits: 12,
writeSpz: async () => {
const transcodeInputs = inputs.map((input) => {
return {
fileBytes: new Uint8Array(input.fileBytes),
pathOrUrl: input.pathOrUrl,
transform: {
translate: input.object.position.toArray(),
quaternion: input.object.quaternion.toArray(),
scale: (input.object.scale.x + input.object.scale.y + input.object.scale.z) / 3,
},
};
});
const clipXyz = clipEnable.value ? {
min: [clipMinX.value, clipMinY.value, clipMinZ.value],
max: [clipMaxX.value, clipMaxY.value, clipMaxZ.value],
} : undefined;
const maxSh = writeOptions.maxSh;
const fractionalBits = writeOptions.fractionalBits;
const opacityThreshid = writeOptions.trimOpacity ? writeOptions.trimOpacityThreshold : undefined;
const transcode = await transcodeSpz({ inputs: transcodeInputs, maxSh, clipXyz, fractionalBits, opacityThreshold: opacityThreshid });
if (transcode.clippedCount && transcode.clippedCount > 0) {
console.log(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
alert(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
}
const blob = new Blob([transcode.fileBytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = writeOptions.filename + ".spz";
a.click();
URL.revokeObjectURL(url);
},
};
writeFolder.add(writeOptions, "filename").name("Filename").listen();
writeFolder.add(writeOptions, "trimOpacity").name("Trim low opacity");
writeFolder.add(writeOptions, "trimOpacityThreshold").name("Trim opacity <= 0..1");
writeFolder.add(writeOptions, "maxSh", 0, 3, 1).name("Max spherical harmonics");
writeFolder.add(writeOptions, "fractionalBits", 6, 24, 1).name("Fractional bits");
writeFolder.add(writeOptions, "writeSpz").name("Create .spz and download");
function makeInstructions() {
const instructions = textSplats({
text: "Drag and Drop\na Gsplat file\nhere to view",
textAlign: "center",
fontSize: 64,
objectScale: 0.1 / 64,
});
instructions.quaternion.set(1, 0, 0, 0);
instructions.enableWorldToView = true;
instructions.worldModifier = makeWorldModifier(instructions);
instructions.updateGenerator();
return instructions;
}
const instructions = makeInstructions();
frame.add(instructions);
// Load URLs from query parameters if any
const urlsToLoad = parseURLsFromQuery();
if (urlsToLoad.length > 0) {
console.log(`Loading ${urlsToLoad.length} URLs from query parameters:`, urlsToLoad);
loadFiles(urlsToLoad);
}
// Focus the canvas initially for keyboard controls
canvas.focus();
// Drag and drop functionality
canvas.addEventListener("dragover", (e) => {
e.preventDefault();
canvas.style.opacity = "0.5";
});
canvas.addEventListener("dragleave", (e) => {
e.preventDefault();
canvas.style.opacity = "1";
});
canvas.addEventListener("drop", async (e) => {
e.preventDefault();
canvas.style.opacity = "1";
// Check for text data first
const textData = e.dataTransfer.getData('text/plain');
if (textData) {
const urls = parseURLsFromText(textData);
if (urls.length > 0) {
console.log(`Found ${urls.length} URLs in dropped text:`, urls);
loadFiles(urls);
return;
}
}
// Fallback to file handling
const files = Array.from(e.dataTransfer.files);
console.log("Dropped files:", files.length, files);
const splatFiles = files.filter((file) =>
file.name.toLowerCase().endsWith('.ply') ||
file.name.toLowerCase().endsWith('.wlg') ||
file.name.toLowerCase().endsWith('.spz') ||
file.name.toLowerCase().endsWith('.splat') ||
file.name.toLowerCase().endsWith('.ksplat')
);
if (splatFiles.length > 0) {
loadFiles(splatFiles);
}
});
// Parse URLs from text (handles single URLs, multiple lines, mixed content)
function parseURLsFromText(text) {
const supportedExtensions = [".ply", ".wlg", ".spz", ".splat", ".ksplat"];
const urls = [];
// Split by lines, commas, and semicolons
const parts = text.trim().split(/[\r\n,;]+/);
for (const part of parts) {
const trimmed = part.trim();
// Check if part looks like a URL
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
// Check if it has a supported extension
const hasValidExtension = supportedExtensions.some(ext =>
trimmed.toLowerCase().includes(ext)
);
if (hasValidExtension) {
urls.push(trimmed);
}
}
}
return urls;
}
let lastTime = null;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = time - (lastTime || time);
lastTime = time;
if (guiOptions.autoRotate) {
frame.rotation.y += deltaTime / 5000;
}
if (guiOptions.orbit) {
orbitControls.update();
} else {
controls.update(camera);
}
renderer.render(scene, camera);
butterfly.rotation.y += 0.01;
});
</script>
</body>