Splat Texture (#79)

* Add experimental Splat Texture rendering and examples/splat-texture. Added star.png and heart.png to assets.json and to assets repo. Update examples/depth-of-field to compute from metric aperture size. Added SparkRenderer.renderScale debug parameter. Updated examples/editor: set default highDPI to true, added logFocalDistance, apertureAngle, refactored, grouped into debugFolder, default settings open, preset buttons for nonAA and AA settings.

* Set default highDpi in examples/editor to not(isMobile).
This commit is contained in:
Andreas Sundquist
2025-07-01 07:57:34 -07:00
committed by GitHub
parent c0db3576bd
commit 179885595c
9 changed files with 359 additions and 40 deletions
+8
View File
@@ -102,5 +102,13 @@
"valley.spz": {
"url": "https://sparkjs.dev/assets/splats/valley.spz",
"directory": "splats"
},
"star.png": {
"url": "https://sparkjs.dev/assets/images/star.png",
"directory": "images"
},
"heart.png": {
"url": "https://sparkjs.dev/assets/images/heart.png",
"directory": "images"
}
}
+19 -5
View File
@@ -38,15 +38,29 @@
document.body.appendChild(renderer.domElement)
const spark = new SparkRenderer({
renderer,
apertureAngle: 0.02,
focalDistance: 5.0,
renderer,
apertureAngle: 0.02,
focalDistance: 5.0,
});
scene.add(spark);
const apertureSize = {
apertureSize: 0.1,
};
function updateApertureAngle() {
if (spark.focalDistance > 0) {
spark.apertureAngle = 2 * Math.atan(0.5 * apertureSize.apertureSize / spark.focalDistance);
} else {
spark.apertureAngle = 0.0;
}
}
updateApertureAngle();
const gui = new GUI({ title: "DoF settings" });
gui.add(spark, "focalDistance", 0.1, 15, 0.1);
gui.add(spark, "apertureAngle", 0.0, 0.01 * Math.PI, 0.001);
gui.add(spark, "focalDistance", 0, 15, 0.01).name("Focal plane dist")
.onChange(updateApertureAngle);
gui.add(apertureSize, "apertureSize", 0, 0.4, 0.01).name("Aperture size")
.onChange(updateApertureAngle);
const splatURL = await getAssetFileURL("valley.spz");
const background = new SplatMesh({ url: splatURL });
+49 -24
View File
@@ -68,7 +68,7 @@
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "lil-gui";
import { constructGrid, SparkControls, SparkRenderer, SplatMesh, textSplats, dyno, transcodeSpz, isPcSogs } from "@sparkjsdev/spark";
import { constructGrid, SparkControls, SparkRenderer, SplatMesh, textSplats, dyno, transcodeSpz, isMobile, isPcSogs } from "@sparkjsdev/spark";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
@@ -113,7 +113,7 @@
const gui = new GUI({
title: "Settings",
container: document.getElementById("main-gui")
}).close();
});
const secondGui = new GUI({
title: "Splats",
container: document.getElementById("second-gui")
@@ -134,7 +134,7 @@
};
const guiOptions = {
highDevicePixel: false,
highDevicePixel: !isMobile(),
stats: false,
resetOnLoad: true,
loadOffset: 0,
@@ -462,30 +462,55 @@
controls.pointerControls.reverseSwipe = value;
});
gui.add(guiOptions, "highDevicePixel").name("High DPI").onChange((value) => {
function setHighDpi(value) {
renderer.setPixelRatio(value ? window.devicePixelRatio : 1);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
console.log("Render size", canvas.width, canvas.height);
}
setHighDpi(guiOptions.highDevicePixel);
gui.add(guiOptions, "highDevicePixel").name("High DPI").onChange((value) => {
setHighDpi(value);
});
gui.add(guiOptions, "stats").name("Show frame stats").onChange((value) => {
stats.dom.style.display = value ? "block" : "none";
});
gui.add(spark.defaultView, "sortRadial").name("Radial sort").listen();
gui.add(grid, "opacity", 0, 1, 0.01).name("Grid opacity").listen();
gui.add({
logFocalDistance: 0.0,
}, "logFocalDistance", -2, 2, 0.01).name("Ln(Focal distance)").onChange((value) => {
spark.focalDistance = Math.exp(value);
});
gui.add(spark, "apertureAngle", 0, 0.01 * Math.PI, 0.001).name("Aperture angle").listen();
const debugFolder = gui.addFolder("Debug").close();
const normalColor = dyno.dynoBool(false);
gui.add(normalColor, "value").name("Normal color").onChange(() => updateFrameSplats());
debugFolder.add(normalColor, "value").name("Normal color").onChange(() => updateFrameSplats());
gui.add(spark, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
gui.add(spark, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
gui.add(spark, "preBlurAmount", 0, 2, 0.1).name("Blur amount (no AA)");
gui.add(spark, "blurAmount", 0, 2, 0.1).name("Blur amount (AA)");
debugFolder.add(spark, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
debugFolder.add(spark, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
debugFolder.add(spark, "preBlurAmount", 0, 2, 0.1).name("Blur amount (no AA)").listen();
debugFolder.add(spark, "blurAmount", 0, 2, 0.1).name("Blur amount (AA)").listen();
debugFolder.add({
nonAA: () => {
spark.preBlurAmount = 0.3;
spark.blurAmount = 0.0;
},
}, "nonAA").name("Non-AA preset");
debugFolder.add({
AA: () => {
spark.preBlurAmount = 0.0;
spark.blurAmount = 0.3;
},
}, "AA").name("AA preset");
debugFolder.add(spark, "renderScale", 0.1, 2.0, 0.1).name("Render scale");
const splatsFolder = secondGui.addFolder("Files");
const editFolder = gui.addFolder("Edit Splats").close();
const clipFolder = gui.addFolder("Clip Splats").close();
function updateFrameSplats() {
frame.children.forEach((child) => {
@@ -502,13 +527,13 @@
const clipMaxY = dyno.dynoFloat(5);
const clipMinZ = dyno.dynoFloat(-5);
const clipMaxZ = dyno.dynoFloat(5);
editFolder.add(clipEnable, "value").name("Enable clip").onChange(() => updateFrameSplats());
editFolder.add(clipMinX, "value", -5, 5, 0.01).name("Min X").onChange(() => updateFrameSplats());
editFolder.add(clipMaxX, "value", -5, 5, 0.01).name("Max X").onChange(() => updateFrameSplats());
editFolder.add(clipMinY, "value", -5, 5, 0.01).name("Min Y").onChange(() => updateFrameSplats());
editFolder.add(clipMaxY, "value", -5, 5, 0.01).name("Max Y").onChange(() => updateFrameSplats());
editFolder.add(clipMinZ, "value", -5, 5, 0.01).name("Min Z").onChange(() => updateFrameSplats());
editFolder.add(clipMaxZ, "value", -5, 5, 0.01).name("Max Z").onChange(() => updateFrameSplats());
clipFolder.add(clipEnable, "value").name("Enable clip").onChange(() => updateFrameSplats());
clipFolder.add(clipMinX, "value", -50, 50, 0.01).name("Min X").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxX, "value", -50, 50, 0.01).name("Max X").onChange(() => updateFrameSplats());
clipFolder.add(clipMinY, "value", -50, 50, 0.01).name("Min Y").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxY, "value", -50, 50, 0.01).name("Max Y").onChange(() => updateFrameSplats());
clipFolder.add(clipMinZ, "value", -50, 50, 0.01).name("Min Z").onChange(() => updateFrameSplats());
clipFolder.add(clipMaxZ, "value", -50, 50, 0.01).name("Max Z").onChange(() => updateFrameSplats());
function makeWorldModifier(mesh) {
const context = mesh.context;
@@ -548,7 +573,7 @@
});
}
const writeFolder = secondGui.addFolder("Write Gsplats").close();
const exportFolder = secondGui.addFolder("Export Gsplats").close();
const writeOptions = {
filename: "gsplats",
trimOpacity: true,
@@ -591,12 +616,12 @@
URL.revokeObjectURL(url);
},
};
writeFolder.add(writeOptions, "filename").name("Filename").listen();
writeFolder.add(writeOptions, "trimOpacity").name("Trim low opacity");
writeFolder.add(writeOptions, "trimOpacityThreshold").name("Trim opacity <= 0..1");
writeFolder.add(writeOptions, "maxSh", 0, 3, 1).name("Max spherical harmonics");
writeFolder.add(writeOptions, "fractionalBits", 6, 24, 1).name("Fractional bits");
writeFolder.add(writeOptions, "writeSpz").name("Create .spz and download");
exportFolder.add(writeOptions, "filename").name("Filename").listen();
exportFolder.add(writeOptions, "trimOpacity").name("Trim low opacity");
exportFolder.add(writeOptions, "trimOpacityThreshold").name("Trim opacity <= 0..1");
exportFolder.add(writeOptions, "maxSh", 0, 3, 1).name("Max spherical harmonics");
exportFolder.add(writeOptions, "fractionalBits", 6, 24, 1).name("Fractional bits");
exportFolder.add(writeOptions, "writeSpz").name("Create .spz and download");
function makeInstructions() {
const instructions = textSplats({
+166
View File
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Splat Texture</title>
<style>
body {
margin: 0;
}
canvas {
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { SparkRenderer, SplatMesh, SparkControls } from "@sparkjsdev/spark";
import GUI from "lil-gui";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.01, 100);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement)
function imgToRgba(img) {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const rgba = new Uint8Array(imageData.data.buffer);
return { rgba, width: img.width, height: img.height };
}
const textureLoader = new THREE.TextureLoader();
const starUrl = await getAssetFileURL("star.png");
const star = imgToRgba((await textureLoader.loadAsync(starUrl)).image);
const heartUrl = await getAssetFileURL("heart.png");
const heart = imgToRgba((await textureLoader.loadAsync(heartUrl)).image);
const splatTexLayers = 32;
const texData = new Uint8Array(4 * star.width * star.height * splatTexLayers);
const texData2 = new Uint8Array(4 * star.width * star.height * splatTexLayers);
for (let z = 0; z < splatTexLayers; z++) {
const t = z / (splatTexLayers - 1);
const starOpacity = Math.max((t - 0.5) / 0.5, 0.0);
const heartOpacity = Math.max((0.5 - t) / 0.5, 0.0);
const gaussOpacity = Math.max(1 - Math.abs(t - 0.5) / 0.5, 0.0);
for (let y = 0; y < star.height; y++) {
for (let x = 0; x < star.width; x++) {
const inIndex = (y * star.width) + x;
const outIndex = ((z * star.height) + y) * star.width + x;
const o4 = outIndex * 4;
const i4 = inIndex * 4;
const deltaX = x / star.width - 0.5;
const deltaY = y / star.height - 0.5;
const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / 0.5;
const inside = (dist < 1.0) ? 255 : 0;
let r = t * star.rgba[i4] + (1 - t) * inside;
let g = t * star.rgba[i4 + 1] + (1 - t) * inside;
let b = t * star.rgba[i4 + 2] + (1 - t) * inside;
let a = t * star.rgba[i4 + 3] + (1 - t) * inside;
texData[o4] = Math.max(0, Math.min(255, Math.round(r)));
texData[o4 + 1] = Math.max(0, Math.min(255, Math.round(g)));
texData[o4 + 2] = Math.max(0, Math.min(255, Math.round(b)));
texData[o4 + 3] = Math.max(0, Math.min(255, Math.round(a)));
r = starOpacity * star.rgba[i4] + heartOpacity * heart.rgba[i4] + inside * gaussOpacity;
g = starOpacity * star.rgba[i4 + 1] + heartOpacity * heart.rgba[i4 + 1] + inside * gaussOpacity;
b = starOpacity * star.rgba[i4 + 2] + heartOpacity * heart.rgba[i4 + 2] + inside * gaussOpacity;
a = starOpacity * star.rgba[i4 + 3] + heartOpacity * heart.rgba[i4 + 3] + inside * gaussOpacity;
texData2[o4] = Math.max(0, Math.min(255, Math.round(r)));
texData2[o4 + 1] = Math.max(0, Math.min(255, Math.round(g)));
texData2[o4 + 2] = Math.max(0, Math.min(255, Math.round(b)));
texData2[o4 + 3] = Math.max(0, Math.min(255, Math.round(a)));
}
}
}
const texture = new THREE.Data3DTexture(texData, star.width, star.height, splatTexLayers);
texture.needsUpdate = true;
const texture2 = new THREE.Data3DTexture(texData2, star.width, star.height, splatTexLayers);
texture2.needsUpdate = true;
const spark = new SparkRenderer({
renderer,
maxStdDev: 1.0,
focalDistance: 0,
});
const splatTexture = {
enable: true,
texture: texture,
near: 1.0,
far: 15,
mid: 5,
};
spark.splatTexture = splatTexture;
scene.add(spark);
const gui = new GUI({ title: "DoF settings" });
const selectedTexture = {
selection: "texture",
};
gui.add(selectedTexture, "selection", ["none", "texture", "texture2"]).name("Splat texture").onChange(() => {
splatTexture.enable = true;
if (selectedTexture.selection === "texture") {
splatTexture.texture = texture;
} else if (selectedTexture.selection === "texture2") {
splatTexture.texture = texture2;
} else {
splatTexture.enable = false;
}
});
gui.add(spark, "maxStdDev", 0.1, 3, 0.1);
gui.add(spark, "falloff", 0, 1, 0.01).name("Gaussian falloff");
const apertureSize = {
apertureSize: 0.1,
};
function updateApertureAngle() {
splatTexture.mid = spark.focalDistance;
splatTexture.near = spark.focalDistance / 5.0;
splatTexture.far = spark.focalDistance * 5.0;
if (spark.focalDistance > 0) {
spark.apertureAngle = 2 * Math.atan(0.5 * apertureSize.apertureSize / spark.focalDistance);
} else {
spark.apertureAngle = 0.0;
}
}
updateApertureAngle();
gui.add(spark, "focalDistance", 0, 15, 0.01).name("Focal plane dist")
.onChange(updateApertureAngle);
gui.add(apertureSize, "apertureSize", 0, 1, 0.01).name("Aperture size")
.onChange(updateApertureAngle);
const splatURL = await getAssetFileURL("valley.spz");
const background = new SplatMesh({ url: splatURL });
background.quaternion.set(1, 0, 0, 0);
background.scale.setScalar(0.5);
scene.add(background);
const controls = new SparkControls({ canvas: renderer.domElement });
renderer.setAnimationLoop(function animate(time) {
controls.update(camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>