ZipSplatPlus / static /viewer /viewer.html
Yosun's picture
Add stroke-by-stroke Gaussian Splatting playback feature
b472c86
Raw
History Blame Contribute Delete
21 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>3DGS Viewer & Stroke Playback - ZipSplatPlus</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #080b12;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
/* HUD Top Overlay */
#hud-top {
position: fixed;
top: 12px;
left: 12px;
right: 12px;
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none;
z-index: 10;
}
.hud-badge {
color: #94a3b8;
font-size: 12px;
background: rgba(15, 23, 42, 0.75);
padding: 6px 14px;
border-radius: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
#hud-stats {
color: #38bdf8;
font-weight: 600;
}
/* Stroke Control Bar */
#control-panel {
position: fixed;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
width: min(94%, 680px);
background: rgba(15, 23, 42, 0.85);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
padding: 10px 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 20;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.control-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
/* Buttons & Controls */
.ctrl-btn {
background: rgba(30, 41, 59, 0.8);
color: #e2e8f0;
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 6px 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.15s ease;
outline: none;
}
.ctrl-btn:hover {
background: rgba(51, 65, 85, 0.9);
border-color: rgba(96, 165, 250, 0.5);
color: #fff;
}
.ctrl-btn.active {
background: #2563eb;
border-color: #60a5fa;
color: #fff;
box-shadow: 0 0 10px rgba(37, 99, 235, 0.5);
}
.ctrl-btn-primary {
background: #3b82f6;
color: #fff;
font-weight: 600;
padding: 6px 16px;
border-radius: 8px;
}
.ctrl-btn-primary:hover {
background: #2563eb;
}
/* Slider Styling */
.slider-container {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: #334155;
border-radius: 3px;
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #38bdf8;
cursor: pointer;
border: 2px solid #0f172a;
box-shadow: 0 0 8px rgba(56, 189, 248, 0.8);
transition: transform 0.1s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.25);
}
.mode-pills {
display: flex;
background: rgba(30, 41, 59, 0.6);
border-radius: 8px;
padding: 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.pill-btn {
background: transparent;
color: #94a3b8;
border: none;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.pill-btn.active {
background: #3b82f6;
color: #ffffff;
}
#loading {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 14px;
gap: 12px;
background: #080b12;
z-index: 100;
}
#spinner {
width: 36px;
height: 36px;
border: 3px solid #334155;
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
#error {
display: none;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
color: #f87171;
font-size: 13px;
padding: 32px;
text-align: center;
white-space: pre-wrap;
background: rgba(15, 23, 42, 0.95);
z-index: 100;
}
</style>
</head>
<body>
<div id="loading">
<div id="spinner"></div>
<div id="loading-label">Loading 3D Gaussian Splat…</div>
</div>
<div id="error"></div>
<div id="hud-top">
<div class="hud-badge">
drag to orbit &nbsp;·&nbsp; scroll to zoom &nbsp;·&nbsp; right-drag to pan
</div>
<div id="hud-stats" class="hud-badge">
Stroke 0 / 0 &nbsp;·&nbsp; 0 / 0 Gaussians
</div>
</div>
<div id="control-panel">
<div class="control-row">
<button id="btn-play-pause" class="ctrl-btn ctrl-btn-primary">▶ Play</button>
<button id="btn-prev" class="ctrl-btn" title="Previous Stroke (Left Arrow)"></button>
<button id="btn-next" class="ctrl-btn" title="Next Stroke (Right Arrow)"></button>
<div class="slider-container">
<input type="range" id="stroke-slider" min="1" max="100" value="100" />
</div>
<button id="btn-loop" class="ctrl-btn active" title="Loop Playback">🔁</button>
</div>
<div class="control-row">
<div class="mode-pills">
<button id="mode-cumulative" class="pill-btn active" title="Build up splat stroke by stroke">Cumulative</button>
<button id="mode-single" class="pill-btn" title="Isolate active stroke only">Single Stroke</button>
<button id="mode-colored" class="pill-btn" title="Color code strokes">Colorized</button>
</div>
<div style="display: flex; gap: 4px; align-items: center;">
<span style="color: #64748b; font-size: 11px; font-weight: 600;">SPEED:</span>
<button class="pill-btn speed-btn" data-speed="0.5">0.5x</button>
<button class="pill-btn speed-btn active" data-speed="1.0">1x</button>
<button class="pill-btn speed-btn" data-speed="2.0">2x</button>
<button class="pill-btn speed-btn" data-speed="5.0">5x</button>
</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.180.0/three.module.js",
"three/addons/": "https://unpkg.com/three@0.180.0/examples/jsm/",
"@sparkjsdev/spark": "https://unpkg.com/@sparkjsdev/spark@2.1.0/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
const urlParams = new URLSearchParams(window.location.search);
const config = window.PLY_CONFIG || {};
const plyUrl = config.plyUrl || urlParams.get("ply");
const hasPlyData = config.plyB64 || plyUrl;
const loadingEl = document.getElementById("loading");
const loadLabel = document.getElementById("loading-label");
const errorEl = document.getElementById("error");
const hudStats = document.getElementById("hud-stats");
const btnPlayPause = document.getElementById("btn-play-pause");
const btnPrev = document.getElementById("btn-prev");
const btnNext = document.getElementById("btn-next");
const btnLoop = document.getElementById("btn-loop");
const strokeSlider = document.getElementById("stroke-slider");
const modeCumulative = document.getElementById("mode-cumulative");
const modeSingle = document.getElementById("mode-single");
const modeColored = document.getElementById("mode-colored");
const speedBtns = document.querySelectorAll(".speed-btn");
function showError(msg) {
loadingEl.style.display = "none";
errorEl.style.display = "flex";
errorEl.textContent = msg;
}
if (!hasPlyData) {
showError("No PLY data provided.");
} else {
initViewer(plyUrl);
}
function hslToRgb(h, s, l) {
s /= 100;
l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return { r: f(0), g: f(8), b: f(4) };
}
function initViewer(url) {
try {
loadLabel.textContent = "Downloading and rendering 3D Gaussian Splat…";
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: false,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x080b12, 1);
document.body.appendChild(renderer.domElement);
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const cx = parseFloat(config.cx !== undefined ? config.cx : (urlParams.get("cx") || "0"));
const cy = parseFloat(config.cy !== undefined ? config.cy : (urlParams.get("cy") || "0"));
const cz = parseFloat(config.cz !== undefined ? config.cz : (urlParams.get("cz") || "0"));
const r = parseFloat(config.r !== undefined ? config.r : (urlParams.get("r") || "2"));
let splatOptions = {};
if (config.plyB64) {
const binaryString = atob(config.plyB64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
splatOptions = { fileBytes: bytes };
} else {
splatOptions = { url: url };
}
let splat = null;
let totalGaussians = 0;
let totalStrokes = 0;
const gaussiansPerStroke = 32;
let currentStroke = 1;
let isPlaying = false;
let isLooping = true;
let playSpeed = 1.0;
let displayMode = "cumulative"; // "cumulative", "single", "colored"
let origOpacities = null;
let origColorsR = null;
let origColorsG = null;
let origColorsB = null;
let strokeColors = [];
let needsReset = false;
splat = new SplatMesh({
...splatOptions,
onLoad: () => {
loadingEl.style.display = "none";
setupStrokePlayback();
},
});
if (splat.initialized && splat.initialized.catch) {
splat.initialized.catch((err) => {
showError("Failed to load 3D Gaussian Splat:\n" + (err.message || err));
});
}
// Re-orient for Three.js (+Y up)
splat.rotation.y = Math.PI / 2;
splat.rotation.x = Math.PI;
scene.add(splat);
splat.updateMatrixWorld(true);
const center = new THREE.Vector3(cx, cy, cz);
center.applyMatrix4(splat.matrixWorld);
const camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
Math.min(0.01, r * 0.05),
Math.max(1000, r * 10),
);
const dist = Math.max(r * 2.0, 0.5);
camera.position.copy(center).add(new THREE.Vector3(0, r * 0.2, dist));
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.minDistance = 0.01;
controls.maxDistance = Math.max(1000, r * 20);
controls.target.copy(center);
controls.update();
function setupStrokePlayback() {
const splatData = splat.splats || splat.packedSplats || splat.extSplats;
if (!splatData) return;
totalGaussians = splatData.getNumSplats();
if (totalGaussians === 0) return;
totalStrokes = Math.ceil(totalGaussians / gaussiansPerStroke);
currentStroke = totalStrokes;
strokeSlider.min = "1";
strokeSlider.max = totalStrokes.toString();
strokeSlider.value = totalStrokes.toString();
// Cache original opacity and RGB values
origOpacities = new Float32Array(totalGaussians);
origColorsR = new Float32Array(totalGaussians);
origColorsG = new Float32Array(totalGaussians);
origColorsB = new Float32Array(totalGaussians);
let idx = 0;
splat.forEachSplat((i, c, s, q, opacity, color) => {
if (idx < totalGaussians) {
origOpacities[idx] = opacity;
origColorsR[idx] = color.r;
origColorsG[idx] = color.g;
origColorsB[idx] = color.b;
idx++;
}
});
// Pre-calculate vibrant HSL colors per stroke for Colorized mode
strokeColors = [];
for (let s = 0; s < totalStrokes; s++) {
const hue = (s * 137.5) % 360; // Golden angle distribution
strokeColors.push(hslToRgb(hue, 85, 60));
}
applyStrokeState();
}
function applyStrokeState() {
const splatData = splat.splats || splat.packedSplats || splat.extSplats;
if (!splatData || totalGaussians === 0) return;
const startIdx = (currentStroke - 1) * gaussiansPerStroke;
const endIdx = Math.min(currentStroke * gaussiansPerStroke, totalGaussians);
if (displayMode === "cumulative") {
if (needsReset) {
for (let i = 0; i < totalGaussians; i++) {
splatData.setOpacity(i, origOpacities[i]);
splatData.setColor(i, origColorsR[i], origColorsG[i], origColorsB[i]);
}
needsReset = false;
}
splatData.numSplats = endIdx;
} else if (displayMode === "single") {
needsReset = true;
splatData.numSplats = totalGaussians;
for (let i = 0; i < totalGaussians; i++) {
if (i >= startIdx && i < endIdx) {
splatData.setOpacity(i, origOpacities[i]);
splatData.setColor(i, origColorsR[i], origColorsG[i], origColorsB[i]);
} else {
splatData.setOpacity(i, 0.0);
}
}
} else if (displayMode === "colored") {
needsReset = true;
splatData.numSplats = endIdx;
for (let i = 0; i < endIdx; i++) {
const strokeIdx = Math.floor(i / gaussiansPerStroke);
const rgb = strokeColors[strokeIdx % strokeColors.length];
splatData.setOpacity(i, origOpacities[i]);
splatData.setColor(i, rgb.r, rgb.g, rgb.b);
}
}
// Update HUD Stats
const percent = ((endIdx / totalGaussians) * 100).toFixed(1);
hudStats.textContent = `Stroke ${currentStroke} / ${totalStrokes} · ${endIdx.toLocaleString()} / ${totalGaussians.toLocaleString()} Gaussians (${percent}%)`;
strokeSlider.value = currentStroke.toString();
}
// UI Event Listeners
strokeSlider.addEventListener("input", (e) => {
currentStroke = parseInt(e.target.value, 10);
applyStrokeState();
});
btnPlayPause.addEventListener("click", () => {
isPlaying = !isPlaying;
btnPlayPause.textContent = isPlaying ? "⏸ Pause" : "▶ Play";
btnPlayPause.classList.toggle("ctrl-btn-primary", !isPlaying);
});
btnPrev.addEventListener("click", () => {
currentStroke = Math.max(1, currentStroke - 1);
applyStrokeState();
});
btnNext.addEventListener("click", () => {
currentStroke = Math.min(totalStrokes, currentStroke + 1);
applyStrokeState();
});
btnLoop.addEventListener("click", () => {
isLooping = !isLooping;
btnLoop.classList.toggle("active", isLooping);
});
modeCumulative.addEventListener("click", () => {
displayMode = "cumulative";
modeCumulative.classList.add("active");
modeSingle.classList.remove("active");
modeColored.classList.remove("active");
applyStrokeState();
});
modeSingle.addEventListener("click", () => {
displayMode = "single";
modeSingle.classList.add("active");
modeCumulative.classList.remove("active");
modeColored.classList.remove("active");
applyStrokeState();
});
modeColored.addEventListener("click", () => {
displayMode = "colored";
modeColored.classList.add("active");
modeCumulative.classList.remove("active");
modeSingle.classList.remove("active");
applyStrokeState();
});
speedBtns.forEach((btn) => {
btn.addEventListener("click", () => {
speedBtns.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
playSpeed = parseFloat(btn.dataset.speed);
});
});
// Keyboard Shortcuts
window.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
if (e.code === "Space") {
e.preventDefault();
btnPlayPause.click();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
btnPrev.click();
} else if (e.code === "ArrowRight") {
e.preventDefault();
btnNext.click();
} else if (e.code === "Home") {
currentStroke = 1;
applyStrokeState();
} else if (e.code === "End") {
currentStroke = totalStrokes;
applyStrokeState();
}
});
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
let lastTime = performance.now();
let accumulatedStrokes = 0;
renderer.setAnimationLoop((time) => {
controls.update();
const dt = (time - lastTime) / 1000;
lastTime = time;
if (isPlaying && totalStrokes > 0) {
const strokesPerSec = 20 * playSpeed;
accumulatedStrokes += strokesPerSec * dt;
if (accumulatedStrokes >= 1) {
const step = Math.floor(accumulatedStrokes);
accumulatedStrokes -= step;
currentStroke += step;
if (currentStroke > totalStrokes) {
if (isLooping) {
currentStroke = 1;
} else {
currentStroke = totalStrokes;
isPlaying = false;
btnPlayPause.textContent = "▶ Play";
}
}
applyStrokeState();
}
}
renderer.render(scene, camera);
});
} catch (err) {
showError(
"Failed to render 3D Gaussian Splat:\n" + (err.message || err),
);
}
}
</script>
</body>
</html>