mirror of
https://github.com/storytold/spark.git
synced 2026-10-17 00:12:59 +00:00
add greyscale painting and add i/o (#217)
This commit is contained in:
@@ -67,6 +67,10 @@
|
|||||||
"url": "https://sparkjs.dev/assets/splats/furry-logo-pedestal.spz",
|
"url": "https://sparkjs.dev/assets/splats/furry-logo-pedestal.spz",
|
||||||
"directory": "splats"
|
"directory": "splats"
|
||||||
},
|
},
|
||||||
|
"greyscale-bedroom.spz": {
|
||||||
|
"url": "https://storage.googleapis.com/forge-dev-public/marble-scenes/greyscale-room.spz",
|
||||||
|
"directory": "splats"
|
||||||
|
},
|
||||||
"gyro.spz": {
|
"gyro.spz": {
|
||||||
"url": "https://sparkjs.dev/assets/splats/food/gyro.spz",
|
"url": "https://sparkjs.dev/assets/splats/food/gyro.spz",
|
||||||
"directory": "splats/food"
|
"directory": "splats/food"
|
||||||
|
|||||||
@@ -61,7 +61,9 @@
|
|||||||
SplatMesh,
|
SplatMesh,
|
||||||
SplatTransformer,
|
SplatTransformer,
|
||||||
SparkControls,
|
SparkControls,
|
||||||
transcodeSpz,
|
SpzWriter,
|
||||||
|
unpackSplat,
|
||||||
|
PackedSplats,
|
||||||
} from "@sparkjsdev/spark";
|
} from "@sparkjsdev/spark";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||||
@@ -85,8 +87,9 @@
|
|||||||
const MIN_BRUSH_DEPTH = 0.1;
|
const MIN_BRUSH_DEPTH = 0.1;
|
||||||
const MAX_BRUSH_DEPTH = 100.0;
|
const MAX_BRUSH_DEPTH = 100.0;
|
||||||
|
|
||||||
const assetID = "painted-bedroom.spz";
|
const assetID = "greyscale-bedroom.spz";
|
||||||
let currentSplatMesh = null;
|
let currentSplatMesh = null;
|
||||||
|
let currentFileName = "painted-splat";
|
||||||
|
|
||||||
function brushDyno(
|
function brushDyno(
|
||||||
brushEnabled,
|
brushEnabled,
|
||||||
@@ -97,6 +100,8 @@
|
|||||||
brushDirection,
|
brushDirection,
|
||||||
brushColor,
|
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 }) => {
|
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
|
||||||
if (!gsplat) {
|
if (!gsplat) {
|
||||||
throw new Error("No gsplat input");
|
throw new Error("No gsplat input");
|
||||||
@@ -108,7 +113,11 @@
|
|||||||
const isInside = dyno.and(dyno.lessThan(distance, brushRadius),
|
const isInside = dyno.and(dyno.lessThan(distance, brushRadius),
|
||||||
dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)),
|
dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)),
|
||||||
dyno.lessThan(projectionAmplitude, brushDepth)));
|
dyno.lessThan(projectionAmplitude, brushDepth)));
|
||||||
const newRgb = dyno.select(brushEnabled, dyno.select(isInside, brushColor, rgb), rgb);
|
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);
|
const newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity);
|
||||||
gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity });
|
gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity });
|
||||||
return { gsplat };
|
return { gsplat };
|
||||||
@@ -181,7 +190,9 @@
|
|||||||
async function loadSplatFromFile(url) {
|
async function loadSplatFromFile(url) {
|
||||||
if (currentSplatMesh) {
|
if (currentSplatMesh) {
|
||||||
scene.remove(currentSplatMesh);
|
scene.remove(currentSplatMesh);
|
||||||
}
|
}
|
||||||
|
// Extract filename for export
|
||||||
|
currentFileName = url.split("/").pop().split("?")[0].split(".")[0] || "painted-splat";
|
||||||
currentSplatMesh = await paintableSplatMesh(
|
currentSplatMesh = await paintableSplatMesh(
|
||||||
url,
|
url,
|
||||||
PARAMETERS.brushEnabled,
|
PARAMETERS.brushEnabled,
|
||||||
@@ -299,6 +310,170 @@
|
|||||||
PARAMETERS.brushColor.value = new THREE.Color(value).convertLinearToSRGB();
|
PARAMETERS.brushColor.value = new THREE.Color(value).convertLinearToSRGB();
|
||||||
console.log(PARAMETERS.brushColor.value);
|
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
|
// Keyboard controls
|
||||||
window.addEventListener('keydown', (event) => {
|
window.addEventListener('keydown', (event) => {
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@
|
|||||||
<li><a href="/examples/dynamic-lighting/">Dynamic Lighting</a></li>
|
<li><a href="/examples/dynamic-lighting/">Dynamic Lighting</a></li>
|
||||||
<li><a href="/examples/particle-animation/">Particle Animation</a></li>
|
<li><a href="/examples/particle-animation/">Particle Animation</a></li>
|
||||||
<li><a href="/examples/particle-simulation/">Particle Simulation</a></li>
|
<li><a href="/examples/particle-simulation/">Particle Simulation</a></li>
|
||||||
<li><a href="/examples/splat-painter/">Splat Plainter</a></li>
|
<li><a href="/examples/splat-painter/">Splat Painter</a></li>
|
||||||
<li><a href="/examples/splat-reveal-effects/">Splat Reveal Effects</a></li>
|
<li><a href="/examples/splat-reveal-effects/">Splat Reveal Effects</a></li>
|
||||||
<li><a href="/examples/splat-shader-effects/">Splat Shader Effects</a></li>
|
<li><a href="/examples/splat-shader-effects/">Splat Shader Effects</a></li>
|
||||||
<li><a href="/examples/splat-transitions/">Splat Transitions</a></li>
|
<li><a href="/examples/splat-transitions/">Splat Transitions</a></li>
|
||||||
|
|||||||
Reference in New Issue
Block a user