|
61 | 61 | SplatMesh, |
62 | 62 | SplatTransformer, |
63 | 63 | SparkControls, |
64 | | - transcodeSpz, |
| 64 | + SpzWriter, |
| 65 | + unpackSplat, |
| 66 | + PackedSplats, |
65 | 67 | } from "@sparkjsdev/spark"; |
66 | 68 | import * as THREE from "three"; |
67 | 69 | import { getAssetFileURL } from "/examples/js/get-asset-url.js"; |
|
85 | 87 | const MIN_BRUSH_DEPTH = 0.1; |
86 | 88 | const MAX_BRUSH_DEPTH = 100.0; |
87 | 89 |
|
88 | | - const assetID = "painted-bedroom.spz"; |
| 90 | + const assetID = "greyscale-bedroom.spz"; |
89 | 91 | let currentSplatMesh = null; |
| 92 | + let currentFileName = "painted-splat"; |
90 | 93 |
|
91 | 94 | function brushDyno( |
92 | 95 | brushEnabled, |
|
97 | 100 | brushDirection, |
98 | 101 | brushColor, |
99 | 102 | ) { |
| 103 | + const flatColor = dyno.dynoVec3(new THREE.Vector3(1.0, 1.0, 1.0)); |
| 104 | + const luminanceThreshold = dyno.dynoFloat(0.1); |
100 | 105 | return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => { |
101 | 106 | if (!gsplat) { |
102 | 107 | throw new Error("No gsplat input"); |
|
108 | 113 | const isInside = dyno.and(dyno.lessThan(distance, brushRadius), |
109 | 114 | dyno.and(dyno.greaterThan(projectionAmplitude, dyno.dynoFloat(0.0)), |
110 | 115 | 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); |
112 | 121 | const newOpacity = dyno.select(eraseEnabled, dyno.select(isInside, dyno.dynoFloat(0.0), opacity), opacity); |
113 | 122 | gsplat = dyno.combineGsplat({ gsplat, rgb: newRgb, opacity: newOpacity }); |
114 | 123 | return { gsplat }; |
|
181 | 190 | async function loadSplatFromFile(url) { |
182 | 191 | if (currentSplatMesh) { |
183 | 192 | scene.remove(currentSplatMesh); |
184 | | - } |
| 193 | + } |
| 194 | + // Extract filename for export |
| 195 | + currentFileName = url.split("/").pop().split("?")[0].split(".")[0] || "painted-splat"; |
185 | 196 | currentSplatMesh = await paintableSplatMesh( |
186 | 197 | url, |
187 | 198 | PARAMETERS.brushEnabled, |
|
299 | 310 | PARAMETERS.brushColor.value = new THREE.Color(value).convertLinearToSRGB(); |
300 | 311 | console.log(PARAMETERS.brushColor.value); |
301 | 312 | }); |
| 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 | + |
302 | 477 |
|
303 | 478 | // Keyboard controls |
304 | 479 | window.addEventListener('keydown', (event) => { |
|
0 commit comments