Skip to content

Commit c9c488c

Browse files
authored
add greyscale painting and add i/o (#217)
1 parent 0d9f7bf commit c9c488c

3 files changed

Lines changed: 184 additions & 5 deletions

File tree

examples/assets.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@
6767
"url": "https://sparkjs.dev/assets/splats/furry-logo-pedestal.spz",
6868
"directory": "splats"
6969
},
70+
"greyscale-bedroom.spz": {
71+
"url": "https://storage.googleapis.com/forge-dev-public/marble-scenes/greyscale-room.spz",
72+
"directory": "splats"
73+
},
7074
"gyro.spz": {
7175
"url": "https://sparkjs.dev/assets/splats/food/gyro.spz",
7276
"directory": "splats/food"

examples/splat-painter/index.html

Lines changed: 179 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@
6161
SplatMesh,
6262
SplatTransformer,
6363
SparkControls,
64-
transcodeSpz,
64+
SpzWriter,
65+
unpackSplat,
66+
PackedSplats,
6567
} from "@sparkjsdev/spark";
6668
import * as THREE from "three";
6769
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
@@ -85,8 +87,9 @@
8587
const MIN_BRUSH_DEPTH = 0.1;
8688
const MAX_BRUSH_DEPTH = 100.0;
8789

88-
const assetID = "painted-bedroom.spz";
90+
const assetID = "greyscale-bedroom.spz";
8991
let currentSplatMesh = null;
92+
let currentFileName = "painted-splat";
9093

