mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
544 lines
19 KiB
HTML
544 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Spark • Splat Painter</title>
|
|
<style>
|
|
html, body {
|
|
margin: 0;
|
|
height: 100%;
|
|
width: 100%;
|
|
background-color: black;
|
|
}
|
|
#canvas {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
outline: none; /* Remove default focus outline */
|
|
touch-action: none;
|
|
}
|
|
#mode-overlay {
|
|
position: absolute;
|
|
top: 20px;
|
|
left: 20px;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
padding: 12px 20px;
|
|
border-radius: 8px;
|
|
font-family: sans-serif;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
z-index: 1000;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
pointer-events: none;
|
|
}
|
|
#mode-overlay.show {
|
|
opacity: 1;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="canvas" tabindex="0"></canvas>
|
|
<div id="mode-overlay"></div>
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "/examples/js/vendor/three/build/three.module.js",
|
|
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
|
|
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
|
|
"@sparkjsdev/spark": "/dist/spark.module.js"
|
|
}
|
|
}
|
|
</script>
|
|
<script type="module">
|
|
import {
|
|
dyno,
|
|
SparkRenderer,
|
|
SplatMesh,
|
|
SplatTransformer,
|
|
SparkControls,
|
|
SpzWriter,
|
|
unpackSplat,
|
|
PackedSplats,
|
|
} from "@sparkjsdev/spark";
|
|
import * as THREE from "three";
|
|
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
|
import { GUI } from "lil-gui";
|
|
|
|
|
|
const PARAMETERS = {
|
|
controlsEnabled: true,
|
|
eraseEnabled: dyno.dynoBool(false),
|
|
brushEnabled: dyno.dynoBool(false),
|
|
brushDepth: dyno.dynoFloat(10.0),
|
|
brushRadius: dyno.dynoFloat(0.05),
|
|
brushOrigin: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)),
|
|
brushDirection: dyno.dynoVec3(new THREE.Vector3(0.0, 0.0, 0.0)),
|
|
brushColorHex: "#ff00ff",
|
|
brushColor: dyno.dynoVec3(new THREE.Vector3(1.0, 0.0, 1.0)),
|
|
};
|
|
|
|
const MIN_BRUSH_RADIUS = 0.01;
|
|
const MAX_BRUSH_RADIUS = 0.25;
|
|
const MIN_BRUSH_DEPTH = 0.1;
|
|
const MAX_BRUSH_DEPTH = 100.0;
|
|
|
|
const assetID = "greyscale-bedroom.spz";
|
|
let currentSplatMesh = null;
|
|
let currentFileName = "painted-splat";
|
|
|
|
function brushDyno(
|
|
brushEnabled,
|
|
eraseEnabled,
|
|
brushRadius,
|
|
brushDepth,
|
|
brushOrigin,
|
|
brushDirection,
|
|
brushColor,
|
|
) {
|
|
const flatColor = dyno.dynoVec3(new THREE.Vector3(1.0, 1.0, 1.0));
|
|
const luminanceThreshold = dyno.dynoFloat(0.1);
|
|
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
|
|
if (!gsplat) {
|
|
throw new Error("No gsplat input");
|
|
}
|
|
let { center, rgb, opacity } = dyno.splitGsplat(gsplat).outputs;
|
|
const projectionAmplitude = dyno.dot(brushDirection, dyno.sub(center, brushOrigin));
|
|
const projectedCenter = dyno.add(brushOrigin, dyno.mul(brushDirection, projectionAmplitude));
|
|
const distance = dyno.length(dyno.sub(projectedCenter, center)); // distance from projected center to actual center
|
|
const isInside = dyno.and(dyno.lessThan(distance, brushRadius),
|
|
dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)),
|
|
dyno.lessThan(projectionAmplitude, brushDepth)));
|
|
const luminanceOld = dyno.div(dyno.dot(rgb, flatColor), dyno.dynoFloat(3.0));
|
|
const luminanceNew = dyno.div(dyno.dot(brushColor, flatColor), dyno.dynoFloat(3.0));
|
|
const weightedRgb = dyno.mul(brushColor, dyno.div(luminanceOld, luminanceNew));
|
|
const isLuminanceAboveThreshold = dyno.greaterThan(luminanceOld, luminanceThreshold);
|
|
const newRgb = dyno.select(dyno.and(dyno.and(brushEnabled, isInside), isLuminanceAboveThreshold), weightedRgb, rgb);
|
|
const newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity);
|
|
gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity });
|
|
return { gsplat };
|
|
});
|
|
}
|
|
|
|
function paintableSplatMesh(
|
|
url,
|
|
brushEnabled,
|
|
eraseEnabled,
|
|
brushRadius,
|
|
brushDepth,
|
|
brushOrigin,
|
|
brushDirection,
|
|
brushColor,
|
|
) {
|
|
const splatMesh = new SplatMesh({
|
|
url: url,
|
|
onFrame: ({ mesh, time }) => {
|
|
mesh.needsUpdate = true;
|
|
}
|
|
});
|
|
splatMesh.worldModifier = brushDyno(
|
|
brushEnabled,
|
|
eraseEnabled,
|
|
brushRadius,
|
|
brushDepth,
|
|
brushOrigin,
|
|
brushDirection,
|
|
brushColor,
|
|
);
|
|
splatMesh.updateGenerator();
|
|
return splatMesh;
|
|
}
|
|
|
|
const canvas = document.getElementById("canvas");
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
|
|
renderer.setClearColor(new THREE.Color(0xaaffff), 1);
|
|
|
|
const scene = new THREE.Scene();
|
|
const spark = new SparkRenderer({
|
|
renderer,
|
|
});
|
|
scene.add(spark);
|
|
const controls = new SparkControls({
|
|
canvas: renderer.domElement,
|
|
});
|
|
|
|
const camera = new THREE.PerspectiveCamera(
|
|
50,
|
|
window.innerWidth / window.innerHeight,
|
|
0.01,
|
|
1000,
|
|
);
|
|
camera.position.set(0, 0, 0);
|
|
camera.lookAt(0, 0, -1);
|
|
scene.add(camera);
|
|
|
|
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);
|
|
|
|
async function loadSplatFromFile(url) {
|
|
if (currentSplatMesh) {
|
|
scene.remove(currentSplatMesh);
|
|
}
|
|
// Extract filename for export
|
|
currentFileName = url.split("/").pop().split("?")[0].split(".")[0] || "painted-splat";
|
|
currentSplatMesh = await paintableSplatMesh(
|
|
url,
|
|
PARAMETERS.brushEnabled,
|
|
PARAMETERS.eraseEnabled,
|
|
PARAMETERS.brushRadius,
|
|
PARAMETERS.brushDepth,
|
|
PARAMETERS.brushOrigin,
|
|
PARAMETERS.brushDirection,
|
|
PARAMETERS.brushColor
|
|
);
|
|
currentSplatMesh.quaternion.set(1, 0, 0, 0);
|
|
scene.add(currentSplatMesh);
|
|
|
|
}
|
|
|
|
await loadSplatFromFile(await getAssetFileURL(assetID));
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
let isDragging = false;
|
|
|
|
// Mode overlay functionality
|
|
const modeOverlay = document.getElementById('mode-overlay');
|
|
let overlayTimeout;
|
|
|
|
function showModeOverlay(text) {
|
|
modeOverlay.textContent = text;
|
|
modeOverlay.classList.add('show');
|
|
|
|
// Clear any existing timeout
|
|
if (overlayTimeout) {
|
|
clearTimeout(overlayTimeout);
|
|
}
|
|
|
|
// Hide overlay after 2 seconds
|
|
overlayTimeout = setTimeout(() => {
|
|
modeOverlay.classList.remove('show');
|
|
}, 2000);
|
|
}
|
|
|
|
renderer.domElement.addEventListener('pointermove', (event) => {
|
|
const clickCoords = new THREE.Vector2(
|
|
(event.clientX / renderer.domElement.width) * 2 - 1,
|
|
-(event.clientY / renderer.domElement.height) * 2 + 1,
|
|
);
|
|
raycaster.setFromCamera(clickCoords, camera);
|
|
const direction = raycaster.ray.direction.normalize();
|
|
PARAMETERS.brushDirection.value.x = direction.x;
|
|
PARAMETERS.brushDirection.value.y = direction.y;
|
|
PARAMETERS.brushDirection.value.z = direction.z;
|
|
PARAMETERS.brushOrigin.value.x = raycaster.ray.origin.x;
|
|
PARAMETERS.brushOrigin.value.y = raycaster.ray.origin.y;
|
|
PARAMETERS.brushOrigin.value.z = raycaster.ray.origin.z;
|
|
|
|
// Apply painting effect while dragging
|
|
if (isDragging && currentSplatMesh) {
|
|
const noSplatRgba = !currentSplatMesh.splatRgba;
|
|
currentSplatMesh.splatRgba = spark.getRgba(
|
|
{ generator: currentSplatMesh , rgba: currentSplatMesh.splatRgba}
|
|
);
|
|
if (noSplatRgba) {
|
|
currentSplatMesh.updateGenerator();
|
|
} else {
|
|
currentSplatMesh.updateVersion();
|
|
}
|
|
}
|
|
});
|
|
|
|
renderer.domElement.addEventListener('pointerdown', (event) => {
|
|
isDragging = true;
|
|
if (currentSplatMesh) {
|
|
const noSplatRgba = !currentSplatMesh.splatRgba;
|
|
currentSplatMesh.splatRgba = spark.getRgba(
|
|
{ generator: currentSplatMesh , rgba: currentSplatMesh.splatRgba}
|
|
);
|
|
if (noSplatRgba) {
|
|
currentSplatMesh.updateGenerator();
|
|
} else {
|
|
currentSplatMesh.updateVersion();
|
|
}
|
|
}
|
|
});
|
|
|
|
renderer.domElement.addEventListener('pointerup', (event) => {
|
|
isDragging = false;
|
|
});
|
|
|
|
renderer.domElement.addEventListener('pointerleave', (event) => {
|
|
isDragging = false;
|
|
});
|
|
|
|
const gui = new GUI();
|
|
|
|
// Instructions section
|
|
const instructions = {
|
|
brush: "Brush Mode",
|
|
erase: "Erase Mode",
|
|
none: "View Mode",
|
|
increase: "Increase Brush Size",
|
|
decrease: "Decrease Brush Size",
|
|
increaseDepth: "Increase Brush Depth",
|
|
decreaseDepth: "Decrease Brush Depth"
|
|
};
|
|
const instructionsFolder = gui.addFolder("Instructions");
|
|
instructionsFolder.add(instructions, "brush").name("1:").disable();
|
|
instructionsFolder.add(instructions, "erase").name("2:").disable();
|
|
instructionsFolder.add(instructions, "none").name("Esc:").disable();
|
|
instructionsFolder.add(instructions, "increase").name("=:").disable();
|
|
instructionsFolder.add(instructions, "decrease").name("-:").disable();
|
|
instructionsFolder.add(instructions, "increaseDepth").name("]:").disable();
|
|
instructionsFolder.add(instructions, "decreaseDepth").name("[:").disable();
|
|
instructionsFolder.open();
|
|
const brushRadiusController = gui.add(PARAMETERS.brushRadius, "value", MIN_BRUSH_RADIUS, MAX_BRUSH_RADIUS, 0.01).name("Brush Radius");
|
|
const brushDepthController = gui.add(PARAMETERS.brushDepth, "value", MIN_BRUSH_DEPTH, MAX_BRUSH_DEPTH, 0.1).name("Brush Depth");
|
|
gui.addColor(PARAMETERS, "brushColorHex").name("Brush Color").onChange((value) => {
|
|
PARAMETERS.brushColor.value = new THREE.Color(value).convertLinearToSRGB();
|
|
console.log(PARAMETERS.brushColor.value);
|
|
});
|
|
|
|
// I/O functionality (load and export)
|
|
const ioOptions = {
|
|
filename: currentFileName,
|
|
maxSh: 0, // Painted splats don't preserve SH data
|
|
fractionalBits: 12,
|
|
loadFile: () => {
|
|
// Create file input element
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.spz,.ply';
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
// Create a blob URL from the file
|
|
const url = URL.createObjectURL(file);
|
|
|
|
try {
|
|
await loadSplatFromFile(url);
|
|
console.log("Loaded file:", file.name);
|
|
// Update filename for export
|
|
ioOptions.filename = file.name.split(".")[0] || "painted-splat";
|
|
} catch (error) {
|
|
console.error("Error loading file:", error);
|
|
alert("Failed to load file. Make sure it's a valid SPZ or PLY file.");
|
|
} finally {
|
|
// Clean up the object URL
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
};
|
|
input.click();
|
|
},
|
|
saveToSpz: async () => {
|
|
if (!currentSplatMesh) {
|
|
console.error("Export failed - currentSplatMesh:", !!currentSplatMesh);
|
|
alert("No splat mesh loaded for export.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log("Starting SPZ export with painted changes...");
|
|
|
|
if (!currentSplatMesh.splatRgba) {
|
|
currentSplatMesh.splatRgba = spark.getRgba({
|
|
generator: currentSplatMesh,
|
|
rgba: currentSplatMesh.splatRgba
|
|
});
|
|
currentSplatMesh.updateGenerator();
|
|
}
|
|
|
|
const rgbaBytes = await spark.readRgba({
|
|
generator: currentSplatMesh,
|
|
rgba: currentSplatMesh.splatRgba
|
|
});
|
|
|
|
const ogSplats = currentSplatMesh.packedSplats;
|
|
const totalSplats = ogSplats.numSplats;
|
|
console.log("Total splats:", totalSplats);
|
|
|
|
let nonZeroCount = 0;
|
|
for (let i = 0; i < totalSplats; i++) {
|
|
const opacity = rgbaBytes[i * 4 + 3] / 255;
|
|
if (opacity > 0) {
|
|
nonZeroCount++;
|
|
}
|
|
}
|
|
|
|
// Create new PackedSplats with baked changes
|
|
const newPackedSplats = new PackedSplats({
|
|
maxSplats: nonZeroCount,
|
|
splatEncoding: ogSplats.splatEncoding,
|
|
});
|
|
|
|
// Build splat array from baked RGBA
|
|
let processedCount = 0;
|
|
for (let i = 0; i < totalSplats; i++) {
|
|
const rgbaOffset = i * 4;
|
|
const opacity = rgbaBytes[rgbaOffset + 3] / 255;
|
|
|
|
// Skip erased splats (zero opacity)
|
|
if (opacity === 0) {
|
|
continue;
|
|
}
|
|
|
|
// Unpack geometry from original packed array
|
|
const unpacked = unpackSplat(
|
|
ogSplats.packedArray,
|
|
i,
|
|
ogSplats.splatEncoding
|
|
);
|
|
|
|
// Replace color/opacity with baked painted values
|
|
unpacked.color.r = rgbaBytes[rgbaOffset + 0] / 255;
|
|
unpacked.color.g = rgbaBytes[rgbaOffset + 1] / 255;
|
|
unpacked.color.b = rgbaBytes[rgbaOffset + 2] / 255;
|
|
unpacked.opacity = opacity;
|
|
|
|
// Push to new PackedSplats
|
|
newPackedSplats.pushSplat(
|
|
unpacked.center,
|
|
unpacked.scales,
|
|
unpacked.quaternion,
|
|
unpacked.opacity,
|
|
unpacked.color
|
|
);
|
|
|
|
processedCount++;
|
|
}
|
|
|
|
console.log(`Processed ${processedCount} splats`);
|
|
|
|
// Now export the PackedSplats to SPZ
|
|
console.log("Creating SPZ writer...");
|
|
const maxSh = ioOptions.maxSh;
|
|
const spzWriter = new SpzWriter({
|
|
numSplats: nonZeroCount,
|
|
shDegree: maxSh,
|
|
fractionalBits: ioOptions.fractionalBits,
|
|
flagAntiAlias: true,
|
|
});
|
|
|
|
console.log("Writing splats to SPZ...");
|
|
// Iterate through the new packed array
|
|
for (let i = 0; i < nonZeroCount; i++) {
|
|
const unpacked = unpackSplat(
|
|
newPackedSplats.packedArray,
|
|
i,
|
|
newPackedSplats.splatEncoding
|
|
);
|
|
|
|
spzWriter.setCenter(i, unpacked.center.x, unpacked.center.y, unpacked.center.z);
|
|
spzWriter.setScale(i, unpacked.scales.x, unpacked.scales.y, unpacked.scales.z);
|
|
spzWriter.setQuat(i, unpacked.quaternion.x, unpacked.quaternion.y, unpacked.quaternion.z, unpacked.quaternion.w);
|
|
spzWriter.setAlpha(i, unpacked.opacity);
|
|
spzWriter.setRgb(i, unpacked.color.r, unpacked.color.g, unpacked.color.b);
|
|
}
|
|
const spzBytes = await spzWriter.finalize();
|
|
if (spzWriter.clippedCount > 0) {
|
|
console.log(`Clipped ${spzWriter.clippedCount} splats. Consider decreasing fractional-bits from ${ioOptions.fractionalBits} to reduce clipping.`);
|
|
}
|
|
|
|
console.log("Creating download...");
|
|
const blob = new Blob([spzBytes], { type: "application/octet-stream" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = ioOptions.filename + "-painted.spz";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
console.log("SPZ file with painted changes downloaded successfully:", ioOptions.filename + "-painted.spz");
|
|
} catch (error) {
|
|
console.error("Error exporting SPZ:", error);
|
|
console.error("Error stack:", error.stack);
|
|
}
|
|
},
|
|
};
|
|
|
|
const ioFolder = gui.addFolder("I/O");
|
|
ioFolder.add(ioOptions, "loadFile").name("Load Splats (SPZ/PLY)");
|
|
ioFolder.add(ioOptions, "saveToSpz").name("Save Splats (SPZ)");
|
|
ioFolder.open();
|
|
|
|
|
|
// Keyboard controls
|
|
window.addEventListener('keydown', (event) => {
|
|
if (event.key === '1') {
|
|
// Brush mode
|
|
PARAMETERS.brushEnabled.value = true;
|
|
PARAMETERS.eraseEnabled.value = false;
|
|
PARAMETERS.controlsEnabled = false;
|
|
controls.enabled = false;
|
|
showModeOverlay('Paint Mode');
|
|
} else if (event.key === '2') {
|
|
// Eraser mode
|
|
PARAMETERS.brushEnabled.value = false;
|
|
PARAMETERS.eraseEnabled.value = true;
|
|
PARAMETERS.controlsEnabled = false;
|
|
controls.enabled = false;
|
|
showModeOverlay('Erase Mode');
|
|
} else if (event.key === 'Escape') {
|
|
// View mode
|
|
PARAMETERS.brushEnabled.value = false;
|
|
PARAMETERS.eraseEnabled.value = false;
|
|
PARAMETERS.controlsEnabled = true;
|
|
controls.enabled = true;
|
|
showModeOverlay('View Mode');
|
|
} else if (event.key === '=' || event.key === '+') {
|
|
// Increase brush radius
|
|
const currentRadius = PARAMETERS.brushRadius.value;
|
|
const newRadius = Math.min(currentRadius + 0.01, MAX_BRUSH_RADIUS);
|
|
PARAMETERS.brushRadius.value = newRadius;
|
|
brushRadiusController.updateDisplay();
|
|
} else if (event.key === '-' || event.key === '_') {
|
|
// Decrease brush radius
|
|
const currentRadius = PARAMETERS.brushRadius.value;
|
|
const newRadius = Math.max(currentRadius - 0.01, MIN_BRUSH_RADIUS);
|
|
PARAMETERS.brushRadius.value = newRadius;
|
|
brushRadiusController.updateDisplay();
|
|
} else if (event.key === ']') {
|
|
// Increase brush depth
|
|
const currentDepth = PARAMETERS.brushDepth.value;
|
|
const newDepth = Math.min(currentDepth + 0.5, MAX_BRUSH_DEPTH);
|
|
PARAMETERS.brushDepth.value = newDepth;
|
|
brushDepthController.updateDisplay();
|
|
} else if (event.key === '[') {
|
|
// Decrease brush depth
|
|
const currentDepth = PARAMETERS.brushDepth.value;
|
|
const newDepth = Math.max(currentDepth - 0.5, MIN_BRUSH_DEPTH);
|
|
PARAMETERS.brushDepth.value = newDepth;
|
|
brushDepthController.updateDisplay();
|
|
}
|
|
});
|
|
|
|
console.log("Starting render loop");
|
|
|
|
// Animation loop
|
|
let lastTime = 0;
|
|
renderer.setAnimationLoop((rawTime) => {
|
|
rawTime *= 0.0005;
|
|
const deltaTime = rawTime - (lastTime ?? rawTime);
|
|
lastTime = rawTime;
|
|
if (PARAMETERS.controlsEnabled) {
|
|
controls.update(camera);
|
|
}
|
|
renderer.render(scene, camera);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|