main.ts
examples/spark/interactive-holes/main.ts · raw · open the example · all examples · site home
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
// Spark's interactive-holes: each click on the bedroom blows the splats
// within a radius up and drops them bouncing to the floor, while they
// shrink. Up to 128 clicks live in uniform arrays read by a world-space
// modifier's shader loop over the stored (original) centers; each click also
// moves the centers in its radius to the floor in the raycast's CPU copy
// only, so later clicks reach through the holes. As in Spark, the GPU keeps
// the original centers: the shader picks the splats a click hit from them.
import * as THREE from "three";
import { GUI } from "lil-gui";
import { CameraControls, TSL, type SplatNode, type TslNode } from "@sparkjsdev/radix";
import { asset } from "../js/assets.js";
import { SplatSet, forgetPicker, pickerOf } from "../js/objects.js";
import { examplePage, windowRenderer } from "../js/page.js";
const { Loop, abs, distance, dot, exp, float, floor, fract, max, mix, pow, select, sin, step, uniform, uniformArray, vec3 } = TSL;
const page = examplePage("interactive-holes");
page.legend("Mouse to look around, click on a splat to blow a hole within a radius");
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 50);
camera.position.set(0, -0.3, -3);
// createRenderer, sized to the window and added to the page; it follows
// window resizes, the camera's aspect too (js/page.ts)
const renderer = await windowRenderer(camera, { antialias: true, pixelRatio: Math.min(devicePixelRatio, 2) });
const controls = new CameraControls({ canvas: renderer.domElement, object: camera, storageKey: "spark-interactive-holes" });
controls.lookAt(new THREE.Vector3(0, -0.3, 1));
const FRICTION = 0.98;
const BOUNCE_DAMPING = 0.45;
const FLOOR = -1.1;
const SHRINK_SPEED = 5;
const animationTime = uniform(0);
const strength = uniform(4);
const gravity = uniform(9.8);
const settings = { radius: 0.3 };
// Per click: center and radius; start time and active
const MAX_IMPULSES = 128;
const impulseSpheres = Array.from({ length: MAX_IMPULSES }, () => new THREE.Vector4());
const impulseTimes = Array.from({ length: MAX_IMPULSES }, () => new THREE.Vector4());
const uSpheres = uniformArray(impulseSpheres, "vec4");
const uTimes = uniformArray(impulseTimes, "vec4");
const hash = (p: TslNode, k: number) => fract(sin(dot(p.add(vec3(k)), vec3(127.1, 311.7, 74.7))).mul(43758.5453));
// Where a splat blown from `original` is `time` seconds later
function simulatePhysics(original: TslNode, time: TslNode) {
const t = max(0, time.add(hash(original, 42).mul(0.2).sub(0.1))).toVar();
const velocity = vec3(
hash(original, 1).sub(0.5).mul(strength).mul(hash(original, 10).mul(0.4).add(0.3)),
abs(hash(original, 3)).mul(strength).mul(hash(original, 20).mul(0.4).add(0.8)).add(0.5),
hash(original, 2).sub(0.5).mul(strength).mul(hash(original, 30).mul(0.4).add(0.3)),
).toVar();
const slide = float(1).sub(pow(FRICTION, t.mul(60))).div((1 - FRICTION) * 60);
const x = original.x.add(velocity.x.mul(slide)).toVar();
const z = original.z.add(velocity.z.mul(slide)).toVar();
const y = original.y.add(velocity.y.mul(t)).sub(gravity.mul(0.5).mul(t).mul(t)).toVar();
const bounceCount = floor(t.mul(3));
const sinceBounce = t.sub(bounceCount.div(3)).toVar();
const bounceHeight = velocity.y.mul(pow(BOUNCE_DAMPING, bounceCount)).mul(max(0, float(1).sub(sinceBounce.mul(3)))).toVar();
const bouncing = bounceHeight.greaterThan(0.1);
const landed = y.lessThanEqual(FLOOR);
const settled = landed.and(bouncing.not());
const scatter = hash(original, 50).mul(0.2);
return vec3(
select(settled, x.add(hash(original, 60).sub(0.5).mul(scatter)), x),
select(landed, select(bouncing, abs(sin(sinceBounce.mul(Math.PI * 3))).mul(bounceHeight).add(FLOOR), float(FLOOR)), y),
select(settled, z.add(hash(original, 70).sub(0.5).mul(scatter)), z),
);
}
// The latest click whose sphere holds a splat moves and shrinks it
const holes = (s: SplatNode) => {
const out = s.toVar();
const original = s.get("center").toVar();
const mask = float(0).toVar();
const tMax = float(0).toVar();
Loop(MAX_IMPULSES, ({ i }: { i: TslNode }) => {
const sphere = uSpheres.element(i);
const times = uTimes.element(i);
const m = select(times.y.greaterThan(0.5), step(distance(original, sphere.xyz), sphere.w), float(0));
mask.assign(max(mask, m));
tMax.assign(max(tMax, max(0, animationTime.sub(times.x)).mul(m)));
});
const hit = mask.greaterThan(0);
out.get("center").assign(select(hit, simulatePhysics(original, tMax), original));
out.get("scales").assign(select(hit, mix(s.get("scales"), vec3(0.005), float(1).sub(exp(tMax.mul(-SHRINK_SPEED)))), s.get("scales")));
return out;
};
const splats = new SplatSet({ renderer, scene, animate: true, modifier: holes, modifierSpace: "world" });
const bedroom = await splats.load(asset("painted-bedroom.spz"), { onProgress: page.progress("painted-bedroom") });
bedroom.rotation.x = Math.PI;
page.ready(renderer);
// The picker's centers in a sphere (in the model's space: Spark's mesh-local
// floor) moved to the floor; the click's raycast has made the picker
function bakeToFloor(worldCenter: THREE.Vector3, radius: number) {
const local = bedroom.worldToLocal(worldCenter.clone());
const { centers } = pickerOf(bedroom.data);
for (let i = 0; i < centers.length; i += 3) {
const x = centers[i] - local.x;
const y = centers[i + 1] - local.y;
const z = centers[i + 2] - local.z;
if (x * x + y * y + z * z <= radius * radius) {
centers[i + 1] = 0;
}
}
}
// Clicks near the previous one (in NDC) reach deeper
const DEPTH_STEP = 0.08;
const NDC_PROXIMITY = 0.1;
let next = 0;
let depth = 0;
const lastClick = new THREE.Vector2(999, 999);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
renderer.domElement.addEventListener("pointerdown", (event) => {
pointer.set((event.clientX / innerWidth) * 2 - 1, -(event.clientY / innerHeight) * 2 + 1);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObject(bedroom, false)[0];
if (!hit) {
return;
}
depth = pointer.distanceTo(lastClick) < NDC_PROXIMITY ? depth + DEPTH_STEP : 0;
lastClick.copy(pointer);
const point = hit.point.addScaledVector(raycaster.ray.direction, depth);
impulseSpheres[next].set(point.x, point.y, point.z, settings.radius);
impulseTimes[next].set(animationTime.value, 1, 0, 0);
next = (next + 1) % MAX_IMPULSES;
bakeToFloor(point, settings.radius);
});
function reset() {
forgetPicker(bedroom.data);
impulseTimes.forEach((times) => times.set(0, 0, 0, 0));
next = 0;
depth = 0;
lastClick.set(999, 999);
}
const gui = new GUI();
gui.add(strength, "value", 0, 10, 0.1).name("Explosion strength");
gui.add(settings, "radius", 0.1, 1, 0.05).name("Explosion radius");
gui.add(gravity, "value", 0, 20, 0.1).name("Gravity");
gui.add({ reset }, "reset").name("Reset holes");
renderer.setAnimationLoop((time) => {
controls.update();
animationTime.value = time / 1000;
renderer.render(scene, camera);
});