mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
462 lines
18 KiB
HTML
462 lines
18 KiB
HTML
<!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>
|
|
|
|
|