main.ts
examples/spark/splat-flow/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
// Spark's splat-flow: three scans take turns, each flowing out of the
// previous one's center of mass, over a GLB environment, with the camera
// flying from one to the next. Spark gives each mesh its own world modifier
// with its object index baked in; here one generator (js/worldEffect.js)
// sets each object's index and world matrices in its bind.
import { GUI } from "lil-gui";
import * as THREE from "three";
import { Splats, TSL, type TslNode } from "@sparkjsdev/radix";
import { asset, loadGlb } from "../js/assets.js";
import { SplatSet } from "../js/objects.js";
import { examplePage, windowRenderer } from "../js/page.js";
import { loadSplatFile } from "../js/files.js";
import { effectModel, worldEffectGenerator } from "../js/worldEffect.js";
const { abs, float, floor, fract, length, mix, pow, select, sin, smoothstep, uniform, uniformArray, vec3 } = TSL;
const FILES = ["woobles.spz", "dessert.spz", "robot-head.spz"];
const POSITIONS = [new THREE.Vector3(-5, -2.2, -3), new THREE.Vector3(5, -2.5, 0), new THREE.Vector3(0, 1.5, 2)];
const PAUSE_SECONDS = 2;
const CYCLE = 1 + PAUSE_SECONDS;
const TOTAL = CYCLE * FILES.length;
const params = { speedMultiplier: 1, objectRotation: true, pause: false, fixedMinScale: false, cameraRotation: true };
const page = examplePage("splat-flow");
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.01, 1000);
// 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, { directCanvas: false, pixelRatio: devicePixelRatio });
const time = uniform(0);
const fixedMinScale = uniform(0);
const waves = uniform(0.5);
const centersOfMass = FILES.map(() => new THREE.Vector3());
const centers = uniformArray(centersOfMass, "vec3");
// Per object, set by bind
const nextIndex = uniform(0, "int");
const hash11 = (x: TslNode) => {
const p = fract(x.mul(0.1031)).toVar();
const q = p.add(p.mul(p.add(33.33)));
return fract(q.mul(q));
};
const generator = worldEffectGenerator((s, objectIndex) => {
const wrapT = time.sub(floor(time.div(TOTAL)).mul(TOTAL));
const pos = wrapT.sub(floor(wrapT.div(CYCLE)).mul(CYCLE));
const t = select(pos.greaterThan(1), float(1), pos).toVar();
const f = abs(t.mul(2).sub(1)).toVar();
const center = s.get("center").toVar();
const k = pow(f, hash11(float(s.get("index"))).mul(2).add(0.5));
const p = select(t.lessThan(0.5), mix(centers.element(nextIndex), center, k), mix(centers.element(objectIndex), center, k)).toVar();
s.get("center").assign(p.add(length(sin(p.mul(2.5))).mul(waves).mul(f.oneMinus()).mul(smoothstep(0, 0.5, t).oneMinus()).mul(2)));
const scales = s.get("scales").toVar();
s.get("scales").assign(mix(select(fixedMinScale.greaterThan(0.5), vec3(0.02), scales.mul(0.2)), scales, pow(f, 3)));
const w = time.add(PAUSE_SECONDS + 0.5).toVar();
const current = floor(w.sub(floor(w.div(TOTAL)).mul(TOTAL)).div(CYCLE));
s.get("opacity").assign(s.get("opacity").mul(select(current.equal(float(objectIndex)), f.add(0.1), float(0))));
s.get("rgb").assign(s.get("rgb").mul(f.mul(0.5).add(0.5)));
return s;
}, { perObject: (data) => (nextIndex.value = (data.index + 1) % FILES.length) });
scene.add(await loadGlb("dali-env.glb", { unlit: "env" }));
const splats = new SplatSet({ renderer, scene, animate: true });
const scans = await Promise.all(FILES.map((file) => loadSplatFile(renderer, asset(file), { onProgress: page.progress(file) })));
const models = scans.map(({ data, arrays }, i) => {
const model = effectModel(generator, arrays, i);
model.position.copy(POSITIONS[i]);
model.rotateX(Math.PI);
splats.add(model);
// The center of its bounds, as Spark computes it (only y flipped)
const { bundle } = Splats.get(data);
const box = new THREE.Box3();
const c = new THREE.Vector3();
for (let j = 0; j < data.count; j += 1) {
box.expandByPoint(Splats.loadCenter(bundle, j, c));
}
box.getCenter(c);
centersOfMass[i].set(c.x, -c.y, c.z).add(model.position);
return model;
});
const gui = new GUI();
gui.add(params, "speedMultiplier", 0.25, 4, 0.01);
gui.add(params, "objectRotation");
gui.add(params, "pause");
gui.add(params, "cameraRotation");
gui.add(params, "fixedMinScale").onChange((value: boolean) => (fixedMinScale.value = value ? 1 : 0));
gui.add(waves, "value", 0, 1, 0.01).name("waves");
page.ready(renderer);
// From one scan to the next, circling it
function updateCamera() {
const w = (time.value + PAUSE_SECONDS) % TOTAL;
const cur = Math.floor(w / CYCLE);
const tr = w / CYCLE - cur;
const s = tr * tr * (3 - 2 * tr);
const target = new THREE.Vector3().lerpVectors(models[cur].position, models[(cur + 1) % models.length].position, s ** 5);
const radius = 4 + Math.abs(s - 0.5) ** 2 * 20;
const angle = -time.value * 0.5;
const offset = params.cameraRotation ? new THREE.Vector3(Math.cos(angle) * radius, 2, Math.sin(angle) * radius) : new THREE.Vector3(0, 2, -radius);
camera.position.copy(target).add(offset);
camera.lookAt(target);
}
let last = 0;
renderer.setAnimationLoop((now) => {
// Spark's clock runs at half speed
const dt = last ? Math.min((now - last) / 1000, 0.1) * 0.5 : 0;
last = now;
updateCamera();
if (!params.pause) {
time.value += dt * params.speedMultiplier;
if (params.objectRotation) {
models.forEach((model) => (model.rotation.y += dt * params.speedMultiplier * 2));
}
}
renderer.render(scene, camera);
});