9194
function brushDyno(
9295
brushEnabled,
@@ -97,6 +100,8 @@
97100
brushDirection,
98101
brushColor,
99102
) {
103+
const flatColor = dyno.dynoVec3(new THREE.Vector3(1.0, 1.0, 1.0));
104+
const luminanceThreshold = dyno.dynoFloat(0.1);
100105
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
101106
if (!gsplat) {
102107
throw new Error("No gsplat input");
@@ -108,7 +113,11 @@
108113
const isInside = dyno.and(dyno.lessThan(distance, brushRadius),
109114
dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)),
110115
dyno.lessThan(projectionAmplitude, brushDepth)));
111-
const newRgb = dyno.select(brushEnabled, dyno.select(isInside, brushColor, rgb), rgb);
116+
const luminanceOld = dyno.div(dyno.dot(rgb, flatColor), dyno.dynoFloat(3.0));
117+
const luminanceNew = dyno.div(dyno.dot(brushColor, flatColor), dyno.dynoFloat(3.0));
118+
const weightedRgb = dyno.mul(brushColor, dyno.div(luminanceOld, luminanceNew));
119+
const isLuminanceAboveThreshold = dyno.greaterThan(luminanceOld, luminanceThreshold);
120+
const newRgb = dyno.select(dyno.and(dyno.and(brushEnabled, isInside), isLuminanceAboveThreshold), weightedRgb, rgb);
112121
const newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity);
113122
gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity });
114123
return { gsplat };
@@ -181,7 +190,9 @@
181190
async function loadSplatFromFile(url) {
182191
if (currentSplatMesh) {
183192
scene.remove(currentSplatMesh);
184-
}
193+
}
194+
// Extract filename for export
195+
currentFileName = url.split("/").pop().split("?")[0].split(".")[0] || "painted-splat";
185196
currentSplatMesh = await paintableSplatMesh(
186197
url,
187198
PARAMETERS.brushEnabled,
@@ -299,6 +310,170 @@
299310
PARAMETERS.brushColor.value = new THREE.Color(value).convertLinearToSRGB();
300311
console.log(PARAMETERS.brushColor.value);
301312
});
313+
314+
// I/O functionality (load and export)
315+
const ioOptions = {
316+
filename: currentFileName,
317+
maxSh: 0, // Painted splats don't preserve SH data
318+
fractionalBits: 12,
319+
loadFile: () => {
320+
// Create file input element
321+
const input = document.createElement('input');
322+
input.type = 'file';
323+
input.accept = '.spz,.ply';
324+
input.onchange = async (e) => {
325+
const file = e.target.files[0];
326+
if (!file) return;
327+
328+
// Create a blob URL from the file
329+
const url = URL.createObjectURL(file);
330+
331+
try {
332+
await loadSplatFromFile(url);
333+
console.log("Loaded file:", file.name);
334+
// Update filename for export
335+
ioOptions.filename = file.name.split(".")[0] || "painted-splat";
336+
} catch (error) {
337+
console.error("Error loading file:", error);
338+
alert("Failed to load file. Make sure it's a valid SPZ or PLY file.");
339+
} finally {
340+
// Clean up the object URL
341+
URL.revokeObjectURL(url);
342+
}
343+
};
344+
input.click();
345+
},
346+
saveToSpz: async () => {
347+
if (!currentSplatMesh) {
348+
console.error("Export failed - currentSplatMesh:", !!currentSplatMesh);
349+
alert("No splat mesh loaded for export.");
350+
return;
351+
}
352+
353+
try {
354+
console.log("Starting SPZ export with painted changes...");
355+
356+
if (!currentSplatMesh.splatRgba) {
357+
currentSplatMesh.splatRgba = spark.getRgba({
358+
generator: currentSplatMesh,
359+
rgba: currentSplatMesh.splatRgba
360+
});
361+
currentSplatMesh.updateGenerator();
362+
}
363+
364+
const rgbaBytes = await spark.readRgba({
365+
generator: currentSplatMesh,
366+
rgba: currentSplatMesh.splatRgba
367+
});
368+
369+
const ogSplats = currentSplatMesh.packedSplats;
370+
const totalSplats = ogSplats.numSplats;
371+
console.log("Total splats:", totalSplats);
372+
373+
let nonZeroCount = 0;
374+
for (let i = 0; i < totalSplats; i++) {
375+
const opacity = rgbaBytes[i * 4 + 3] / 255;
376+
if (opacity > 0) {
377+
nonZeroCount++;
378+
}
379+
}
380+
381+
// Create new PackedSplats with baked changes
382+
const newPackedSplats = new PackedSplats({
383+
maxSplats: nonZeroCount,
384+
splatEncoding: ogSplats.splatEncoding,
385+
});
386+
387+
// Build splat array from baked RGBA
388+
let processedCount = 0;
389+
for (let i = 0; i < totalSplats; i++) {
390+
const rgbaOffset = i * 4;
391+
const opacity = rgbaBytes[rgbaOffset + 3] / 255;
392+
393+
// Skip erased splats (zero opacity)
394+
if (opacity === 0) {
395+
continue;
396+
}
397+
398+
// Unpack geometry from original packed array
399+
const unpacked = unpackSplat(
400+
ogSplats.packedArray,
401+
i,
402+
ogSplats.splatEncoding
403+
);
404+
405+
// Replace color/opacity with baked painted values
406+
unpacked.color.r = rgbaBytes[rgbaOffset + 0] / 255;
407+
unpacked.color.g = rgbaBytes[rgbaOffset + 1] / 255;
408+
unpacked.color.b = rgbaBytes[rgbaOffset + 2] / 255;
409+
unpacked.opacity = opacity;
410+
411+
// Push to new PackedSplats
412+
newPackedSplats.pushSplat(
413+
unpacked.center,
414+
unpacked.scales,
415+
unpacked.quaternion,
416+
unpacked.opacity,
417+
unpacked.color
418+
);
419+
420+
processedCount++;
421+
}
422+
423+
console.log(`Processed ${processedCount} splats`);
424+
425+
// Now export the PackedSplats to SPZ
426+
console.log("Creating SPZ writer...");
427+
const maxSh = ioOptions.maxSh;
428+
const spzWriter = new SpzWriter({
429+
numSplats: nonZeroCount,
430+
shDegree: maxSh,
431+
fractionalBits: ioOptions.fractionalBits,
432+
flagAntiAlias: true,
433+
});
434+
435+
console.log("Writing splats to SPZ...");
436+
// Iterate through the new packed array
437+
for (let i = 0; i < nonZeroCount; i++) {
438+
const unpacked = unpackSplat(
439+
newPackedSplats.packedArray,
440+
i,
441+
newPackedSplats.splatEncoding
442+
);
443+
444+
spzWriter.setCenter(i, unpacked.center.x, unpacked.center.y, unpacked.center.z);
445+
spzWriter.setScale(i, unpacked.scales.x, unpacked.scales.y, unpacked.scales.z);
446+
spzWriter.setQuat(i, unpacked.quaternion.x, unpacked.quaternion.y, unpacked.quaternion.z, unpacked.quaternion.w);
447+
spzWriter.setAlpha(i, unpacked.opacity);
448+
spzWriter.setRgb(i, unpacked.color.r, unpacked.color.g, unpacked.color.b);
449+
}
450+
const spzBytes = await spzWriter.finalize();
451+
if (spzWriter.clippedCount > 0) {
452+
console.log(`Clipped ${spzWriter.clippedCount} splats. Consider decreasing fractional-bits from ${ioOptions.fractionalBits} to reduce clipping.`);
453+
}
454+
455+
console.log("Creating download...");
456+
const blob = new Blob([spzBytes], { type: "application/octet-stream" });
457+
const url = URL.createObjectURL(blob);
458+
const a = document.createElement("a");
459+
a.href = url;
460+
a.download = ioOptions.filename + "-painted.spz";
461+
a.click();
462+
URL.revokeObjectURL(url);
463+
464+
console.log("SPZ file with painted changes downloaded successfully:", ioOptions.filename + "-painted.spz");
465+
} catch (error) {
466+
console.error("Error exporting SPZ:", error);
467+
console.error("Error stack:", error.stack);
468+
}
469+
},
470+
};
471+
472+
const ioFolder = gui.addFolder("I/O");
473+
ioFolder.add(ioOptions, "loadFile").name("Load Splats (SPZ/PLY)");
474+
ioFolder.add(ioOptions, "saveToSpz").name("Save Splats (SPZ)");
475+
ioFolder.open();
476+
302477

303478
// Keyboard controls
304479
window.addEventListener('keydown', (event) => {

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ <h2>Examples</h2>
142142
<li><a href="/examples/dynamic-lighting/">Dynamic Lighting</a></li>
143143
<li><a href="/examples/particle-animation/">Particle Animation</a></li>
144144
<li><a href="/examples/particle-simulation/">Particle Simulation</a></li>
145-
<li><a href="/examples/splat-painter/">Splat Plainter</a></li>
145+
<li><a href="/examples/splat-painter/">Splat Painter</a></li>
146146
<li><a href="/examples/splat-reveal-effects/">Splat Reveal Effects</a></li>
147147
<li><a href="/examples/splat-shader-effects/">Splat Shader Effects</a></li>
148148
<li><a href="/examples/splat-transitions/">Splat Transitions</a></li>

0 commit comments

Comments
 (0)