Add interactive-holes example with improved text visibility (#189)

This commit is contained in:
kali
2025-10-07 01:23:45 -03:00
committed by GitHub
parent a4a649f70e
commit 5eb7dc5a5e
+461
View File
@@ -0,0 +1,461 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Interactive Holes (Click Radius)</title>
<style>
body {
margin: 0;
overflow: hidden;
background: #000;
}
header {
position: absolute;
color: silver;
font-family: sans-serif;
padding: 12px 16px;
text-align: left;
width: 100vw;
pointer-events: none;
text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8),
-1px -1px 2px rgba(0, 0, 0, 0.6),
1px -1px 2px rgba(0, 0, 0, 0.6),
-1px 1px 2px rgba(0, 0, 0, 0.6);
-webkit-text-stroke: 0.5px rgba(0, 0, 0, 0.7);
}
</style>
</head>
<body>
<header>Mouse to look around • Click on a splat to create interactive holes within a radius</header>
<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 * as THREE from "three";
import { SplatMesh, dyno, SparkControls, SplatEdit, SplatEditSdf, SplatEditSdfType, SplatEditRgbaBlendMode } from "@sparkjsdev/spark";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { GUI } from "lil-gui";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
// Scene
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 50);
camera.position.set(0, -0.3, -3);
camera.lookAt(0, 0, 1);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// Controls (mouse only, WASD disabled)
const controls = new SparkControls({ canvas: renderer.domElement });
controls.fpsMovement.enable = false; // Disable WASD movement
// Lights
// Uniforms for click-radius interactive holes (multi-impulse)
const animationTime = dyno.dynoFloat(0.0);
const uExplosionStrength = dyno.dynoFloat(4.0);
const uFriction = dyno.dynoFloat(0.98);
const uGravity = dyno.dynoFloat(9.8);
const uBounceDamping = dyno.dynoFloat(0.45);
const uFloorLevel = dyno.dynoFloat(-1.1);
const uShrinkSpeed = dyno.dynoFloat(5.0);
const uExplosionRadius = dyno.dynoFloat(0.3); // GUI-controlled radius
const MAX_IMPULSES = 128; // Increased buffer size
const impulses = Array.from({ length: MAX_IMPULSES }, () => ({
center: dyno.dynoVec3(new THREE.Vector3(0, 0, 0)),
radius: dyno.dynoFloat(0.0),
start: dyno.dynoFloat(0.0),
active: dyno.dynoFloat(0.0),
}));
let impulseWriteIndex = 0;
// Depth selection state: choose deeper intersection on repeated clicks near same pixel
let lastClickNDC = new THREE.Vector2(999, 999);
let depthIndex = 0; // reserved if we want stepped indices later
let stackedDepth = 0.0; // actual accumulated depth distance
const DEPTH_STEP = 0.08;
const NDC_PROXIMITY = 0.1; // threshold in NDC to consider repeated click
// Dyno program with radial gating based on click center and radius
function createInteractiveHolesDynoshader() {
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
// Generate input types dynamically
const inputTypes = {
gsplat: dyno.Gsplat,
time: "float",
explosionStrength: "float",
gravity: "float",
bounceDamping: "float",
floorLevel: "float",
friction: "float",
shrinkSpeed: "float",
};
// Add impulse parameters dynamically
for (let i = 0; i < MAX_IMPULSES; i++) {
inputTypes[`clickCenter${i}`] = "vec3";
inputTypes[`clickRadius${i}`] = "float";
inputTypes[`clickStart${i}`] = "float";
inputTypes[`clickActive${i}`] = "float";
}
const shader = new dyno.Dyno({
inTypes: inputTypes,
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
vec3 simulatePhysics(vec3 originalPos, float dropTime, float gravity, float damping, float floorLevel, float friction, float explosionStrength) {
float timeVariation = hash(originalPos + vec3(42.0)) * 0.2 - 0.1;
float t = max(0.0, dropTime + timeVariation);
vec3 initialVelocity = vec3(
(hash(originalPos + vec3(1.0)) - 0.5) * explosionStrength * (0.3 + hash(originalPos + vec3(10.0)) * 0.4),
abs(hash(originalPos + vec3(3.0))) * explosionStrength * (0.8 + hash(originalPos + vec3(20.0)) * 0.4) + 0.5,
(hash(originalPos + vec3(2.0)) - 0.5) * explosionStrength * (0.3 + hash(originalPos + vec3(30.0)) * 0.4)
);
float frictionDecay = pow(friction, t * 60.0);
vec3 position = originalPos;
position.x += initialVelocity.x * (1.0 - frictionDecay) / (1.0 - friction) / 60.0;
position.z += initialVelocity.z * (1.0 - frictionDecay) / (1.0 - friction) / 60.0;
position.y += initialVelocity.y * t - 0.5 * gravity * t * t;
if (position.y <= floorLevel) {
float bounceTime = t;
float bounceCount = floor(bounceTime * 3.0);
float timeSinceBounce = bounceTime - bounceCount / 3.0;
float bounceHeight = initialVelocity.y * pow(damping, bounceCount) * max(0.0, 1.0 - timeSinceBounce * 3.0);
if (bounceHeight > 0.1) {
position.y = floorLevel + abs(sin(timeSinceBounce * 3.14159 * 3.0)) * bounceHeight;
} else {
position.y = floorLevel;
float scatterFactor = hash(originalPos + vec3(50.0)) * 0.2;
position.x += (hash(originalPos + vec3(60.0)) - 0.5) * scatterFactor;
position.z += (hash(originalPos + vec3(70.0)) - 0.5) * scatterFactor;
}
}
return position;
}
`),
],
statements: ({ inputs, outputs }) => dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
vec3 originalPos = ${inputs.gsplat}.center;
const int K = ${MAX_IMPULSES};
vec3 centers[K];
float radii[K];
float starts[K];
float actives[K];
${Array.from({ length: MAX_IMPULSES }, (_, i) =>
`centers[${i}] = ${inputs[`clickCenter${i}`]}; radii[${i}] = ${inputs[`clickRadius${i}`]}; starts[${i}] = ${inputs[`clickStart${i}`]}; actives[${i}] = ${inputs[`clickActive${i}`]};`
).join('\n ')}
float maskUnion = 0.0;
float tMax = 0.0;
for (int i = 0; i < K; i++) {
float m = actives[i] > 0.5 ? step(distance(originalPos, centers[i]), radii[i]) : 0.0;
maskUnion = max(maskUnion, m);
float ti = max(0.0, ${inputs.time} - starts[i]);
tMax = max(tMax, ti * m);
}
if (maskUnion > 0.0) {
float strength = ${inputs.explosionStrength};
vec3 physicsPos = simulatePhysics(originalPos, tMax, ${inputs.gravity}, ${inputs.bounceDamping}, ${inputs.floorLevel}, ${inputs.friction}, strength);
float factor = exp(-tMax * ${inputs.shrinkSpeed});
vec3 currentScale = mix(${inputs.gsplat}.scales, vec3(0.005), 1.0 - factor);
${outputs.gsplat}.center = physicsPos;
${outputs.gsplat}.scales = currentScale;
}
`),
});
// Generate apply parameters dynamically
const applyParams = {
gsplat,
time: animationTime,
explosionStrength: uExplosionStrength,
gravity: uGravity,
bounceDamping: uBounceDamping,
floorLevel: uFloorLevel,
friction: uFriction,
shrinkSpeed: uShrinkSpeed,
};
// Add impulse parameters dynamically
for (let i = 0; i < MAX_IMPULSES; i++) {
applyParams[`clickCenter${i}`] = impulses[i].center;
applyParams[`clickRadius${i}`] = impulses[i].radius;
applyParams[`clickStart${i}`] = impulses[i].start;
applyParams[`clickActive${i}`] = impulses[i].active;
}
gsplat = shader.apply(applyParams).gsplat;
return { gsplat };
},
);
}
// Load a demo splat and a floor model to give context
const splatName = "painted-bedroom.spz";
const splatURL = await getAssetFileURL(splatName);
const splatMesh = new SplatMesh({ url: splatURL });
await splatMesh.initialized;
splatMesh.rotation.x = Math.PI;
splatMesh.position.set(0, 0, 0);
splatMesh.scale.set(1,1, 1);
splatMesh.worldModifier = createInteractiveHolesDynoshader();
splatMesh.updateGenerator();
scene.add(splatMesh);
// Persistent interactive holes edit (permanently removes splats in clicked regions)
const interactiveHolesEdit = new SplatEdit({
rgbaBlendMode: SplatEditRgbaBlendMode.MULTIPLY,
softEdge: 0.02,
sdfSmooth: 0.0,
});
splatMesh.add(interactiveHolesEdit);
// -------- CPU baking of centers so they stay on the floor and raycast uses updated positions --------
// Half-float helpers (encode/decode)
function floatToHalf(val) {
const floatView = new Float32Array(1);
const int32View = new Int32Array(floatView.buffer);
floatView[0] = val;
const x = int32View[0];
const bits = (x >>> 16) & 0x8000; // sign
const m = (x >>> 12) & 0x07ff; // mantissa
const e = (x >>> 23) & 0xff; // exponent
if (e < 103) return bits; // too small => 0
if (e > 142) return bits | 0x7c00; // too large => inf
let half = bits | ((e - 112) << 10) | (m >> 1);
return half;
}
function halfToFloat(h) {
const s = (h & 0x8000) >> 15;
let e = (h & 0x7c00) >> 10;
let f = h & 0x03ff;
if (e === 0) {
if (f === 0) return s ? -0 : 0;
// subnormal
return (s ? -1 : 1) * Math.pow(2, -14) * (f / 1024);
} else if (e === 31) {
return f ? NaN : (s ? -Infinity : Infinity);
}
return (s ? -1 : 1) * Math.pow(2, e - 15) * (1 + f / 1024);
}
// Decode all centers once and keep an up-to-date CPU-side array (friendly space with y/z flipped)
const packed = splatMesh.packedSplats.packedArray;
const originalPacked = packed.slice();
const numSplats = splatMesh.packedSplats.numSplats;
const centersFriendly = new Float32Array(numSplats * 3);
(function decodeCenters() {
for (let i = 0; i < numSplats; i++) {
const i4 = i * 4;
const w1 = packed[i4 + 1];
const w2 = packed[i4 + 2];
const uX = w1 & 0xffff;
const uY = (w1 >>> 16) & 0xffff;
const uZ = w2 & 0xffff;
const x = halfToFloat(uX);
const y = halfToFloat(uY);
const z = halfToFloat(uZ);
// Convert to friendly space used by clicks (y/z flipped)
centersFriendly[i * 3 + 0] = x;
centersFriendly[i * 3 + 1] = -y;
centersFriendly[i * 3 + 2] = -z;
}
})();
function writeCenterToPacked(index, xFriendly, yFriendly, zFriendly) {
// Convert back to packed space (invert friendly flips)
const x = xFriendly;
const y = -yFriendly;
const z = -zFriendly;
const uX = floatToHalf(x) & 0xffff;
const uY = floatToHalf(y) & 0xffff;
const uZ = floatToHalf(z) & 0xffff;
const i4 = index * 4;
// write X,Y into word1
packed[i4 + 1] = (uY << 16) | uX;
// write Z into low 16 bits of word2, keep the high 16 bits (quat)
packed[i4 + 2] = (packed[i4 + 2] & 0xffff0000) | uZ;
// Update CPU-side cache
centersFriendly[index * 3 + 0] = xFriendly;
centersFriendly[index * 3 + 1] = yFriendly;
centersFriendly[index * 3 + 2] = zFriendly;
}
function bakeClickedRegionToFloor(centerFriendly, radius) {
const r2 = radius * radius;
const floorY = 0.0; // mesh-local floor level
for (let i = 0; i < numSplats; i++) {
const cx = centersFriendly[i * 3 + 0];
const cy = centersFriendly[i * 3 + 1];
const cz = centersFriendly[i * 3 + 2];
const dx = cx - centerFriendly.x;
const dy = cy - centerFriendly.y;
const dz = cz - centerFriendly.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 <= r2) {
// Bake to floor: keep x/z, set y to floor
writeCenterToPacked(i, cx, floorY, cz);
}
}
// Upload edited centers to GPU
splatMesh.updateVersion();
}
function resetSplat() {
// Restore original packed centers
const packed = splatMesh.packedSplats.packedArray;
packed.set(originalPacked);
// Rebuild CPU-side centersFriendly from restored packed
for (let i = 0; i < numSplats; i++) {
const i4 = i * 4;
const w1 = packed[i4 + 1];
const w2 = packed[i4 + 2];
const uX = w1 & 0xffff;
const uY = (w1 >>> 16) & 0xffff;
const uZ = w2 & 0xffff;
const x = halfToFloat(uX);
const y = halfToFloat(uY);
const z = halfToFloat(uZ);
centersFriendly[i * 3 + 0] = x;
centersFriendly[i * 3 + 1] = -y;
centersFriendly[i * 3 + 2] = -z;
}
// Clear any interactive holes SDFs
try {
while (interactiveHolesEdit.children.length) interactiveHolesEdit.remove(interactiveHolesEdit.children[0]);
interactiveHolesEdit.sdfs = null;
} catch {}
// Reset impulses and stacking
for (let i = 0; i < MAX_IMPULSES; i++) {
impulses[i].active.value = 0.0;
impulses[i].radius.value = 0.0;
}
impulseWriteIndex = 0;
stackedDepth = 0.0;
lastClickNDC.set(999, 999);
// Upload
splatMesh.updateVersion();
splatMesh.updateGenerator();
}
// Raycaster for clicks (with generous threshold for better hit detection)
const raycaster = new THREE.Raycaster();
// Increase threshold to detect splats more reliably, especially after modifications
raycaster.params.Points = { threshold: 0.5 };
renderer.domElement.addEventListener("pointerdown", (event) => {
const rect = renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObject(splatMesh, false);
const hit = hits && hits.length ? hits[0] : null;
if (!hit) {
console.log("No hit detected at NDC:", ndc, "- try clicking closer to visible splats");
return;
}
console.log("Hit detected at distance:", hit.distance.toFixed(3), "point:", hit.point);
const localPoint = splatMesh.worldToLocal(hit.point.clone());
// Compute local ray direction for depth stacking
const localRayOrigin = splatMesh.worldToLocal(raycaster.ray.origin.clone());
const localRayDir = splatMesh.worldToLocal(raycaster.ray.origin.clone().add(raycaster.ray.direction)).sub(localRayOrigin).normalize();
// Adjust local axes to match visual orientation (model faces camera with X=PI)
localPoint.y = -localPoint.y;
localPoint.z = -localPoint.z;
const adjustedLocalRayDir = new THREE.Vector3(localRayDir.x, -localRayDir.y, -localRayDir.z).normalize();
// Depth stacking: if this click is near previous (in NDC), push deeper
if (ndc.distanceTo(lastClickNDC) < NDC_PROXIMITY) {
stackedDepth += DEPTH_STEP;
} else {
stackedDepth = 0.0;
}
lastClickNDC.copy(ndc);
const depthPoint = localPoint.clone().add(adjustedLocalRayDir.clone().multiplyScalar(stackedDepth));
// Write into circular impulse buffer (persist impulses; never deactivate)
const slot = impulses[impulseWriteIndex];
slot.center.value.copy(depthPoint);
slot.radius.value = uExplosionRadius.value; // use GUI-controlled radius
slot.start.value = animationTime.value; // start time for this impulse
slot.active.value = 1.0;
// Use circular buffer - overwrite oldest when full
impulseWriteIndex = (impulseWriteIndex + 1) % MAX_IMPULSES;
// Bake clicked region to floor on the CPU so raycasting sees updated geometry
bakeClickedRegionToFloor(depthPoint, uExplosionRadius.value);
splatMesh.updateVersion();
});
// Animation loop
renderer.setAnimationLoop((timeMs) => {
const time = timeMs * 0.001;
// Update camera controls
controls.update(camera);
animationTime.value = time;
// Always update; impulses are persistent and unioned in shader
splatMesh.updateVersion();
renderer.render(scene, camera);
});
// Initialize GUI
const gui = new GUI();
const params = {
explosionStrength: uExplosionStrength.value,
explosionRadius: uExplosionRadius.value,
gravity: uGravity.value
};
gui.add(params, "explosionStrength", 0.0, 10.0, 0.1)
.name("Explosion Strength")
.onChange((v) => {
uExplosionStrength.value = v;
});
gui.add(params, "explosionRadius", 0.1, 1.0, 0.05)
.name("Explosion Radius")
.onChange((v) => {
uExplosionRadius.value = v;
});
gui.add(params, "gravity", 0.0, 20.0, 0.1)
.name("Gravity")
.onChange((v) => {
uGravity.value = v;
});
gui.add({ reset: resetSplat }, "reset").name("Reset Interactive Holes");
</script>
</body>
</html>