diff --git a/effects/effects.json b/effects/effects.json
new file mode 100644
index 00000000..7e387ae9
--- /dev/null
+++ b/effects/effects.json
@@ -0,0 +1,63 @@
+[
+ { "title": "Spark", "path": "", "thumb_filename": "spark.png" },
+ {
+ "title": "Reveals",
+ "path": "splat-reveal",
+ "thumb_filename": "splat-reveal.jpg"
+ },
+ {
+ "title": "Transitions",
+ "path": "splat-transitions",
+ "thumb_filename": "splat-transitions.jpg"
+ },
+ {
+ "title": "GLSL Effects",
+ "path": "splat-shader",
+ "thumb_filename": "splat-shader.jpg"
+ },
+ {
+ "title": "Matrix",
+ "path": "splat-matrix",
+ "thumb_filename": "splat-matrix.jpg"
+ },
+ {
+ "title": "Portal",
+ "path": "splat-portal",
+ "thumb_filename": "splat-portal.jpg"
+ },
+ {
+ "title": "Shatter",
+ "path": "splat-shatter",
+ "thumb_filename": "splat-shatter.jpg"
+ },
+ {
+ "title": "Disintegration",
+ "path": "splat-disintegration",
+ "thumb_filename": "splat-disintegration.jpg"
+ },
+ {
+ "title": "Splat Ninja demo",
+ "path": "splat-ninja",
+ "thumb_filename": "splat-ninja.jpg"
+ },
+ {
+ "title": "Interactive Ripples",
+ "path": "interactive-ripples",
+ "thumb_filename": "interactive-ripples.jpg"
+ },
+ {
+ "title": "Interactive Deform",
+ "path": "interactive-deform",
+ "thumb_filename": "interactive-deform.jpg"
+ },
+ {
+ "title": "Interactive Holes",
+ "path": "interactive-holes",
+ "thumb_filename": "interactive-holes.jpg"
+ },
+ {
+ "title": "Interactive Assembly",
+ "path": "interactive-assembly",
+ "thumb_filename": "interactive-assembly.jpg"
+ }
+]
diff --git a/effects/index.html b/effects/index.html
new file mode 100644
index 00000000..13b95c49
--- /dev/null
+++ b/effects/index.html
@@ -0,0 +1,396 @@
+
+
+
+
+
+ Spark • Effects
+
+
+
+
+
+
+
+
+
+
diff --git a/effects/interactive-assembly/index.html b/effects/interactive-assembly/index.html
new file mode 100644
index 00000000..040c0767
--- /dev/null
+++ b/effects/interactive-assembly/index.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+ Spark • Splat Experiment
+
+
+
+
+ WASD to move · Drag mouse to look around
+
+
+
+
+
diff --git a/effects/interactive-assembly/main.js b/effects/interactive-assembly/main.js
new file mode 100644
index 00000000..4eb1460f
--- /dev/null
+++ b/effects/interactive-assembly/main.js
@@ -0,0 +1,256 @@
+import { SplatMesh, dyno } from "@sparkjsdev/spark";
+import * as THREE from "three";
+import { getAssetFileURL } from "/examples/js/get-asset-url.js";
+
+const scene = new THREE.Scene();
+const camera = new THREE.PerspectiveCamera(
+ 100,
+ window.innerWidth / window.innerHeight,
+ 0.1,
+ 1000,
+);
+camera.position.set(0, 0, 0);
+camera.lookAt(0, 0, 0);
+
+const renderer = new THREE.WebGLRenderer();
+renderer.setSize(window.innerWidth, window.innerHeight);
+document.body.appendChild(renderer.domElement);
+
+window.addEventListener("resize", onWindowResize, false);
+function onWindowResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+}
+
+// Load painted bedroom splat
+const splatURL = await getAssetFileURL("painted-bedroom.spz");
+const bedroom = new SplatMesh({ url: splatURL });
+bedroom.quaternion.set(1, 0, 0, 0);
+bedroom.position.set(0, 0, 0);
+scene.add(bedroom);
+
+// Camera position for shader (will be updated in animation loop)
+const cameraPos = dyno.dynoVec3(new THREE.Vector3(0, 0, 0));
+const assemblyRadius = dyno.dynoFloat(7); // Radius within which blocks assemble
+
+// Setup dynoshader for distance-based block separation
+bedroom.objectModifier = dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const d = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ cameraPos: "vec3",
+ assemblyRadius: "float",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ globals: () => [
+ dyno.unindent(`
+ // Scalar hash function
+ float hashF(vec3 p) {
+ return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
+ }
+
+ // Vector hash using scalar hash
+ vec3 hash3(vec3 p) {
+ return vec3(
+ hashF(p),
+ hashF(p + vec3(1.0, 0.0, 0.0)),
+ hashF(p + vec3(0.0, 1.0, 0.0))
+ );
+ }
+
+ // 2D rotation matrix
+ mat2 rot(float a) {
+ float s=sin(a),c=cos(a);
+ return mat2(c,-s,s,c);
+ }
+ `),
+ ],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+
+ vec3 localPos = ${inputs.gsplat}.center;
+ vec3 scales = ${inputs.gsplat}.scales;
+
+ // Grid cell calculation
+ float gridSize = .25;
+ vec3 cellIndex = floor(localPos / gridSize);
+ vec3 cellHash = hash3(cellIndex);
+
+ // Calculate cell center position
+ vec3 cellCenter = cellIndex * gridSize + gridSize * 0.5;
+
+ // Calculate distance from camera to cell AABB (in object space)
+ vec3 cellMin = cellIndex * gridSize;
+ vec3 cellMax = cellMin + vec3(gridSize);
+ vec3 closestPoint = clamp(${inputs.cameraPos}, cellMin, cellMax);
+ float distToCamera = length(closestPoint - ${inputs.cameraPos});
+
+ // Calculate separation factor based on distance
+ // When far: separation = 1.0 (fully disassembled)
+ // When near: separation = 0.0 (fully assembled)
+ // Smooth transition between innerRadius and outerRadius
+ float innerRadius = ${inputs.assemblyRadius} * 0.4;
+ float outerRadius = ${inputs.assemblyRadius};
+ float separation = smoothstep(innerRadius, outerRadius, distToCamera);
+
+ // Randomized offset per cell
+ vec3 cellOffset = (cellHash - 0.5) * gridSize * 10. * separation;
+
+ vec3 finalCellCenter = cellCenter + cellOffset;
+ vec3 cellLocalPos = localPos - (cellIndex * gridSize + gridSize * 0.5);
+
+ // Cube rotation per cell (more rotation when separated)
+ float randomOffset = length(cellHash) * 2.0;
+ float rotAngle = separation * randomOffset * 2.0;
+
+ cellLocalPos.xy *= rot(rotAngle * 0.7);
+ cellLocalPos.xz *= rot(rotAngle * 0.5);
+
+ // Displacement from cell center (more displacement when separated)
+ vec3 displacement = cellOffset * separation * 10.;
+
+ ${outputs.gsplat}.center = finalCellCenter + cellLocalPos + displacement;
+
+ // Scale animation (smaller when far, normal when near)
+ float scaleFactor = 1.0 - separation;
+ ${outputs.gsplat}.scales = scales * scaleFactor;
+ `),
+ });
+
+ gsplat = d.apply({
+ gsplat,
+ cameraPos: cameraPos,
+ assemblyRadius: assemblyRadius,
+ }).gsplat;
+
+ return { gsplat };
+ },
+);
+
+// Update generator to apply modifier
+bedroom.updateGenerator();
+
+// Camera rotation (pitch and yaw)
+let pitch = 0;
+let yaw = 0;
+const mouseSensitivity = 0.002;
+
+// Mouse controls for camera rotation
+let isMouseDown = false;
+let lastMouseX = 0;
+let lastMouseY = 0;
+
+renderer.domElement.addEventListener("mousedown", (event) => {
+ isMouseDown = true;
+ lastMouseX = event.clientX;
+ lastMouseY = event.clientY;
+ renderer.domElement.requestPointerLock().catch(() => {
+ // Pointer lock not available, continue with regular mouse tracking
+ });
+});
+
+document.addEventListener("mouseup", () => {
+ isMouseDown = false;
+ if (document.pointerLockElement === renderer.domElement) {
+ document.exitPointerLock();
+ }
+});
+
+// Handle pointer lock change
+document.addEventListener("pointerlockchange", () => {
+ if (document.pointerLockElement !== renderer.domElement) {
+ isMouseDown = false;
+ }
+});
+
+// Handle mouse movement with pointer lock
+document.addEventListener("mousemove", (event) => {
+ if (document.pointerLockElement === renderer.domElement) {
+ // Pointer is locked, use movementX/Y
+ yaw -= event.movementX * mouseSensitivity;
+ pitch -= event.movementY * mouseSensitivity;
+
+ // Limit pitch to avoid gimbal lock
+ pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, pitch));
+ } else if (isMouseDown) {
+ // Pointer not locked, calculate delta manually
+ const deltaX = event.clientX - lastMouseX;
+ const deltaY = event.clientY - lastMouseY;
+
+ yaw -= deltaX * mouseSensitivity;
+ pitch -= deltaY * mouseSensitivity;
+
+ // Limit pitch to avoid gimbal lock
+ pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, pitch));
+
+ lastMouseX = event.clientX;
+ lastMouseY = event.clientY;
+ }
+});
+
+// Keyboard controls for WASD movement
+const keys = {
+ w: false,
+ a: false,
+ s: false,
+ d: false,
+};
+
+const moveSpeed = 0.01;
+
+window.addEventListener("keydown", (event) => {
+ const key = event.key.toLowerCase();
+ if (key === "w") keys.w = true;
+ if (key === "a") keys.a = true;
+ if (key === "s") keys.s = true;
+ if (key === "d") keys.d = true;
+});
+
+window.addEventListener("keyup", (event) => {
+ const key = event.key.toLowerCase();
+ if (key === "w") keys.w = false;
+ if (key === "a") keys.a = false;
+ if (key === "s") keys.s = false;
+ if (key === "d") keys.d = false;
+});
+
+renderer.setAnimationLoop(function animate(time) {
+ // Update camera rotation based on pitch and yaw
+ const euler = new THREE.Euler(pitch, yaw, 0, "YXZ");
+ camera.quaternion.setFromEuler(euler);
+
+ // WASD movement relative to camera orientation
+ const direction = new THREE.Vector3();
+ const right = new THREE.Vector3();
+
+ camera.getWorldDirection(direction);
+ right.crossVectors(direction, camera.up).normalize();
+
+ if (keys.w) {
+ camera.position.addScaledVector(direction, moveSpeed);
+ }
+ if (keys.s) {
+ camera.position.addScaledVector(direction, -moveSpeed);
+ }
+ if (keys.a) {
+ camera.position.addScaledVector(right, -moveSpeed);
+ }
+ if (keys.d) {
+ camera.position.addScaledVector(right, moveSpeed);
+ }
+
+ // Update camera position in shader (transform to object space)
+ const worldCameraPos = camera.position.clone();
+ const objectCameraPos = bedroom.worldToLocal(worldCameraPos);
+ cameraPos.value = objectCameraPos;
+
+ // Update splat mesh to apply shader changes
+ bedroom.updateVersion();
+
+ renderer.render(scene, camera);
+});
diff --git a/effects/interactive-deform/index.html b/effects/interactive-deform/index.html
new file mode 100644
index 00000000..4845ad02
--- /dev/null
+++ b/effects/interactive-deform/index.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+ Spark • Splat Experiment
+
+
+
+
+ Click and drag on the penguin to deform it • Release to see elastic bounce • A/D to rotate • W/S to zoom • Adjust parameters with GUI controls
+
+
+
+
+
+
diff --git a/effects/interactive-deform/main.js b/effects/interactive-deform/main.js
new file mode 100644
index 00000000..fa74c13c
--- /dev/null
+++ b/effects/interactive-deform/main.js
@@ -0,0 +1,322 @@
+import { SparkRenderer, SplatMesh, dyno } from "@sparkjsdev/spark";
+import { GUI } from "lil-gui";
+import * as THREE from "three";
+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.1,
+ 1000,
+);
+const renderer = new THREE.WebGLRenderer({ antialias: false });
+renderer.setSize(window.innerWidth, window.innerHeight);
+document.body.appendChild(renderer.domElement);
+
+const spark = new SparkRenderer({ renderer });
+scene.add(spark);
+
+window.addEventListener("resize", onWindowResize, false);
+function onWindowResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+}
+
+let rotationAngle = 0;
+let zoomDistance = 5.5;
+const minZoom = 1;
+const maxZoom = 20;
+const rotationSpeed = 0.02;
+const zoomSpeed = 0.1;
+
+camera.position.set(0, 3, zoomDistance);
+camera.lookAt(0, 1, 0);
+
+const keys = {};
+window.addEventListener("keydown", (event) => {
+ keys[event.key.toLowerCase()] = true;
+});
+window.addEventListener("keyup", (event) => {
+ keys[event.key.toLowerCase()] = false;
+});
+
+// Dyno uniforms for drag and bounce effects
+const dragPoint = dyno.dynoVec3(new THREE.Vector3(0, 0, 0));
+const dragDisplacement = dyno.dynoVec3(new THREE.Vector3(0, 0, 0));
+const dragRadius = dyno.dynoFloat(0.5);
+const dragActive = dyno.dynoFloat(0.0);
+const bounceTime = dyno.dynoFloat(0.0);
+const bounceBaseDisplacement = dyno.dynoVec3(new THREE.Vector3(0, 0, 0));
+const dragIntensity = dyno.dynoFloat(5.0);
+const bounceAmount = dyno.dynoFloat(0.5);
+const bounceSpeed = dyno.dynoFloat(0.5);
+let isBouncing = false;
+
+const gui = new GUI();
+const guiParams = {
+ intensity: dragIntensity.value,
+ radius: 0.5,
+ bounceAmount: 0.5,
+ bounceSpeed: 0.5,
+};
+gui
+ .add(guiParams, "intensity", 0, 10.0, 0.1)
+ .name("Deformation Strength")
+ .onChange((value) => {
+ dragIntensity.value = value;
+ if (splatMesh) {
+ splatMesh.updateVersion();
+ }
+ });
+gui
+ .add(guiParams, "radius", 0.25, 1.0, 0.1)
+ .name("Drag Radius")
+ .onChange((value) => {
+ dragRadius.value = value;
+ if (splatMesh) {
+ splatMesh.updateVersion();
+ }
+ });
+gui
+ .add(guiParams, "bounceAmount", 0, 1.0, 0.1)
+ .name("Bounce Strength")
+ .onChange((value) => {
+ bounceAmount.value = value;
+ if (splatMesh) {
+ splatMesh.updateVersion();
+ }
+ });
+gui
+ .add(guiParams, "bounceSpeed", 0, 1.0, 0.01)
+ .name("Bounce Speed")
+ .onChange((value) => {
+ bounceSpeed.value = value;
+ if (splatMesh) {
+ splatMesh.updateVersion();
+ }
+ });
+
+if (window.matchMedia("(max-width: 768px)").matches) {
+ gui.close();
+}
+
+let isDragging = false;
+let dragStartPoint = null;
+let currentDragPoint = null;
+const raycaster = new THREE.Raycaster();
+raycaster.params.Points = { threshold: 0.5 };
+
+function createDragBounceDynoshader() {
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const shader = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ dragPoint: "vec3",
+ dragDisplacement: "vec3",
+ dragRadius: "float",
+ dragActive: "float",
+ bounceTime: "float",
+ bounceBaseDisplacement: "vec3",
+ dragIntensity: "float",
+ bounceAmount: "float",
+ bounceSpeed: "float",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ vec3 originalPos = ${inputs.gsplat}.center;
+
+ // Calculate influence based on distance from drag point
+ float distToDrag = distance(originalPos, ${inputs.dragPoint});
+ float dragInfluence = 1.0 - smoothstep(0.0, ${inputs.dragRadius}*2., distToDrag);
+ float time = ${inputs.bounceTime};
+
+ // Apply drag deformation
+ if (${inputs.dragActive} > 0.5 && ${inputs.dragRadius} > 0.0) {
+ vec3 dragOffset = ${inputs.dragDisplacement} * dragInfluence * ${inputs.dragIntensity} * 50.0;
+ originalPos += dragOffset;
+ }
+
+ // Apply elastic bounce effect
+ float bounceFrequency = 1.0 + ${inputs.bounceSpeed} * 8.0;
+ vec3 bounceOffset = ${inputs.bounceBaseDisplacement} * dragInfluence * ${inputs.dragIntensity} * 50.0;
+ originalPos += bounceOffset * cos(time*bounceFrequency) * exp(-time*2.0*(1.0-${inputs.bounceAmount}*.9));
+
+ ${outputs.gsplat}.center = originalPos;
+ `),
+ });
+
+ return {
+ gsplat: shader.apply({
+ gsplat,
+ dragPoint: dragPoint,
+ dragDisplacement: dragDisplacement,
+ dragRadius: dragRadius,
+ dragActive: dragActive,
+ bounceTime: bounceTime,
+ bounceBaseDisplacement: bounceBaseDisplacement,
+ dragIntensity: dragIntensity,
+ bounceAmount: bounceAmount,
+ bounceSpeed: bounceSpeed,
+ }).gsplat,
+ };
+ },
+ );
+}
+
+let splatMesh = null;
+
+async function loadSplat() {
+ const splatURL = await getAssetFileURL("penguin.spz");
+ splatMesh = new SplatMesh({ url: splatURL });
+ splatMesh.quaternion.set(1, 0, 0, 0);
+ splatMesh.position.set(0, 0, 0);
+ scene.add(splatMesh);
+
+ await splatMesh.initialized;
+
+ splatMesh.worldModifier = createDragBounceDynoshader();
+ splatMesh.updateGenerator();
+}
+
+loadSplat().catch((error) => {
+ console.error("Error loading splat:", error);
+});
+
+// Convert mouse coordinates to normalized device coordinates
+function getMouseNDC(event) {
+ const rect = renderer.domElement.getBoundingClientRect();
+ return new THREE.Vector2(
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
+ -((event.clientY - rect.top) / rect.height) * 2 + 1,
+ );
+}
+
+// Raycast to find intersection point on splat
+function getHitPoint(ndc) {
+ if (!splatMesh) return null;
+ raycaster.setFromCamera(ndc, camera);
+ const hits = raycaster.intersectObject(splatMesh, false);
+ if (hits && hits.length > 0) {
+ return hits[0].point.clone();
+ }
+ return null;
+}
+
+let dragStartNDC = null;
+let dragScale = 1.0;
+
+renderer.domElement.addEventListener("pointerdown", (event) => {
+ if (!splatMesh) return;
+
+ const ndc = getMouseNDC(event);
+ const hitPoint = getHitPoint(ndc);
+
+ if (hitPoint) {
+ isDragging = true;
+ dragStartNDC = ndc.clone();
+ dragStartPoint = hitPoint.clone();
+ currentDragPoint = hitPoint.clone();
+
+ // Calculate scale factor for screen-to-world conversion
+ const distanceToCamera = camera.position.distanceTo(hitPoint);
+ const fov = camera.fov * (Math.PI / 180);
+ const screenHeight = 2.0 * Math.tan(fov / 2.0) * distanceToCamera;
+ dragScale = screenHeight / window.innerHeight;
+
+ dragPoint.value.copy(hitPoint);
+ dragActive.value = 1.0;
+ dragRadius.value = guiParams.radius;
+ dragDisplacement.value.set(0, 0, 0);
+
+ bounceTime.value = -1.0;
+ bounceBaseDisplacement.value.set(0, 0, 0);
+ isBouncing = false;
+ }
+});
+
+renderer.domElement.addEventListener("pointermove", (event) => {
+ if (!isDragging || !splatMesh || !dragStartPoint || !dragStartNDC) return;
+
+ const ndc = getMouseNDC(event);
+
+ // Convert screen space movement to world space
+ const mouseDelta = new THREE.Vector2(
+ (ndc.x - dragStartNDC.x) * dragScale,
+ (ndc.y - dragStartNDC.y) * dragScale,
+ );
+
+ const cameraRight = new THREE.Vector3();
+ const cameraUp = new THREE.Vector3();
+ camera.getWorldDirection(new THREE.Vector3());
+ cameraRight.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
+ cameraUp.setFromMatrixColumn(camera.matrixWorld, 1).normalize();
+
+ const worldDisplacement = new THREE.Vector3()
+ .addScaledVector(cameraRight, mouseDelta.x)
+ .addScaledVector(cameraUp, mouseDelta.y);
+
+ currentDragPoint = dragStartPoint.clone().add(worldDisplacement);
+ dragDisplacement.value.copy(worldDisplacement);
+});
+
+renderer.domElement.addEventListener("pointerup", (event) => {
+ if (!isDragging) return;
+
+ isDragging = false;
+
+ // Start bounce animation with final displacement
+ if (currentDragPoint && dragStartPoint) {
+ const finalDisplacement = currentDragPoint.clone().sub(dragStartPoint);
+ bounceBaseDisplacement.value.copy(dragDisplacement.value);
+ bounceTime.value = 0.0;
+ isBouncing = true;
+ }
+
+ dragActive.value = 0.0;
+ dragDisplacement.value.set(0, 0, 0);
+ dragStartNDC = null;
+});
+
+renderer.setAnimationLoop(() => {
+ // Update bounce animation
+ if (isBouncing) {
+ bounceTime.value += 0.1;
+ if (splatMesh) {
+ splatMesh.updateVersion();
+ }
+ }
+
+ // Keyboard controls
+ if (keys.a) {
+ rotationAngle -= rotationSpeed;
+ }
+ if (keys.d) {
+ rotationAngle += rotationSpeed;
+ }
+
+ if (keys.w) {
+ zoomDistance = Math.max(minZoom, zoomDistance - zoomSpeed);
+ }
+ if (keys.s) {
+ zoomDistance = Math.min(maxZoom, zoomDistance + zoomSpeed);
+ }
+
+ // Update camera orbit
+ camera.position.x = Math.sin(rotationAngle) * zoomDistance;
+ camera.position.z = Math.cos(rotationAngle) * zoomDistance;
+ camera.position.y = 3;
+ camera.lookAt(0, 1.5, 0);
+
+ if (splatMesh) {
+ splatMesh.updateVersion();
+ }
+
+ renderer.render(scene, camera);
+});
diff --git a/effects/interactive-holes/index.html b/effects/interactive-holes/index.html
new file mode 100644
index 00000000..f28f12de
--- /dev/null
+++ b/effects/interactive-holes/index.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+ Spark • Interactive Holes (Click Radius)
+
+
+
+
+ Mouse to look around • Click on a splat to create interactive holes within a radius
+
+
+
+
+
+
+
diff --git a/effects/interactive-holes/main.js b/effects/interactive-holes/main.js
new file mode 100644
index 00000000..36b120be
--- /dev/null
+++ b/effects/interactive-holes/main.js
@@ -0,0 +1,457 @@
+import {
+ SparkControls,
+ SplatEdit,
+ SplatEditRgbaBlendMode,
+ SplatEditSdf,
+ SplatEditSdfType,
+ SplatMesh,
+ dyno,
+} from "@sparkjsdev/spark";
+import { GUI } from "lil-gui";
+import * as THREE from "three";
+import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
+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
+const lastClickNDC = new THREE.Vector2(999, 999);
+const 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
+ const half = bits | ((e - 112) << 10) | (m >> 1);
+ return half;
+}
+function halfToFloat(h) {
+ const s = (h & 0x8000) >> 15;
+ const e = (h & 0x7c00) >> 10;
+ const f = h & 0x03ff;
+ if (e === 0) {
+ if (f === 0) return s ? -0 : 0;
+ // subnormal
+ return (s ? -1 : 1) * 2 ** -14 * (f / 1024);
+ }
+ if (e === 31) {
+ return f
+ ? Number.NaN
+ : s
+ ? Number.NEGATIVE_INFINITY
+ : Number.POSITIVE_INFINITY;
+ }
+ return (s ? -1 : 1) * 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?.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");
+
+if (window.matchMedia("(max-width: 768px)").matches) {
+ gui.close();
+}
diff --git a/effects/interactive-ripples/index.html b/effects/interactive-ripples/index.html
new file mode 100644
index 00000000..94fbb812
--- /dev/null
+++ b/effects/interactive-ripples/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ spark | splat-shockwave
+
+
+
+
+ Click to generate ripples • WASD + Mouse to move camera
+
+
+
diff --git a/effects/interactive-ripples/main.js b/effects/interactive-ripples/main.js
new file mode 100644
index 00000000..0dc848f2
--- /dev/null
+++ b/effects/interactive-ripples/main.js
@@ -0,0 +1,156 @@
+import {
+ SparkControls,
+ SparkRenderer,
+ SplatMesh,
+ dyno,
+} from "@sparkjsdev/spark";
+import * as THREE from "three";
+import { getAssetFileURL } from "/examples/js/get-asset-url.js";
+
+const canvas = document.getElementById("canvas");
+const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+renderer.setPixelRatio(window.devicePixelRatio);
+renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
+renderer.setClearColor(0x000000, 1);
+
+const scene = new THREE.Scene();
+const spark = new SparkRenderer({ renderer });
+scene.add(spark);
+
+const camera = new THREE.PerspectiveCamera(
+ 50,
+ canvas.clientWidth / canvas.clientHeight,
+ 0.01,
+ 2000,
+);
+camera.position.set(0, 0, 3);
+camera.lookAt(0, 0, 0);
+scene.add(camera);
+
+function handleResize() {
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ renderer.setSize(w, h, false);
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+}
+window.addEventListener("resize", handleResize);
+
+// Camera controls with mouse and WASD enabled
+const controls = new SparkControls({ canvas: renderer.domElement });
+controls.fpsMovement.enable = true; // Enable WASD movement
+controls.pointerControls.enable = true; // Enable mouse controls
+
+// Dyno shader with time and shockwave function
+function passthroughDyno(timeUniform, hitpointUniform) {
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const shader = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ time: "float",
+ hitpoint: "vec3",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ globals: () => [
+ dyno.unindent(`
+ vec3 shockwave(vec3 center, float t, vec3 hitpoint) {
+ vec3 direction = center - hitpoint;
+ float distance = length(direction);
+ center += normalize(direction)*sin(t*4.-distance*5.)*exp(-t)*smoothstep(t*2.,0.,distance)*.5;
+ return center;
+ }
+ vec4 shockwaveColor(vec4 rgba, vec3 center, float t, vec3 hitpoint) {
+ vec3 direction = center - hitpoint;
+ float distance = length(direction);
+ float wave = sin(t*4.-distance*5.)*exp(-t*.7)*smoothstep(t*2.,0.,distance);
+ float brightness = pow(abs(wave),3.) * 10.; // Increase brightness on wave crests
+ rgba.rgb += brightness;
+ return rgba;
+ }
+ `),
+ ],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ // Apply shockwave function to position
+ ${outputs.gsplat}.center = shockwave(${inputs.gsplat}.center, ${inputs.time}, ${inputs.hitpoint});
+ // Apply shockwave function to color
+ ${outputs.gsplat}.rgba = shockwaveColor(${inputs.gsplat}.rgba, ${inputs.gsplat}.center, ${inputs.time}, ${inputs.hitpoint});
+ `),
+ });
+ return {
+ gsplat: shader.apply({
+ gsplat,
+ time: timeUniform,
+ hitpoint: hitpointUniform,
+ }).gsplat,
+ };
+ },
+ );
+}
+
+async function run() {
+ // Time and hitpoint uniforms for dyno shader
+ const timeUniform = dyno.dynoFloat(0.0);
+ const hitpointUniform = dyno.dynoVec3(new THREE.Vector3(0, 0, 1000)); // Initialize far away to avoid initial effect
+
+ // Load valley.spz
+ const splatURL = await getAssetFileURL("valley.spz");
+ const valley = new SplatMesh({ url: splatURL });
+ await valley.initialized;
+
+ // Fix orientation - rotate 180 degrees around X axis
+ valley.rotateX(Math.PI);
+
+ // Apply dyno shader with time and hitpoint uniforms
+ valley.objectModifier = passthroughDyno(timeUniform, hitpointUniform);
+ valley.updateGenerator();
+
+ scene.add(valley);
+
+ // Raycaster for click detection
+ const raycaster = new THREE.Raycaster();
+ raycaster.params.Points = { threshold: 1.0 }; // Increased threshold for better hit detection
+
+ // Simple time counter that resets on click
+ let timeCounter = 0;
+
+ // Click event listener to set hitpoint and reset time
+ 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(valley, false);
+ const hit = hits?.length ? hits[0] : null;
+
+ if (!hit) {
+ return;
+ }
+
+ const localPoint = valley.worldToLocal(hit.point.clone());
+ // Don't invert Y or Z - keep original coordinates
+
+ hitpointUniform.value.copy(localPoint);
+ timeCounter = 0; // Reset time counter
+ });
+
+ renderer.setAnimationLoop((timeMs) => {
+ // Increment time counter each frame
+ timeCounter += 0.016; // ~60fps increment
+ timeUniform.value = timeCounter;
+
+ // Update dyno uniforms to propagate to the mesh each frame
+ valley.updateVersion();
+
+ controls.update(camera);
+ renderer.render(scene, camera);
+ });
+}
+
+run();
diff --git a/effects/splat-disintegration/index.html b/effects/splat-disintegration/index.html
new file mode 100644
index 00000000..dab5b200
--- /dev/null
+++ b/effects/splat-disintegration/index.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+ Spark • Splat Disintegration
+
+
+
+
+
+
+
+
+
diff --git a/effects/splat-matrix/index.html b/effects/splat-matrix/index.html
new file mode 100644
index 00000000..a499f627
--- /dev/null
+++ b/effects/splat-matrix/index.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+ Spark • Splat Matrix
+
+
+
+
+
+
+
+
+
+
+
diff --git a/effects/splat-matrix/main.js b/effects/splat-matrix/main.js
new file mode 100644
index 00000000..ed2cbcc3
--- /dev/null
+++ b/effects/splat-matrix/main.js
@@ -0,0 +1,178 @@
+import { SparkRenderer, SplatMesh, dyno } from "@sparkjsdev/spark";
+import { GUI } from "lil-gui";
+import * as THREE from "three";
+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.1,
+ 1000,
+);
+const renderer = new THREE.WebGLRenderer({ antialias: false });
+renderer.setSize(window.innerWidth, window.innerHeight);
+document.body.appendChild(renderer.domElement);
+
+const spark = new SparkRenderer({ renderer });
+scene.add(spark);
+
+window.addEventListener("resize", onWindowResize, false);
+function onWindowResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+}
+
+camera.position.set(0, 3, 5.5);
+camera.lookAt(0, 1, 0);
+camera.fov = 80;
+camera.updateProjectionMatrix();
+
+const keys = {};
+window.addEventListener("keydown", (event) => {
+ keys[event.key.toLowerCase()] = true;
+});
+window.addEventListener("keyup", (event) => {
+ keys[event.key.toLowerCase()] = false;
+});
+
+// Dyno uniforms
+const time = dyno.dynoFloat(0.0);
+const glowSpeed = dyno.dynoFloat(3.0);
+
+const gui = new GUI();
+const guiParams = {
+ glowSpeed: 3.0,
+};
+
+gui
+ .add(guiParams, "glowSpeed", 1.0, 10.0, 0.1)
+ .name("Glow Speed")
+ .onChange((value) => {
+ glowSpeed.value = value;
+ if (splatMesh) splatMesh.updateVersion();
+ });
+
+if (window.matchMedia("(max-width: 768px)").matches) {
+ gui.close();
+}
+
+function createMatrixDynoshader() {
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const shader = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ time: "float",
+ glowSpeed: "float",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ globals: () => [
+ dyno.unindent(`
+ mat2 rot(float a) {
+ a = radians(a);
+ float s = sin(a);
+ float c = cos(a);
+ mat2 m = mat2(c, -s, s, c);
+ return m;
+ }
+ float hash(float p) {
+ return fract(sin(p * 127.1) * 43758.5453);
+ }
+ float fractal(vec2 p, float t) {
+ float m = 100.;
+ float id = floor(min(abs(p.x),abs(p.y))*30.);
+ p.y+=2.+t*hash(id)*0.1;
+ float y = p.y;
+ p*=.1;
+ p=fract(p);
+ for (int i=0; i<7; i++) {
+ p = abs(p) / clamp((p.x * p.y), 0.5, 3.) - 1.;
+ if (i>1) m = min(m, abs(p.x)+step(fract(p.y*.5+t*.5+float(i)*.2),0.7)+step(fract(y*.2+t*.6+hash(id)*.3),0.5));
+ }
+ //m = step(m, 0.02);
+ m = exp(-m*50.)*1.5;
+ return m;
+ }
+ `),
+ ],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ vec3 p = ${inputs.gsplat}.center;
+ if (p.y+p.x > 12.-${inputs.time}*2.) {
+ vec4 col = ${inputs.gsplat}.rgba;
+ col.rgb = 1.-pow(col.rgb,vec3(2.))*1.5;
+ vec3 p2 = p;
+ p.xz*=rot(38.);
+ float f = fractal(p.xy, ${inputs.time})+fractal(p.zy,${inputs.time});
+ p2.y += sin(${inputs.time}*5.+p.x*10.+p.z*10.)*.005;
+ col.rgb *= .7;
+ col.rgb += f * (.5+length(col.rgb)*.5);
+ col.rgb *= vec3(.2,.8,0.);
+ ${outputs.gsplat}.rgba = vec4(col);
+ ${outputs.gsplat}.scales = vec3(.004)+f*.003;
+ ${outputs.gsplat}.center = p2;
+ }
+ `),
+ });
+
+ return {
+ gsplat: shader.apply({
+ gsplat,
+ time: time,
+ glowSpeed: glowSpeed,
+ }).gsplat,
+ };
+ },
+ );
+}
+
+let splatMesh = null;
+let isSplatLoaded = false;
+
+async function loadSplat() {
+ const splatURL = await getAssetFileURL("greyscale-bedroom.spz");
+ splatMesh = new SplatMesh({ url: splatURL });
+ splatMesh.rotation.set(Math.PI, Math.PI, 0);
+ splatMesh.position.set(0, 2.0, 2.0);
+ scene.add(splatMesh);
+
+ await splatMesh.initialized;
+
+ splatMesh.worldModifier = createMatrixDynoshader();
+ splatMesh.updateGenerator();
+ isSplatLoaded = true;
+}
+
+loadSplat().catch((error) => {
+ console.error("Error loading splat:", error);
+});
+
+let startTime = null;
+
+renderer.setAnimationLoop((tMs) => {
+ if (!isSplatLoaded) return;
+
+ if (startTime === null) startTime = tMs;
+ const t = (tMs - startTime) * 0.001;
+ time.value = t;
+
+ const camPos = new THREE.Vector3(0, 2, 5);
+ camera.position.copy(camPos);
+ // Rotate lookAt continuously 360 degrees around camera position
+ const lookAtRadius = 5.0;
+ const lookAtX = camPos.x + Math.sin(t * 0.2) * lookAtRadius;
+ const lookAtY = camPos.y;
+ const lookAtZ = camPos.z + Math.cos(t * 0.2) * lookAtRadius;
+ camera.lookAt(lookAtX, lookAtY, lookAtZ);
+
+ if (splatMesh) {
+ splatMesh.updateVersion();
+ }
+
+ renderer.render(scene, camera);
+});
diff --git a/effects/splat-ninja/index.html b/effects/splat-ninja/index.html
new file mode 100644
index 00000000..ab73dd09
--- /dev/null
+++ b/effects/splat-ninja/index.html
@@ -0,0 +1,692 @@
+
+
+
+
+
+
+ Spark • Splat Ninja
+
+
+
+
+
+
+
+
+
diff --git a/effects/splat-portal/index.html b/effects/splat-portal/index.html
new file mode 100644
index 00000000..86a24201
--- /dev/null
+++ b/effects/splat-portal/index.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+ spark | splat-portal
+
+
+
+
+ WASD + Mouse to move camera • Go through portal to switch between worlds
+
+
+
+
diff --git a/effects/splat-portal/main.js b/effects/splat-portal/main.js
new file mode 100644
index 00000000..d9717f22
--- /dev/null
+++ b/effects/splat-portal/main.js
@@ -0,0 +1,405 @@
+import { SparkControls, SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
+import * as THREE from "three";
+import { getAssetFileURL } from "/examples/js/get-asset-url.js";
+
+const canvas = document.getElementById("canvas");
+const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+renderer.setPixelRatio(window.devicePixelRatio);
+renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
+renderer.setClearColor(0x000000, 1);
+
+// Two independent scenes (world A and world B)
+const sceneA = new THREE.Scene();
+const sceneB = new THREE.Scene();
+const sparkA = new SparkRenderer({ renderer });
+const sparkB = new SparkRenderer({ renderer });
+sceneA.add(sparkA);
+sceneB.add(sparkB);
+
+// Main camera (not parented to scenes)
+const camera = new THREE.PerspectiveCamera(
+ 50,
+ canvas.clientWidth / canvas.clientHeight,
+ 0.01,
+ 2000,
+);
+camera.position.set(0, 1, 3);
+camera.lookAt(0, 1, 0);
+
+// Offscreen render targets for portal views
+const rtAtoB = new THREE.WebGLRenderTarget(
+ canvas.clientWidth,
+ canvas.clientHeight,
+ {
+ depthBuffer: true,
+ },
+);
+rtAtoB.texture.minFilter = THREE.LinearFilter;
+rtAtoB.texture.magFilter = THREE.LinearFilter;
+rtAtoB.texture.generateMipmaps = false;
+const rtBtoA = new THREE.WebGLRenderTarget(
+ canvas.clientWidth,
+ canvas.clientHeight,
+ {
+ depthBuffer: true,
+ },
+);
+rtBtoA.texture.minFilter = THREE.LinearFilter;
+rtBtoA.texture.magFilter = THREE.LinearFilter;
+rtBtoA.texture.generateMipmaps = false;
+
+function resizeRenderTargets(width, height) {
+ const dpr =
+ typeof renderer.getPixelRatio === "function"
+ ? renderer.getPixelRatio()
+ : window.devicePixelRatio || 1;
+ const w = Math.max(1, Math.floor(width * dpr));
+ const h = Math.max(1, Math.floor(height * dpr));
+ if (rtAtoB) {
+ rtAtoB.setSize(w, h);
+ }
+ if (rtBtoA) {
+ rtBtoA.setSize(w, h);
+ }
+}
+
+function handleResize() {
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ renderer.setSize(w, h, false);
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ resizeRenderTargets(w, h);
+}
+window.addEventListener("resize", handleResize);
+
+// Camera controls with mouse and WASD enabled
+const controls = new SparkControls({ canvas: renderer.domElement });
+controls.fpsMovement.enable = true;
+controls.pointerControls.enable = true;
+
+// Portal helpers
+function makePortalMaterial() {
+ const material = new THREE.ShaderMaterial({
+ uniforms: {
+ tMap: { value: null },
+ portalPV: { value: new THREE.Matrix4() },
+ portalBridge: { value: new THREE.Matrix4() },
+ worldMatrix: { value: new THREE.Matrix4() },
+ circleRadius: { value: 0.6 },
+ time: { value: 0.0 },
+ waveStrength: { value: 0.001 },
+ waveSpeed: { value: 10.0 },
+ waveFrequency: { value: 50.0 },
+ edgeSoftness: { value: 0.2 },
+ },
+ vertexShader: `
+ varying vec3 vWorldPosition;
+ varying vec2 vLocalXY;
+ void main() {
+ vec4 worldPos = modelMatrix * vec4(position, 1.0);
+ vWorldPosition = worldPos.xyz;
+ vLocalXY = position.xy;
+ gl_Position = projectionMatrix * viewMatrix * worldPos;
+ }
+ `,
+ fragmentShader: `
+ uniform sampler2D tMap;
+ uniform mat4 portalPV;
+ uniform mat4 portalBridge;
+ uniform float circleRadius;
+ uniform float time;
+ uniform float waveStrength;
+ uniform float waveSpeed;
+ uniform float waveFrequency;
+ uniform float edgeSoftness;
+ varying vec3 vWorldPosition;
+ varying vec2 vLocalXY;
+ void main() {
+ float r = length(vLocalXY);
+ if (r > circleRadius) discard;
+
+ // Generate radial waves
+ float wave = sin(r * waveFrequency - time * waveSpeed);
+ float distortion = wave * waveStrength;
+
+ // Deform UV radially based on wave
+ vec2 direction = normalize(vLocalXY);
+ vec2 offset = direction * distortion;
+
+ vec4 targetPos = portalBridge * vec4(vWorldPosition, 1.0);
+ vec4 clip = portalPV * targetPos;
+ vec3 ndc = clip.xyz / max(clip.w, 1e-6);
+ vec2 uv = ndc.xy * 0.5 + 0.5;
+
+ // Apply wave distortion to UV
+ uv += offset;
+
+ // Clamp UVs instead of discarding to avoid black edges
+ uv = clamp(uv, vec2(0.0), vec2(1.0));
+ vec4 color = texture2D(tMap, uv);
+ color+=offset.x*50.;
+
+ // Edge fadeout by reducing brightness instead of alpha
+ float edgeFade = 1.0 - smoothstep(circleRadius * (1.0 - edgeSoftness), circleRadius, r);
+ color.rgb *= .1+edgeFade;
+
+ gl_FragColor = color;
+ }
+ `,
+ side: THREE.DoubleSide,
+ transparent: false,
+ depthWrite: true,
+ depthTest: true,
+ });
+ return material;
+}
+
+function makePortal(radius) {
+ const geom = new THREE.CircleGeometry(radius, 64);
+ const mat = makePortalMaterial();
+ const mesh = new THREE.Mesh(geom, mat);
+ mesh.renderOrder = 1000;
+ return mesh;
+}
+
+function buildPVMatrix(cameraObj) {
+ const pv = new THREE.Matrix4();
+ pv.multiplyMatrices(cameraObj.projectionMatrix, cameraObj.matrixWorldInverse);
+ return pv;
+}
+
+function buildPortalBridgeMatrix(sourcePortal, targetPortal) {
+ // Bridge = targetPortal * Ry(PI) * inverse(sourcePortal)
+ const invSrc = new THREE.Matrix4().copy(sourcePortal.matrixWorld).invert();
+ const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
+ const tmp = new THREE.Matrix4().multiplyMatrices(rotY180, invSrc);
+ const bridge = new THREE.Matrix4().multiplyMatrices(
+ targetPortal.matrixWorld,
+ tmp,
+ );
+ return bridge;
+}
+
+function computeLinkedCamera(
+ sourcePortal,
+ targetPortal,
+ fromCamera,
+ outCamera,
+ clampRadius = 0,
+) {
+ // outCameraWorld = targetPortal * R_y(PI) * inverse(sourcePortal) * fromCameraWorld
+ const invSrc = new THREE.Matrix4().copy(sourcePortal.matrixWorld).invert();
+ const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
+ const tmp = new THREE.Matrix4().multiplyMatrices(
+ invSrc,
+ fromCamera.matrixWorld,
+ );
+ const withRot = new THREE.Matrix4().multiplyMatrices(rotY180, tmp);
+ const dst = new THREE.Matrix4().multiplyMatrices(
+ targetPortal.matrixWorld,
+ withRot,
+ );
+
+ outCamera.matrixWorld.copy(dst);
+ outCamera.matrixWorld.decompose(
+ outCamera.position,
+ outCamera.quaternion,
+ outCamera.scale,
+ );
+
+ // Clamp camera position if radius is specified
+ if (clampRadius > 0) {
+ const targetPos = new THREE.Vector3();
+ targetPortal.getWorldPosition(targetPos);
+
+ const toCam = outCamera.position.clone().sub(targetPos);
+ const distance = toCam.length();
+
+ if (distance > clampRadius) {
+ toCam.normalize().multiplyScalar(clampRadius);
+ outCamera.position.copy(targetPos).add(toCam);
+ outCamera.updateMatrixWorld(true);
+ }
+ }
+
+ outCamera.projectionMatrix.copy(fromCamera.projectionMatrix);
+ outCamera.updateMatrixWorld(true);
+}
+
+function transformPoseThroughPortal(sourcePortal, targetPortal, object3D) {
+ const srcWorld = sourcePortal.matrixWorld;
+ const dstWorld = targetPortal.matrixWorld;
+ const invSrc = new THREE.Matrix4().copy(srcWorld).invert();
+ const localMat = new THREE.Matrix4().multiplyMatrices(
+ invSrc,
+ object3D.matrixWorld,
+ );
+ const rotY180 = new THREE.Matrix4().makeRotationY(Math.PI);
+ const dstMat = new THREE.Matrix4().multiplyMatrices(
+ dstWorld,
+ new THREE.Matrix4().multiplyMatrices(rotY180, localMat),
+ );
+ object3D.matrixWorld.copy(dstMat);
+ object3D.matrixWorld.decompose(
+ object3D.position,
+ object3D.quaternion,
+ object3D.scale,
+ );
+}
+
+async function run() {
+ // Load valley (world A)
+ const valleyURL = await getAssetFileURL("valley.spz");
+ const valley = new SplatMesh({ url: valleyURL });
+ await valley.initialized;
+ valley.rotateX(Math.PI);
+ sceneA.add(valley);
+
+ // Load sutro (world B)
+ const sutroURL = await getAssetFileURL("sutro.zip");
+ const sutro = new SplatMesh({ url: sutroURL });
+ await sutro.initialized;
+ sutro.rotateX(Math.PI); // Fix orientation
+ sutro.position.set(0, 0, 0);
+ sutro.scale.set(3.5, 3.5, 3.5);
+ sceneB.add(sutro);
+
+ // Portals in each world
+ const portalRadius = 0.6;
+ const portalA = makePortal(portalRadius);
+ portalA.position.set(0, 1, -2);
+ portalA.rotation.set(0, 0, 0);
+ sceneA.add(portalA);
+
+ const portalB = makePortal(portalRadius);
+ portalB.position.set(-1, 1, -5);
+ portalB.rotation.set(0, Math.PI, 0);
+ sceneB.add(portalB);
+
+ portalA.updateMatrixWorld(true);
+ portalB.updateMatrixWorld(true);
+
+ // Offscreen cameras
+ const camAtoB = new THREE.PerspectiveCamera();
+ const camBtoA = new THREE.PerspectiveCamera();
+
+ // Teleportation tracking
+ let activeWorld = "A";
+ let lastPortalSide = -1; // -1 = behind portal, 1 = in front
+ let teleportCooldown = 0;
+ const TELEPORT_COOLDOWN_MS = 500; // 500ms cooldown
+ const CROSSING_THRESHOLD = 0.3; // Distance threshold for crossing detection
+
+ function getDistanceFromPortal(portal, pointWorld) {
+ const local = pointWorld.clone();
+ portal.worldToLocal(local);
+ return local.z; // positive = in front, negative = behind
+ }
+
+ function withinRadius(portal, pointWorld, radius) {
+ const local = pointWorld.clone();
+ portal.worldToLocal(local);
+ const d = Math.hypot(local.x, local.y);
+ return d <= radius;
+ }
+
+ renderer.setAnimationLoop((timeMs) => {
+ // Decrease cooldown timer
+ if (teleportCooldown > 0) {
+ teleportCooldown -= 16; // assuming ~60fps
+ }
+
+ // Controls and camera updates
+ controls.update(camera);
+ camera.updateMatrixWorld(true);
+
+ // Update time for wave animation
+ const time = timeMs * 0.001; // Convert to seconds
+ portalA.material.uniforms.time.value = time;
+ portalB.material.uniforms.time.value = time;
+
+ // Prepare portal cameras
+ portalA.updateMatrixWorld(true);
+ portalB.updateMatrixWorld(true);
+
+ computeLinkedCamera(portalA, portalB, camera, camAtoB, 3);
+ computeLinkedCamera(portalB, portalA, camera, camBtoA, 3);
+
+ // Render other worlds into targets (hide portals to avoid recursion/artifacts)
+ const prevAVisible = portalA.visible;
+ const prevBVisible = portalB.visible;
+ portalA.visible = false;
+ portalB.visible = false;
+
+ renderer.setRenderTarget(rtAtoB);
+ renderer.clear(true, true, true);
+ renderer.render(sceneB, camAtoB);
+ renderer.setRenderTarget(rtBtoA);
+ renderer.clear(true, true, true);
+ renderer.render(sceneA, camBtoA);
+ renderer.setRenderTarget(null);
+
+ portalA.visible = prevAVisible;
+ portalB.visible = prevBVisible;
+
+ // Update portal materials with PV matrices composed with portal bridges
+ const pvA = buildPVMatrix(camAtoB);
+ const pvB = buildPVMatrix(camBtoA);
+ const bridgeAtoB = buildPortalBridgeMatrix(portalA, portalB);
+ const bridgeBtoA = buildPortalBridgeMatrix(portalB, portalA);
+ const matA = portalA.material;
+ const matB = portalB.material;
+ matA.uniforms.tMap.value = rtAtoB.texture;
+ matA.uniforms.portalPV.value.copy(pvA);
+ matA.uniforms.portalBridge.value.copy(bridgeAtoB);
+ matB.uniforms.tMap.value = rtBtoA.texture;
+ matB.uniforms.portalPV.value.copy(pvB);
+ matB.uniforms.portalBridge.value.copy(bridgeBtoA);
+
+ // Teleport logic when crossing the active world's portal
+ if (teleportCooldown <= 0) {
+ const camPos = camera.position.clone();
+ const activePortal = activeWorld === "A" ? portalA : portalB;
+ const distance = getDistanceFromPortal(activePortal, camPos);
+ const currentSide = Math.sign(distance);
+ const absDistance = Math.abs(distance);
+
+ // Check if we're crossing the portal (from either side)
+ const isCrossing =
+ absDistance < CROSSING_THRESHOLD &&
+ withinRadius(activePortal, camPos, portalRadius * 1.2);
+
+ // Detect side change: going from front to back or back to front
+ const sideChanged =
+ lastPortalSide !== 0 && currentSide !== lastPortalSide;
+
+ if (isCrossing && (sideChanged || absDistance < 0.1)) {
+ const src = activeWorld === "A" ? portalA : portalB;
+ const dst = activeWorld === "A" ? portalB : portalA;
+
+ transformPoseThroughPortal(src, dst, camera);
+ // Calculate forward direction relative to the destination portal
+ const forward = new THREE.Vector3(0, 0, -1)
+ .applyQuaternion(camera.quaternion)
+ .multiplyScalar(0.2);
+ camera.position.add(forward);
+ camera.updateMatrixWorld(true);
+ activeWorld = activeWorld === "A" ? "B" : "A";
+ teleportCooldown = TELEPORT_COOLDOWN_MS;
+ lastPortalSide = currentSide; // Update side tracking
+ } else {
+ lastPortalSide = currentSide;
+ }
+ }
+
+ // Render active world to screen
+ if (activeWorld === "A") {
+ renderer.render(sceneA, camera);
+ } else {
+ renderer.render(sceneB, camera);
+ }
+ });
+}
+
+run();
diff --git a/effects/splat-reveal/index.html b/effects/splat-reveal/index.html
new file mode 100644
index 00000000..59554e84
--- /dev/null
+++ b/effects/splat-reveal/index.html
@@ -0,0 +1,446 @@
+
+
+
+
+
+
+ Spark • Splat Reveal Effects
+
+
+
+
+
+
+
+
+
diff --git a/effects/splat-shader/index.html b/effects/splat-shader/index.html
new file mode 100644
index 00000000..5dafa087
--- /dev/null
+++ b/effects/splat-shader/index.html
@@ -0,0 +1,246 @@
+
+
+
+
+
+ Spark • GLSL Shaders
+
+
+
+
+
+
+
+
diff --git a/effects/splat-shatter/index.html b/effects/splat-shatter/index.html
new file mode 100644
index 00000000..f408f261
--- /dev/null
+++ b/effects/splat-shatter/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ Spark • Splat Matrix
+
+
+
+
+ Click anywhere to shatter
+
+
+
+
+
+
diff --git a/effects/splat-shatter/main.js b/effects/splat-shatter/main.js
new file mode 100644
index 00000000..bc33bdbb
--- /dev/null
+++ b/effects/splat-shatter/main.js
@@ -0,0 +1,463 @@
+import { SparkRenderer, SplatMesh, dyno } from "@sparkjsdev/spark";
+import { GUI } from "lil-gui";
+import * as THREE from "three";
+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.1,
+ 1000,
+);
+const renderer = new THREE.WebGLRenderer({ antialias: false });
+renderer.setSize(window.innerWidth, window.innerHeight);
+document.body.appendChild(renderer.domElement);
+
+const spark = new SparkRenderer({ renderer });
+scene.add(spark);
+
+window.addEventListener("resize", onWindowResize, false);
+function onWindowResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+}
+
+camera.position.set(0, 3, 5.5);
+camera.lookAt(0, 1, 0);
+camera.fov = 80;
+camera.updateProjectionMatrix();
+
+const keys = {};
+window.addEventListener("keydown", (event) => {
+ keys[event.key.toLowerCase()] = true;
+});
+window.addEventListener("keyup", (event) => {
+ keys[event.key.toLowerCase()] = false;
+});
+
+const time = dyno.dynoFloat(0.0);
+const effectStarted = dyno.dynoFloat(0.0);
+const revealSpeed = dyno.dynoFloat(1.0);
+const voronoiScale = dyno.dynoFloat(3.0);
+const yBoundsMin = dyno.dynoFloat(0.0);
+const yBoundsMax = dyno.dynoFloat(1.0);
+const pieceYMin = dyno.dynoFloat(-32.0);
+const pieceYMax = dyno.dynoFloat(32.0);
+const objectCenter = dyno.dynoVec3(new THREE.Vector3(0, 0, 0));
+/** World-space Y of the floor used for landing and bounce (shader). */
+const FLOOR_Y = 0.6;
+const groundY = dyno.dynoFloat(FLOOR_Y);
+const flyStagger = dyno.dynoFloat(0.8);
+const flySpeed = dyno.dynoFloat(3.5);
+const flyFade = dyno.dynoFloat(0.0);
+const flyGap = dyno.dynoFloat(0.58);
+const flyBottomCutoff = dyno.dynoFloat(0.45);
+// When explosion starts: zero scales for oversized / very anisotropic splats (tune in code).
+const explodeCullMaxScale = dyno.dynoFloat(0.14);
+const explodeCullStretchRatio = dyno.dynoFloat(22.0);
+
+const gui = new GUI();
+const guiParams = {
+ revealSpeed: 0.5,
+ voronoiScale: 2.0,
+};
+
+function syncFractureGuiToUniforms() {
+ revealSpeed.value = guiParams.revealSpeed;
+ voronoiScale.value = guiParams.voronoiScale;
+ if (splatMesh) {
+ updatePieceLayerBounds(splatMesh);
+ splatMesh.updateVersion();
+ }
+}
+
+gui
+ .add(guiParams, "revealSpeed", 0.25, 1.5, 0.01)
+ .name("Reveal speed")
+ .onChange(syncFractureGuiToUniforms);
+gui
+ .add(guiParams, "voronoiScale", 1.0, 4.0, 0.1)
+ .name("Voronoi scale")
+ .onChange(syncFractureGuiToUniforms);
+
+let effectStartTime = null;
+/** Wall-clock animation time for camera orbit (runs before and after click). Effect uses `time` only after click. */
+let orbitStartMs = null;
+const hintEl = document.getElementById("breakHint");
+
+gui
+ .add(
+ {
+ resetTime() {
+ effectStarted.value = 0;
+ effectStartTime = null;
+ time.value = 0;
+ if (hintEl) hintEl.style.display = "";
+ if (splatMesh) splatMesh.updateVersion();
+ },
+ },
+ "resetTime",
+ )
+ .name("Reset");
+
+if (window.matchMedia("(max-width: 768px)").matches) {
+ gui.close();
+}
+
+function createMatrixDynoshader() {
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const shader = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ time: "float",
+ effectStarted: "float",
+ revealSpeed: "float",
+ voronoiScale: "float",
+ yBoundsMin: "float",
+ yBoundsMax: "float",
+ pieceYMin: "float",
+ pieceYMax: "float",
+ objectCenter: "vec3",
+ groundY: "float",
+ flyStagger: "float",
+ flySpeed: "float",
+ flyFade: "float",
+ flyGap: "float",
+ flyBottomCutoff: "float",
+ explodeCullMaxScale: "float",
+ explodeCullStretchRatio: "float",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ globals: () => [
+ dyno.unindent(`
+ mat2 rot(float a) {
+ a = radians(a);
+ float s = sin(a);
+ float c = cos(a);
+ mat2 m = mat2(c, -s, s, c);
+ return m;
+ }
+ vec3 hash3(vec3 p) {
+ p = fract(p * vec3(0.1031, 0.1030, 0.0973));
+ p += dot(p, p.yxz + 33.33);
+ return fract((p.xxy + p.yxx) * p.zyx);
+ }
+
+ float voronoi3DFractureLines(vec3 p) {
+ vec3 i = floor(p);
+ vec3 f = fract(p);
+ float f1 = 8.0;
+ float f2 = 8.0;
+ for (int zz = -1; zz <= 1; zz++) {
+ for (int yy = -1; yy <= 1; yy++) {
+ for (int xx = -1; xx <= 1; xx++) {
+ vec3 b = vec3(float(xx), float(yy), float(zz));
+ vec3 r = b + hash3(i + b) - f;
+ float d = length(r);
+ if (d < f1) {
+ f2 = f1;
+ f1 = d;
+ } else if (d < f2) {
+ f2 = d;
+ }
+ }
+ }
+ }
+ float edgeGap = f2 - f1;
+ float lineWidth = 0.05;
+ return 1.0 - step(lineWidth, edgeGap);
+ }
+
+ vec3 voronoiWinningCell(vec3 p) {
+ vec3 i = floor(p);
+ vec3 f = fract(p);
+ float f1 = 8.0;
+ vec3 bestB = vec3(0.0);
+ for (int zz = -1; zz <= 1; zz++) {
+ for (int yy = -1; yy <= 1; yy++) {
+ for (int xx = -1; xx <= 1; xx++) {
+ vec3 b = vec3(float(xx), float(yy), float(zz));
+ vec3 r = b + hash3(i + b) - f;
+ float d = length(r);
+ if (d < f1) {
+ f1 = d;
+ bestB = b;
+ }
+ }
+ }
+ }
+ return i + bestB;
+ }
+
+ float irregularEdge(vec2 xz) {
+ vec2 q = xz * 2.4;
+ float n =
+ sin(q.x * 1.9 + q.y * 2.3 + 0.7) * 0.55
+ + sin(q.x * -3.1 + q.y * 2.7) * 0.28
+ + sin(q.x * 6.2 + q.y * 5.1 + 1.3) * 0.14
+ + sin(q.x * 11.0 - q.y * 8.3) * 0.08;
+ n += (hash3(vec3(xz * 3.1, 1.7)).x - 0.5) * 0.35;
+ return n * 0.22;
+ }
+
+ vec4 quatAxisAngle(vec3 axis, float angle) {
+ vec3 nAxis = normalize(axis);
+ float halfAngle = angle * 0.5;
+ float s = sin(halfAngle);
+ return vec4(nAxis * s, cos(halfAngle));
+ }
+ `),
+ ],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ if (${inputs.effectStarted} > 0.5) {
+ vec3 p = ${inputs.gsplat}.center;
+ vec4 col = ${inputs.gsplat}.rgba;
+ vec3 scales = ${inputs.gsplat}.scales;
+ vec3 p2 = p;
+ vec3 pr = p;
+ pr.xz *= rot(38.);
+ vec3 pv = pr * ${inputs.voronoiScale};
+ vec3 winCell = voronoiWinningCell(pv);
+ float lines = voronoi3DFractureLines(pv);
+ float ySpan = max(${inputs.yBoundsMax} - ${inputs.yBoundsMin}, 1e-5);
+ float hNorm = clamp((p.y - ${inputs.yBoundsMin}) / ySpan, 0.0, 1.0);
+ float reveal = clamp(${inputs.time} * ${inputs.revealSpeed}, 0.0, 1.0);
+ float edge = irregularEdge(pr.xz);
+ float thresh = clamp(reveal + edge * (1.0 - reveal), 0.0, 1.0);
+ float fractureZone = 1.0 - step(thresh, hNorm);
+ col.rgb *= mix(1.0, 1.0 - lines * 0.8, fractureZone);
+
+ float rs = max(${inputs.revealSpeed}, 0.01);
+ float revealEndT = 1.0 / rs;
+ float canFly = step(revealEndT + ${inputs.flyGap}, ${inputs.time});
+ float tFly = max(0.0, ${inputs.time} - revealEndT - ${inputs.flyGap});
+ float pySpan = max(${inputs.pieceYMax} - ${inputs.pieceYMin}, 1.0);
+ float layerNorm = clamp((winCell.y - ${inputs.pieceYMin}) / pySpan, 0.0, 1.0);
+ float flyableSpan = max(1.0 - ${inputs.flyBottomCutoff}, 1e-5);
+ float flyLayerNorm = clamp((layerNorm - ${inputs.flyBottomCutoff}) / flyableSpan, 0.0, 1.0);
+ float canLiftOff = step(${inputs.flyBottomCutoff}, layerNorm);
+ float layerDelay = pow(1.0 - flyLayerNorm, 1.35) * ${inputs.flyStagger};
+ float randomDelay =
+ hash3(winCell * 1.31).x * 0.08
+ + hash3(winCell.zxy * 2.17).x * 0.04;
+ float flyDelay = max(layerDelay + randomDelay, 0.0);
+ float localFly = max(0.0, tFly - flyDelay);
+ float fullFracture = step(0.999, reveal);
+ float explodeStarted = fullFracture * canFly;
+ float phaseActive = explodeStarted * canLiftOff * step(flyDelay, tFly);
+ float maxAxis = max(scales.x, max(scales.y, scales.z));
+ float minAxis = min(scales.x, min(scales.y, scales.z));
+ float stretch = maxAxis / max(minAxis, 1e-7);
+ float cullOversized = max(
+ step(${inputs.explodeCullMaxScale}, maxAxis),
+ step(${inputs.explodeCullStretchRatio}, stretch)
+ );
+ scales *= mix(vec3(1.0), vec3(0.0), explodeStarted * cullOversized);
+
+ vec3 rnd = hash3(winCell + vec3(3.7, 1.1, 9.2));
+ float explodeAzimuth = hash3(winCell + vec3(9.1, 2.3, 5.7)).x * 6.2831853;
+ vec3 blastDir = vec3(cos(explodeAzimuth), 0.0, sin(explodeAzimuth));
+ float heightBoost = mix(0.5, 1.0, flyLayerNorm);
+ float burstSpeed = ${inputs.flySpeed} * heightBoost * (0.88 + rnd.x * 0.38);
+ vec3 launchVel = blastDir * burstSpeed;
+ launchVel.y += mix(0.15, 1.05, flyLayerNorm) + rnd.z * mix(0.1, 0.45, flyLayerNorm);
+ float gravity = 12.0;
+ vec3 airPos = p2 + launchVel * localFly;
+ airPos.y -= 0.5 * gravity * localFly * localFly;
+
+ float yToGround = max(p2.y - ${inputs.groundY}, 0.0);
+ float landT = (launchVel.y + sqrt(max(launchVel.y * launchVel.y + 2.0 * gravity * yToGround, 0.0))) / gravity;
+ float settleT = max(localFly - landT, 0.0);
+ vec3 landPos = p2 + launchVel * landT;
+ landPos.y = ${inputs.groundY};
+ vec2 slideVel = launchVel.xz * (0.4 + rnd.x * 0.18);
+ float slideDrag = 5.8 + rnd.y * 1.8;
+ vec2 slideOff = slideVel * (1.0 - exp(-settleT * slideDrag)) / slideDrag;
+ float bounceAmp = 0.18 + rnd.z * 0.16;
+ float bounce = exp(-settleT * 7.5) * sin(settleT * 18.0) * bounceAmp;
+
+ float landed = step(landT, localFly);
+ vec3 settledPos = vec3(landPos.x + slideOff.x, ${inputs.groundY} + max(bounce, 0.0), landPos.z + slideOff.y);
+ vec3 motionPos = mix(airPos, settledPos, landed);
+ float fade = exp(-settleT * ${inputs.flyFade});
+ col.a *= mix(1.0, fade, phaseActive);
+
+ float spinTravel = min(localFly, landT) * 1.35 + (1.0 - exp(-settleT * 6.5)) * 0.22;
+ float spinAngle = spinTravel * (5.0 + rnd.z * 7.0);
+ vec3 spinAxis = normalize(vec3(rnd.z - 0.5, 0.2 + rnd.x * 0.7, rnd.y - 0.5));
+ vec4 spinQ = quatAxisAngle(spinAxis, spinAngle);
+ float flatProgress = smoothstep(0.0, max(landT * 0.9, 1e-3), localFly);
+ vec4 flatQ = quatAxisAngle(vec3(1.0, 0.0, 0.0), -1.5707963 * flatProgress);
+
+ vec3 pOut = mix(p2, motionPos, phaseActive);
+ ${outputs.gsplat}.center = pOut;
+ ${outputs.gsplat}.rgba = vec4(col);
+ ${outputs.gsplat}.scales = scales;
+ }
+ `),
+ });
+
+ return {
+ gsplat: shader.apply({
+ gsplat,
+ time: time,
+ effectStarted: effectStarted,
+ revealSpeed: revealSpeed,
+ voronoiScale: voronoiScale,
+ yBoundsMin: yBoundsMin,
+ yBoundsMax: yBoundsMax,
+ pieceYMin: pieceYMin,
+ pieceYMax: pieceYMax,
+ objectCenter: objectCenter,
+ groundY: groundY,
+ flyStagger: flyStagger,
+ flySpeed: flySpeed,
+ flyFade: flyFade,
+ flyGap: flyGap,
+ flyBottomCutoff: flyBottomCutoff,
+ explodeCullMaxScale: explodeCullMaxScale,
+ explodeCullStretchRatio: explodeCullStretchRatio,
+ }).gsplat,
+ };
+ },
+ );
+}
+
+let splatMesh = null;
+let isSplatLoaded = false;
+
+const ROT_38 = THREE.MathUtils.degToRad(38);
+
+function worldToPvY(worldP, vScale) {
+ const pr = worldP.clone();
+ const c = Math.cos(ROT_38);
+ const s = Math.sin(ROT_38);
+ const nx = pr.x * c - pr.z * s;
+ const nz = pr.x * s + pr.z * c;
+ pr.x = nx;
+ pr.z = nz;
+ pr.multiplyScalar(vScale);
+ return pr.y;
+}
+
+function updateWorldYBoundsFromSplat(mesh) {
+ mesh.updateWorldMatrix(true, false);
+ const lb = mesh.getBoundingBox(true);
+ const center = new THREE.Vector3(
+ (lb.min.x + lb.max.x) * 0.5,
+ (lb.min.y + lb.max.y) * 0.5,
+ (lb.min.z + lb.max.z) * 0.5,
+ );
+ center.applyMatrix4(mesh.matrixWorld);
+ const corners = [
+ new THREE.Vector3(lb.min.x, lb.min.y, lb.min.z),
+ new THREE.Vector3(lb.max.x, lb.min.y, lb.min.z),
+ new THREE.Vector3(lb.min.x, lb.max.y, lb.min.z),
+ new THREE.Vector3(lb.max.x, lb.max.y, lb.min.z),
+ new THREE.Vector3(lb.min.x, lb.min.y, lb.max.z),
+ new THREE.Vector3(lb.max.x, lb.min.y, lb.max.z),
+ new THREE.Vector3(lb.min.x, lb.max.y, lb.max.z),
+ new THREE.Vector3(lb.max.x, lb.max.y, lb.max.z),
+ ];
+ let yMin = Number.POSITIVE_INFINITY;
+ let yMax = Number.NEGATIVE_INFINITY;
+ for (let i = 0; i < corners.length; i++) {
+ corners[i].applyMatrix4(mesh.matrixWorld);
+ if (corners[i].y < yMin) yMin = corners[i].y;
+ if (corners[i].y > yMax) yMax = corners[i].y;
+ }
+ const pad = (yMax - yMin) * 0.02 + 1e-4;
+ yBoundsMin.value = yMin - pad;
+ yBoundsMax.value = yMax + pad;
+ objectCenter.value.copy(center);
+}
+
+function updatePieceLayerBounds(mesh) {
+ mesh.updateWorldMatrix(true, false);
+ const lb = mesh.getBoundingBox(true);
+ const corners = [
+ new THREE.Vector3(lb.min.x, lb.min.y, lb.min.z),
+ new THREE.Vector3(lb.max.x, lb.min.y, lb.min.z),
+ new THREE.Vector3(lb.min.x, lb.max.y, lb.min.z),
+ new THREE.Vector3(lb.max.x, lb.max.y, lb.min.z),
+ new THREE.Vector3(lb.min.x, lb.min.y, lb.max.z),
+ new THREE.Vector3(lb.max.x, lb.min.y, lb.max.z),
+ new THREE.Vector3(lb.min.x, lb.max.y, lb.max.z),
+ new THREE.Vector3(lb.max.x, lb.max.y, lb.max.z),
+ ];
+ const vs = voronoiScale.value;
+ let pMin = Number.POSITIVE_INFINITY;
+ let pMax = Number.NEGATIVE_INFINITY;
+ for (let i = 0; i < corners.length; i++) {
+ corners[i].applyMatrix4(mesh.matrixWorld);
+ const py = worldToPvY(corners[i], vs);
+ const fl = Math.floor(py);
+ if (fl < pMin) pMin = fl;
+ if (fl > pMax) pMax = fl;
+ }
+ pieceYMin.value = pMin - 2;
+ pieceYMax.value = pMax + 2;
+}
+
+async function loadSplat() {
+ const splatURL = await getAssetFileURL("greyscale-bedroom.spz");
+ splatMesh = new SplatMesh({ url: splatURL });
+ splatMesh.rotation.set(Math.PI, Math.PI, 0);
+ splatMesh.position.set(0, 2.0, 2.0);
+ scene.add(splatMesh);
+
+ await splatMesh.initialized;
+
+ updateWorldYBoundsFromSplat(splatMesh);
+ updatePieceLayerBounds(splatMesh);
+ splatMesh.worldModifier = createMatrixDynoshader();
+ splatMesh.updateGenerator();
+ syncFractureGuiToUniforms();
+ isSplatLoaded = true;
+}
+
+renderer.domElement.addEventListener("pointerdown", () => {
+ if (effectStarted.value > 0.5) return;
+ effectStarted.value = 1;
+ effectStartTime = performance.now();
+ if (hintEl) hintEl.style.display = "none";
+ if (splatMesh) splatMesh.updateVersion();
+});
+
+loadSplat().catch((error) => {
+ console.error("Error loading splat:", error);
+});
+
+renderer.setAnimationLoop((tMs) => {
+ if (!isSplatLoaded) return;
+
+ if (orbitStartMs === null) orbitStartMs = tMs;
+ const orbitT = (tMs - orbitStartMs) * 0.001;
+
+ if (effectStarted.value < 0.5) {
+ time.value = 0;
+ } else if (effectStartTime !== null) {
+ time.value = (performance.now() - effectStartTime) * 0.001;
+ }
+
+ const camPos = new THREE.Vector3(0, 2, 5);
+ camera.position.copy(camPos);
+ const lookAtRadius = 5.0;
+ const lookAtX = camPos.x + Math.sin(orbitT * 0.2) * lookAtRadius;
+ const lookAtY = camPos.y;
+ const lookAtZ = camPos.z + Math.cos(orbitT * 0.2) * lookAtRadius;
+ camera.lookAt(lookAtX, lookAtY, lookAtZ);
+
+ if (splatMesh) {
+ splatMesh.updateVersion();
+ }
+
+ renderer.render(scene, camera);
+});
diff --git a/effects/splat-transitions/effects/explosion.js b/effects/splat-transitions/effects/explosion.js
new file mode 100644
index 00000000..8b137c9b
--- /dev/null
+++ b/effects/splat-transitions/effects/explosion.js
@@ -0,0 +1,414 @@
+import { SparkControls, SplatMesh, dyno, textSplats } from "@sparkjsdev/spark";
+import * as THREE from "three";
+import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
+import { getAssetFileURL } from "/examples/js/get-asset-url.js";
+
+export async function init({ THREE: _THREE, scene, camera, renderer, spark }) {
+ const group = new THREE.Group();
+ scene.add(group);
+ let disposed = false;
+
+ // Basic lights
+ const ambient = new THREE.AmbientLight(0x404040, 0.6);
+ group.add(ambient);
+ const dir = new THREE.DirectionalLight(0xffffff, 1.0);
+ dir.position.set(5, 10, 5);
+ dir.castShadow = true;
+ group.add(dir);
+
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+
+ // Camera baseline
+ camera.position.set(0, 2.5, 7);
+ camera.lookAt(0, 1, 0);
+
+ // WASD + mouse controls
+ const controls = new SparkControls({ canvas: renderer.domElement });
+ controls.fpsMovement.moveSpeed = 3.0;
+
+ // Uniforms
+ const animationTime = dyno.dynoFloat(0.0);
+ const uDropProgress = dyno.dynoFloat(0.0);
+ const uGravity = dyno.dynoFloat(9.8);
+ const uBounceDamping = dyno.dynoFloat(0.4);
+ const uFloorLevel = dyno.dynoFloat(0.0);
+ const uRandomFactor = dyno.dynoFloat(1.0);
+ const uReformSpeed = dyno.dynoFloat(2.0);
+ const uCycleDuration = dyno.dynoFloat(1.0);
+ const uDropTime = dyno.dynoFloat(0.0);
+ const uFriction = dyno.dynoFloat(0.98);
+ const uShrinkSpeed = dyno.dynoFloat(5.0 - 3.0);
+ const uExplosionStrength = dyno.dynoFloat(4.5);
+ const uIsReforming = dyno.dynoFloat(0.0);
+ const uReformTime = dyno.dynoFloat(0.0);
+ const uReformDuration = dyno.dynoFloat(2.0);
+
+ const uIsBirthing = dyno.dynoFloat(0.0);
+ const uBirthTime = dyno.dynoFloat(0.0);
+ const uBirthDuration = dyno.dynoFloat(0.5);
+
+ function createDeathDynoshader() {
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const physicsShader = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ time: "float",
+ dropTime: "float",
+ dropProgress: "float",
+ gravity: "float",
+ bounceDamping: "float",
+ floorLevel: "float",
+ randomFactor: "float",
+ reformSpeed: "float",
+ cycleDuration: "float",
+ friction: "float",
+ shrinkSpeed: "float",
+ explosionStrength: "float",
+ isReforming: "float",
+ reformTime: "float",
+ reformDuration: "float",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ globals: () => [
+ dyno.unindent(`
+ mat2 rot(float angle) { float c = cos(angle); float s = sin(angle); return mat2(c, -s, s, c); }
+ 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 progress, float gravity, float damping, float floorLevel, float randomOffset, float friction, float explosionStrength) {
+ if (progress <= 0.0) return originalPos;
+ 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;
+ }
+ vec3 elegantReform(vec3 currentPos, vec3 originalPos, float reformTime, float duration) {
+ if (reformTime <= 0.0) return currentPos;
+ if (reformTime >= duration) return originalPos;
+ float progress = reformTime / duration;
+ return mix(currentPos, originalPos, progress);
+ }
+ vec3 reformScale(vec3 currentScale, vec3 originalScale, float reformTime, float duration) {
+ if (reformTime <= 0.0) return currentScale;
+ if (reformTime >= duration) return originalScale;
+ float progress = reformTime / duration;
+ float easeOut = 1.0 - pow(1.0 - progress, 2.0);
+ return mix(currentScale, originalScale, easeOut);
+ }
+ `),
+ ],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ vec3 originalPos = ${inputs.gsplat}.center;
+ vec3 originalScale = ${inputs.gsplat}.scales;
+ vec3 physicsPos = originalPos;
+ vec3 currentScale = originalScale;
+ if (${inputs.dropProgress} > 0.0) {
+ float randomOffset = hash(originalPos) * ${inputs.randomFactor};
+ physicsPos = simulatePhysics(originalPos, ${inputs.dropTime}, ${inputs.dropProgress}, ${inputs.gravity}, ${inputs.bounceDamping}, ${inputs.floorLevel}, randomOffset, ${inputs.friction}, ${inputs.explosionStrength});
+ float factor = exp(-${inputs.dropTime} * ${inputs.shrinkSpeed});
+ currentScale = mix(originalScale, vec3(0.005), 1.0 - factor);
+ }
+ vec3 finalPos = physicsPos;
+ vec3 finalScale = currentScale;
+ if (${inputs.isReforming} > 0.5) {
+ finalPos = elegantReform(physicsPos, originalPos, ${inputs.reformTime}, ${inputs.reformDuration});
+ finalScale = reformScale(currentScale, originalScale, ${inputs.reformTime}, ${inputs.reformDuration});
+ }
+ ${outputs.gsplat}.center = finalPos;
+ ${outputs.gsplat}.scales = finalScale;
+ `),
+ });
+ gsplat = physicsShader.apply({
+ gsplat,
+ time: animationTime,
+ dropTime: uDropTime,
+ dropProgress: uDropProgress,
+ gravity: uGravity,
+ bounceDamping: uBounceDamping,
+ floorLevel: uFloorLevel,
+ randomFactor: uRandomFactor,
+ reformSpeed: uReformSpeed,
+ cycleDuration: uCycleDuration,
+ friction: uFriction,
+ shrinkSpeed: uShrinkSpeed,
+ explosionStrength: uExplosionStrength,
+ isReforming: uIsReforming,
+ reformTime: uReformTime,
+ reformDuration: uReformDuration,
+ }).gsplat;
+ return { gsplat };
+ },
+ );
+ }
+
+ function createBirthDynoshader() {
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const birthShader = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ time: "float",
+ isBirthing: "float",
+ birthTime: "float",
+ birthDuration: "float",
+ },
+ 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); }
+ `),
+ ],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ vec3 originalPos = ${inputs.gsplat}.center;
+ vec3 originalScale = ${inputs.gsplat}.scales;
+ if (${inputs.isBirthing} > 0.5 && ${inputs.birthTime} < ${inputs.birthDuration}) {
+ float progress = ${inputs.birthTime} / ${inputs.birthDuration};
+ float birthOffset = hash(originalPos) * 0.1;
+ float adjusted = clamp((progress - birthOffset / ${inputs.birthDuration}) / (1.0 - birthOffset / ${inputs.birthDuration}), 0.0, 1.0);
+ float ease = pow(adjusted * adjusted * (3.0 - 2.0 * adjusted), 0.6);
+ vec3 birthPos = mix(vec3(0.0), originalPos, ease);
+ vec3 birthScale = mix(vec3(0.0), originalScale, ease);
+ ${outputs.gsplat}.center = birthPos;
+ ${outputs.gsplat}.scales = birthScale;
+ float alpha = ${inputs.gsplat}.rgba.a * ease;
+ ${outputs.gsplat}.rgba.a = alpha;
+ }
+ `),
+ });
+ gsplat = birthShader.apply({
+ gsplat,
+ time: animationTime,
+ isBirthing: uIsBirthing,
+ birthTime: uBirthTime,
+ birthDuration: uBirthDuration,
+ }).gsplat;
+ return { gsplat };
+ },
+ );
+ }
+
+ const splatMeshes = {};
+ let currentSplatName = "penguin";
+ let nextSplatName = "cat";
+
+ async function loadSplats() {
+ const splatNames = ["penguin.spz", "cat.spz", "woobles.spz"];
+ for (const splatName of splatNames) {
+ const splatURL = await getAssetFileURL(splatName);
+ const mesh = new SplatMesh({ url: splatURL });
+ await mesh.initialized;
+ const nameKey = splatName.replace(".spz", "");
+ mesh.worldModifier = createDeathDynoshader();
+ mesh.updateGenerator();
+ mesh.position.set(0, 0, 0);
+ mesh.rotation.set(Math.PI, 0, 0);
+ if (nameKey === "woobles") mesh.scale.set(1.7, 2.0, 1.7);
+ else mesh.scale.set(1, 1, 1);
+ mesh.visible = nameKey === currentSplatName;
+ group.add(mesh);
+ splatMeshes[nameKey] = mesh;
+ }
+ }
+
+ async function loadTable() {
+ const tableURL = await getAssetFileURL("table.glb");
+ const loader = new GLTFLoader();
+ await new Promise((resolve, reject) => {
+ loader.load(
+ tableURL,
+ (gltf) => {
+ const tableModel = gltf.scene;
+ tableModel.position.set(0, -0.5, 0);
+ tableModel.scale.set(5.5, 5.5, 5.5);
+ tableModel.rotation.set(0, 0, 0);
+ tableModel.traverse((child) => {
+ if (child.isMesh) {
+ child.castShadow = true;
+ child.receiveShadow = true;
+ }
+ });
+ group.add(tableModel);
+ resolve(tableModel);
+ },
+ undefined,
+ reject,
+ );
+ });
+ }
+
+ function _switchToSplat(name) {
+ for (const m of Object.values(splatMeshes)) {
+ if (m) m.visible = false;
+ }
+ if (splatMeshes[name]) {
+ splatMeshes[name].visible = true;
+ currentSplatName = name;
+ }
+ }
+
+ function getNextSplatName(current) {
+ const order = ["penguin", "cat", "woobles"];
+ return order[(order.indexOf(current) + 1) % order.length];
+ }
+
+ const transitionState = {
+ isTransitioning: false,
+ transitionTime: 0.0,
+ transitionDuration: 3.0,
+ };
+
+ function startExplosion() {
+ if (transitionState.isTransitioning) return;
+ transitionState.isTransitioning = true;
+ transitionState.transitionTime = 0.0;
+ for (const [name, mesh] of Object.entries(splatMeshes)) {
+ if (mesh) mesh.visible = name === currentSplatName;
+ }
+ if (splatMeshes[currentSplatName]) {
+ splatMeshes[currentSplatName].worldModifier = createDeathDynoshader();
+ splatMeshes[currentSplatName].updateGenerator();
+ }
+ uDropProgress.value = 1.0;
+ uDropTime.value = 0.0;
+ uIsReforming.value = 0.0;
+ }
+
+ function startTransition() {
+ startExplosion();
+ nextSplatName = getNextSplatName(currentSplatName);
+ if (splatMeshes[nextSplatName]) {
+ for (const [name, mesh] of Object.entries(splatMeshes)) {
+ if (!mesh) continue;
+ if (name !== currentSplatName && name !== nextSplatName)
+ mesh.visible = false;
+ }
+ splatMeshes[nextSplatName].worldModifier = createBirthDynoshader();
+ splatMeshes[nextSplatName].updateGenerator();
+ splatMeshes[nextSplatName].visible = true;
+ uIsBirthing.value = 1.0;
+ uBirthTime.value = 0.0;
+ }
+ }
+
+ function completeTransition() {
+ uIsBirthing.value = 0.0;
+ uBirthTime.value = 0.0;
+ currentSplatName = nextSplatName;
+ transitionState.isTransitioning = false;
+ transitionState.transitionTime = 0.0;
+ }
+
+ await Promise.all([loadSplats(), loadTable()]);
+ if (disposed) return { group, update: () => {}, dispose, setupGUI };
+
+ // Instructional text
+ const instructionsText = textSplats({
+ text: "WASD + mouse to move\nSPACEBAR: Explosion!",
+ font: "Arial",
+ fontSize: 24,
+ color: new THREE.Color(0xffffff),
+ textAlign: "center",
+ lineHeight: 1.3,
+ });
+ instructionsText.scale.setScalar(0.15 / 24);
+ instructionsText.position.set(0, 0.2, 2.5);
+ group.add(instructionsText);
+
+ let totalTime = 0;
+ const transitionParams = { autoTransition: true };
+
+ function onKeyDown(e) {
+ if (e.code === "Space") {
+ e.preventDefault();
+ if (transitionState.isTransitioning) completeTransition();
+ startTransition();
+ totalTime = 0;
+ }
+ }
+ window.addEventListener("keydown", onKeyDown);
+
+ function update(dt, t) {
+ animationTime.value = t;
+ // Update camera controls
+ controls.update(camera);
+ if (transitionParams.autoTransition) {
+ if (!transitionState.isTransitioning) totalTime += dt;
+ if (!transitionState.isTransitioning && totalTime >= 1.0) {
+ startTransition();
+ totalTime = 0;
+ }
+ }
+ if (transitionState.isTransitioning) {
+ transitionState.transitionTime += dt;
+ uBirthTime.value = transitionState.transitionTime;
+ if (transitionState.transitionTime >= transitionState.transitionDuration)
+ completeTransition();
+ }
+ uDropTime.value += dt;
+ const dying = splatMeshes[currentSplatName];
+ const birthing = splatMeshes[nextSplatName];
+ if (transitionState.isTransitioning) {
+ if (dying) dying.updateVersion();
+ if (birthing) birthing.updateVersion();
+ } else {
+ if (dying) dying.updateVersion();
+ }
+ }
+
+ function setupGUI(folder) {
+ const params = { explosionStrength: uExplosionStrength.value };
+ folder
+ .add(params, "explosionStrength", 0.0, 10.0, 0.1)
+ .name("Explosion Strength")
+ .onChange((v) => {
+ uExplosionStrength.value = v;
+ });
+ folder.add(transitionParams, "autoTransition").name("Auto Transition");
+ folder
+ .add({ explode: () => startTransition() }, "explode")
+ .name("Trigger Explosion");
+ return folder;
+ }
+
+ function dispose() {
+ disposed = true;
+ window.removeEventListener("keydown", onKeyDown);
+ // Disable controls to avoid interfering with other effects
+ controls.fpsMovement.enable = false;
+ controls.pointerControls.enable = false;
+ scene.remove(group);
+ }
+
+ return { group, update, dispose, setupGUI };
+}
diff --git a/effects/splat-transitions/effects/flow.js b/effects/splat-transitions/effects/flow.js
new file mode 100644
index 00000000..1cbf72da
--- /dev/null
+++ b/effects/splat-transitions/effects/flow.js
@@ -0,0 +1,308 @@
+import { SplatMesh, dyno } from "@sparkjsdev/spark";
+import * as THREE from "three";
+import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
+import { getAssetFileURL } from "/examples/js/get-asset-url.js";
+
+export async function init({ THREE: _THREE, scene, camera, renderer, spark }) {
+ const group = new THREE.Group();
+ scene.add(group);
+ let disposed = false;
+
+ const PARAMETERS = {
+ speedMultiplier: 0.5,
+ objectRotation: true,
+ pause: false,
+ fixedMinScale: false,
+ waves: 0.5,
+ cameraRotation: true,
+ };
+ const PAUSE_SECONDS = 2.0;
+
+ function getTransitionState(t, fadeInTime, fadeOutTime, period) {
+ const one = dyno.dynoFloat(1.0);
+ const pauseTime = dyno.dynoFloat(PAUSE_SECONDS);
+ const cycleTime = dyno.add(one, pauseTime);
+ const total = dyno.mul(period, cycleTime);
+ const wrapT = dyno.mod(t, total);
+ const pos = dyno.mod(wrapT, cycleTime);
+ const inPause = dyno.greaterThan(pos, one);
+ const normT = dyno.select(inPause, one, pos);
+ const fadeIn = dyno.and(
+ dyno.greaterThan(wrapT, dyno.mul(fadeInTime, cycleTime)),
+ dyno.lessThan(wrapT, dyno.mul(dyno.add(fadeInTime, one), cycleTime)),
+ );
+ const fadeOut = dyno.and(
+ dyno.greaterThan(wrapT, dyno.mul(fadeOutTime, cycleTime)),
+ dyno.lessThan(wrapT, dyno.mul(dyno.add(fadeOutTime, one), cycleTime)),
+ );
+ return { inTransition: dyno.or(fadeIn, fadeOut), isFadeIn: fadeIn, normT };
+ }
+
+ function contractionDyno(centerGLSL) {
+ return new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ inTransition: "bool",
+ fadeIn: "bool",
+ t: "float",
+ gt: "float",
+ objectIndex: "int",
+ fixedMinScale: "bool",
+ waves: "float",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ globals: () => [
+ dyno.unindent(`
+ float hash13(vec3 p3) { p3 = fract(p3 * .1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
+ float hash11(float p) { p = fract(p * .1031); p += dot(p, p + 33.33); return fract(p * p); }
+ float fadeInOut(float t) { return abs(mix(-1., 1., t)); }
+ ${centerGLSL}
+ float applyBrightness(float t) { return .5 + fadeInOut(t) * .5; }
+ vec3 applyCenter(vec3 center, float t, float id, int idx, float waves) {
+ int next = (idx + 1) % 3;
+ vec3 cNext = getCenterOfMass(next);
+ vec3 cOwn = getCenterOfMass(idx);
+ float f = fadeInOut(t);
+ float v = .5 + hash11(id) * 2.;
+ vec3 p = t < .5 ? mix(cNext, center, pow(f, v)) : mix(cOwn, center, pow(f, v));
+ return p + length(sin(p*2.5)) * waves * (1.-f)*smoothstep(0.5,0.,t) * 2.;
+ }
+ vec3 applyScale(vec3 s, float t, bool fixedMin) { return mix(fixedMin ? vec3(.02) : s * .2, s, pow(fadeInOut(t), 3.)); }
+ float applyOpacity(float t, float gt, int idx) {
+ float p = float(${PAUSE_SECONDS});
+ float c = 1.0 + p;
+ float tot = 3.0 * c;
+ float w = mod(gt + p + .5, tot);
+ int cur = int(floor(w / c));
+ return cur == idx ? .1+fadeInOut(t) : 0.0;
+ }
+ `),
+ ],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ ${outputs.gsplat}.center = applyCenter(${inputs.gsplat}.center, ${inputs.t}, float(${inputs.gsplat}.index), ${inputs.objectIndex}, ${inputs.waves});
+ ${outputs.gsplat}.scales = applyScale(${inputs.gsplat}.scales, ${inputs.t}, ${inputs.fixedMinScale});
+ ${outputs.gsplat}.rgba.a *= applyOpacity(${inputs.t}, ${inputs.gt}, ${inputs.objectIndex});
+ ${outputs.gsplat}.rgba.rgb *= applyBrightness(${inputs.t});
+ `),
+ });
+ }
+
+ function getTransitionModifier(
+ inTrans,
+ fadeIn,
+ t,
+ idx,
+ gt,
+ centerGLSL,
+ fixedMinScale,
+ waves,
+ ) {
+ const dyn = contractionDyno(centerGLSL);
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => ({
+ gsplat: dyn.apply({
+ gsplat,
+ inTransition: inTrans,
+ fadeIn,
+ t,
+ gt,
+ objectIndex: idx,
+ fixedMinScale,
+ waves,
+ }).gsplat,
+ }),
+ );
+ }
+
+ async function loadGLB(file, isEnv = false) {
+ const url = await getAssetFileURL(file);
+ const loader = new GLTFLoader();
+ const gltf = await new Promise((res, rej) =>
+ loader.load(url, res, undefined, rej),
+ );
+ gltf.scene.traverse((child) => {
+ if (child.isMesh && child.material) {
+ const mat = new THREE.MeshBasicMaterial({
+ color: child.material.color,
+ map: child.material.map,
+ });
+ if (isEnv) {
+ mat.side = THREE.BackSide;
+ if (mat.map) {
+ mat.map.mapping = THREE.EquirectangularReflectionMapping;
+ mat.map.colorSpace = THREE.LinearSRGBColorSpace;
+ mat.map.needsUpdate = true;
+ }
+ }
+ child.material = mat;
+ }
+ });
+ return gltf.scene;
+ }
+
+ const time = dyno.dynoFloat(0.0);
+ const splatFiles = ["woobles.spz", "dessert.spz", "robot-head.spz"];
+ const skyFile = "dali-env.glb";
+
+ const env = await loadGLB(skyFile, true);
+ if (!disposed) group.add(env);
+
+ const meshes = [];
+ const period = dyno.dynoFloat(splatFiles.length);
+ const positions = [
+ new THREE.Vector3(-5, -2.2, -3),
+ new THREE.Vector3(5, -2.5, 0),
+ new THREE.Vector3(0, 1.5, 2),
+ ];
+
+ for (let i = 0; i < splatFiles.length; i++) {
+ const url = await getAssetFileURL(splatFiles[i]);
+ const m = new SplatMesh({ url });
+ await m.initialized;
+ m.position.copy(positions[i]);
+ m.rotateX(Math.PI);
+ if (!disposed) group.add(m);
+ meshes.push(m);
+ }
+
+ const centers = meshes.map((m) => {
+ const box = new THREE.Box3();
+ m.packedSplats.forEachSplat((_, c) => {
+ box.expandByPoint(c);
+ });
+ const localCenter = box.getCenter(new THREE.Vector3());
+ localCenter.y = -localCenter.y;
+ return localCenter.add(m.position);
+ });
+
+ const centerGLSL = `
+ vec3 getCenterOfMass(int idx) {
+ if (idx == 0) return vec3(${centers[0].x}, ${centers[0].y}, ${centers[0].z});
+ if (idx == 1) return vec3(${centers[1].x}, ${centers[1].y}, ${centers[1].z});
+ if (idx == 2) return vec3(${centers[2].x}, ${centers[2].y}, ${centers[2].z});
+ return vec3(0.0);
+ }
+ `;
+
+ meshes.forEach((m, i) => {
+ const { inTransition, isFadeIn, normT } = getTransitionState(
+ time,
+ dyno.dynoFloat(i),
+ dyno.dynoFloat((i + 1) % splatFiles.length),
+ period,
+ );
+ m.worldModifier = getTransitionModifier(
+ inTransition,
+ isFadeIn,
+ normT,
+ dyno.dynoInt(i),
+ time,
+ centerGLSL,
+ dyno.dynoBool(PARAMETERS.fixedMinScale),
+ dyno.dynoFloat(PARAMETERS.waves),
+ );
+ m.updateGenerator();
+ });
+
+ function updateCamera() {
+ const cTime = 1 + PAUSE_SECONDS;
+ const tot = meshes.length * cTime;
+ const w = (time.value + PAUSE_SECONDS) % tot;
+ const cur = Math.floor(w / cTime);
+ const nxt = (cur + 1) % meshes.length;
+ const tr = w / cTime - cur;
+ const s = tr * tr * (3 - 2 * tr);
+ const tgt = new THREE.Vector3().lerpVectors(
+ meshes[cur].position,
+ meshes[nxt].position,
+ s ** 5,
+ );
+ const radius = 4 + Math.abs(s - 0.5) ** 2 * 20;
+ let x;
+ let z;
+ if (PARAMETERS.cameraRotation) {
+ const angle = -time.value * 0.5;
+ x = Math.cos(angle) * radius;
+ z = Math.sin(angle) * radius;
+ } else {
+ x = 0;
+ z = -radius;
+ }
+ const frm = tgt.clone().add(new THREE.Vector3(x, 2, z));
+ camera.position.copy(frm);
+ camera.lookAt(tgt);
+ }
+
+ function update(dt, _t) {
+ if (!PARAMETERS.pause) {
+ time.value += dt * PARAMETERS.speedMultiplier;
+ if (PARAMETERS.objectRotation) {
+ for (const m of meshes) {
+ m.rotation.y += dt * PARAMETERS.speedMultiplier * 2;
+ }
+ }
+ }
+ updateCamera();
+ }
+
+ function setupGUI(folder) {
+ folder.add(PARAMETERS, "speedMultiplier", 0, 1, 0.01);
+ folder.add(PARAMETERS, "objectRotation");
+ folder.add(PARAMETERS, "pause");
+ folder.add(PARAMETERS, "cameraRotation");
+ folder.add(PARAMETERS, "fixedMinScale").onChange(() => {
+ meshes.forEach((m, i) => {
+ const { inTransition, isFadeIn, normT } = getTransitionState(
+ time,
+ dyno.dynoFloat(i),
+ dyno.dynoFloat((i + 1) % splatFiles.length),
+ dyno.dynoFloat(splatFiles.length),
+ );
+ m.worldModifier = getTransitionModifier(
+ inTransition,
+ isFadeIn,
+ normT,
+ dyno.dynoInt(i),
+ time,
+ centerGLSL,
+ dyno.dynoBool(PARAMETERS.fixedMinScale),
+ dyno.dynoFloat(PARAMETERS.waves),
+ );
+ m.updateGenerator();
+ });
+ });
+ folder.add(PARAMETERS, "waves", 0, 1, 0.01).onChange(() => {
+ meshes.forEach((m, i) => {
+ const { inTransition, isFadeIn, normT } = getTransitionState(
+ time,
+ dyno.dynoFloat(i),
+ dyno.dynoFloat((i + 1) % splatFiles.length),
+ dyno.dynoFloat(splatFiles.length),
+ );
+ m.worldModifier = getTransitionModifier(
+ inTransition,
+ isFadeIn,
+ normT,
+ dyno.dynoInt(i),
+ time,
+ centerGLSL,
+ dyno.dynoBool(PARAMETERS.fixedMinScale),
+ dyno.dynoFloat(PARAMETERS.waves),
+ );
+ m.updateGenerator();
+ });
+ });
+ return folder;
+ }
+
+ function dispose() {
+ disposed = true;
+ scene.remove(group);
+ }
+
+ return { group, update, dispose, setupGUI };
+}
diff --git a/effects/splat-transitions/effects/line-wipe.js b/effects/splat-transitions/effects/line-wipe.js
new file mode 100644
index 00000000..4c5ab9ba
--- /dev/null
+++ b/effects/splat-transitions/effects/line-wipe.js
@@ -0,0 +1,134 @@
+import { SplatMesh, dyno } from "@sparkjsdev/spark";
+import * as THREE from "three";
+import { getAssetFileURL } from "/examples/js/get-asset-url.js";
+
+export async function init({ THREE: _THREE, scene, camera, renderer, spark }) {
+ const group = new THREE.Group();
+ scene.add(group);
+ let disposed = false;
+
+ const PARAMETERS = {
+ speedMultiplier: 1.0,
+ edgeSoftness: 0.3,
+ angleDegrees: 0,
+ pause: false,
+ };
+ const LINE_MIN_Y = -2.0;
+ const LINE_MAX_Y = 2.0;
+
+ const time = dyno.dynoFloat(0.0);
+ const lineYDyn = dyno.dynoFloat(0.0);
+ const lineMinDyn = dyno.dynoFloat(LINE_MIN_Y);
+ const lineMaxDyn = dyno.dynoFloat(LINE_MAX_Y);
+ const edgeDyn = dyno.dynoFloat(PARAMETERS.edgeSoftness);
+ const angleRadDyn = dyno.dynoFloat(0.0);
+
+ function createLineWipeModifier(isAboveLine) {
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const d = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ lineY: "float",
+ edge: "float",
+ angleRad: "float",
+ isAbove: "int",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ vec3 c = ${inputs.gsplat}.center;
+ float height = c.y * cos(${inputs.angleRad}) + c.x * sin(${inputs.angleRad});
+ float lineY = ${inputs.lineY};
+ float edge = ${inputs.edge};
+ float alpha;
+ if (${inputs.isAbove} == 1) {
+ alpha = smoothstep(lineY - edge, lineY + edge, height);
+ } else {
+ alpha = 1.0 - smoothstep(lineY - edge, lineY + edge, height);
+ }
+ ${outputs.gsplat}.rgba.a *= alpha;
+ `),
+ });
+ return {
+ gsplat: d.apply({
+ gsplat,
+ lineY: lineYDyn,
+ edge: edgeDyn,
+ angleRad: angleRadDyn,
+ isAbove: dyno.dynoInt(isAboveLine ? 1 : 0),
+ }).gsplat,
+ };
+ },
+ );
+ }
+
+ const penguinURL = await getAssetFileURL("penguin.spz");
+ const catURL = await getAssetFileURL("cat.spz");
+
+ const penguin = new SplatMesh({ url: penguinURL });
+ await penguin.initialized;
+ penguin.position.set(0.1, -1.5, 0);
+ penguin.rotation.set(Math.PI, 0, 0);
+ penguin.scale.set(1, 1, 1);
+ penguin.worldModifier = createLineWipeModifier(true);
+ penguin.updateGenerator();
+
+ const cat = new SplatMesh({ url: catURL });
+ await cat.initialized;
+ cat.position.set(0, -1.5, 0);
+ cat.rotation.set(Math.PI, 0, 0);
+ cat.scale.set(1, 1, 1);
+ cat.worldModifier = createLineWipeModifier(false);
+ cat.updateGenerator();
+
+ if (!disposed) {
+ group.add(penguin);
+ group.add(cat);
+ }
+
+ camera.position.set(0, 0, 5);
+ camera.lookAt(0, 0, 0);
+
+ function update(dt, t) {
+ if (!PARAMETERS.pause) {
+ time.value += dt * PARAMETERS.speedMultiplier;
+ const cycle = (lineMaxDyn.value - lineMinDyn.value) * 3;
+ const phase = (time.value % cycle) / cycle;
+ const lineY =
+ phase < 0.5
+ ? lineMinDyn.value +
+ (lineMaxDyn.value - lineMinDyn.value) * (phase * 2)
+ : lineMaxDyn.value -
+ (lineMaxDyn.value - lineMinDyn.value) * ((phase - 0.5) * 2);
+ lineYDyn.value = lineY;
+ penguin.updateVersion();
+ cat.updateVersion();
+ }
+ }
+
+ function setupGUI(folder) {
+ folder.add(PARAMETERS, "speedMultiplier", 0.2, 3.0, 0.1);
+ folder.add(PARAMETERS, "edgeSoftness", 0.01, 0.5, 0.01).onChange((v) => {
+ edgeDyn.value = v;
+ });
+ folder
+ .add(PARAMETERS, "angleDegrees", 0, 90, 1)
+ .name("Angle (deg)")
+ .onChange((v) => {
+ angleRadDyn.value = (v * Math.PI) / 180;
+ });
+ folder.add(PARAMETERS, "pause");
+ return folder;
+ }
+
+ function dispose() {
+ disposed = true;
+ scene.remove(group);
+ }
+
+ return { group, update, dispose, setupGUI };
+}
diff --git a/effects/splat-transitions/effects/morph.js b/effects/splat-transitions/effects/morph.js
new file mode 100644
index 00000000..f3e9582c
--- /dev/null
+++ b/effects/splat-transitions/effects/morph.js
@@ -0,0 +1,347 @@
+import { SplatMesh, dyno } from "@sparkjsdev/spark";
+import * as THREE from "three";
+import { getAssetFileURL } from "/examples/js/get-asset-url.js";
+
+function compareFrontOrder(a, b) {
+ const yDiff = b.y - a.y;
+ if (Math.abs(yDiff) > 1e-4) {
+ return yDiff;
+ }
+ const xDiff = a.x - b.x;
+ if (Math.abs(xDiff) > 1e-4) {
+ return xDiff;
+ }
+ return a.z - b.z;
+}
+
+function compareHorizontalOrder(a, b) {
+ const xDiff = a.x - b.x;
+ if (Math.abs(xDiff) > 1e-4) {
+ return xDiff;
+ }
+ const yDiff = b.y - a.y;
+ if (Math.abs(yDiff) > 1e-4) {
+ return yDiff;
+ }
+ return a.z - b.z;
+}
+
+function buildRankMapping(srcNorm, tgtNorm, srcN, tgtN) {
+ const srcOrder = new Array(srcN);
+ const tgtOrder = new Array(tgtN);
+ const partner = new Uint32Array(srcN);
+ const sliceCount = Math.min(
+ 128,
+ Math.max(24, Math.floor(Math.sqrt(srcN) * 0.35)),
+ );
+
+ for (let i = 0; i < srcN; i++) {
+ srcOrder[i] = i;
+ }
+ for (let j = 0; j < tgtN; j++) {
+ tgtOrder[j] = j;
+ }
+
+ srcOrder.sort((a, b) => compareFrontOrder(srcNorm[a], srcNorm[b]));
+ tgtOrder.sort((a, b) => compareFrontOrder(tgtNorm[a], tgtNorm[b]));
+
+ for (let slice = 0; slice < sliceCount; slice++) {
+ const srcStart = Math.floor((slice * srcN) / sliceCount);
+ const srcEnd = Math.floor(((slice + 1) * srcN) / sliceCount);
+ const tgtStart = Math.floor((slice * tgtN) / sliceCount);
+ const tgtEnd = Math.floor(((slice + 1) * tgtN) / sliceCount);
+ if (srcEnd <= srcStart || tgtEnd <= tgtStart) {
+ continue;
+ }
+
+ const srcSlice = srcOrder.slice(srcStart, srcEnd);
+ const tgtSlice = tgtOrder.slice(tgtStart, tgtEnd);
+ srcSlice.sort((a, b) => compareHorizontalOrder(srcNorm[a], srcNorm[b]));
+ tgtSlice.sort((a, b) => compareHorizontalOrder(tgtNorm[a], tgtNorm[b]));
+
+ const srcCount = srcSlice.length;
+ const tgtCount = tgtSlice.length;
+ for (let rank = 0; rank < srcCount; rank++) {
+ const srcIndex = srcSlice[rank];
+ const tgtRank = Math.min(
+ tgtCount - 1,
+ Math.floor((rank * tgtCount) / srcCount),
+ );
+ partner[srcIndex] = tgtSlice[tgtRank];
+ }
+ }
+
+ return partner;
+}
+
+function setMorphTexel(data, texW, splatIndex, row, v) {
+ const col = splatIndex % texW;
+ const block = Math.floor(splatIndex / texW);
+ const y = block * 4 + row;
+ const o = (y * texW + col) * 4;
+ data[o + 0] = v.x;
+ data[o + 1] = v.y;
+ data[o + 2] = v.z;
+ data[o + 3] = v.w;
+}
+
+function createMorphModifier(morphT, morphSampler, texW) {
+ const tw = String(texW);
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ const shader = new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ t: "float",
+ morphTex: "sampler2D",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ globals: () => [],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ float tm = clamp(${inputs.t}, 0.0, 1.0);
+ int si = ${inputs.gsplat}.index;
+ int col = si % ${tw};
+ int block = si / ${tw};
+ int y0 = block * 4;
+ vec4 t0 = texelFetch(${inputs.morphTex}, ivec2(col, y0 + 0), 0);
+ vec4 t1 = texelFetch(${inputs.morphTex}, ivec2(col, y0 + 1), 0);
+ vec4 t2 = texelFetch(${inputs.morphTex}, ivec2(col, y0 + 2), 0);
+ vec4 t3 = texelFetch(${inputs.morphTex}, ivec2(col, y0 + 3), 0);
+ vec3 tc = t0.xyz;
+ vec4 tq = t1;
+ vec3 ts = t2.xyz;
+ vec4 trgba = vec4(t3.xyz, t3.w);
+ vec3 c0 = ${inputs.gsplat}.center;
+ vec4 q0 = ${inputs.gsplat}.quaternion;
+ vec3 s0 = ${inputs.gsplat}.scales;
+ vec4 rgba0 = ${inputs.gsplat}.rgba;
+ if (dot(q0, tq) < 0.0) {
+ tq = -tq;
+ }
+ ${outputs.gsplat}.center = mix(c0, tc, tm);
+ ${outputs.gsplat}.quaternion = normalize(mix(q0, tq, tm));
+ ${outputs.gsplat}.scales = mix(s0, ts, tm);
+ ${outputs.gsplat}.rgba = mix(rgba0, trgba, tm);
+ `),
+ });
+ return {
+ gsplat: shader.apply({
+ gsplat,
+ t: morphT,
+ morphTex: morphSampler,
+ }).gsplat,
+ };
+ },
+ );
+}
+
+export async function init({ scene, camera }) {
+ const group = new THREE.Group();
+ scene.add(group);
+ let disposed = false;
+
+ camera.position.set(0, 0, 5.2);
+ camera.lookAt(0, 0, 0);
+
+ const vWorld = new THREE.Vector3();
+ const invPenguin = new THREE.Matrix4();
+ const halfSpacing = 0;
+ const splatRot = new THREE.Euler(Math.PI, 0, 0);
+
+ const [urlPenguin, urlCat] = await Promise.all([
+ getAssetFileURL("penguin.spz"),
+ getAssetFileURL("cat.spz"),
+ ]);
+
+ const penguinMesh = new SplatMesh({ url: urlPenguin });
+ penguinMesh.position.set(-halfSpacing, -1.5, 0);
+ penguinMesh.rotation.copy(splatRot);
+
+ const catMesh = new SplatMesh({ url: urlCat });
+ catMesh.position.set(halfSpacing, -1.5, 0);
+ catMesh.rotation.copy(splatRot);
+
+ await Promise.all([penguinMesh.initialized, catMesh.initialized]);
+
+ penguinMesh.updateMatrixWorld(true);
+ catMesh.updateMatrixWorld(true);
+ invPenguin.copy(penguinMesh.matrixWorld).invert();
+
+ const nP = penguinMesh.numSplats;
+ const nC = catMesh.numSplats;
+
+ const srcWorld = new Array(nP);
+ const tgtWorld = new Array(nC);
+ const srcBox = new THREE.Box3();
+ const tgtBox = new THREE.Box3();
+
+ for (let i = 0; i < nP; i++) {
+ srcWorld[i] = new THREE.Vector3();
+ }
+ for (let j = 0; j < nC; j++) {
+ tgtWorld[j] = new THREE.Vector3();
+ }
+
+ penguinMesh.forEachSplat((i, center) => {
+ vWorld.copy(center).applyMatrix4(penguinMesh.matrixWorld);
+ srcWorld[i].copy(vWorld);
+ srcBox.expandByPoint(vWorld);
+ });
+
+ catMesh.forEachSplat((j, center) => {
+ vWorld.copy(center).applyMatrix4(catMesh.matrixWorld);
+ tgtWorld[j].copy(vWorld);
+ tgtBox.expandByPoint(vWorld);
+ });
+
+ const srcSize = new THREE.Vector3();
+ const tgtSize = new THREE.Vector3();
+ const srcMin = srcBox.min.clone();
+ const tgtMin = tgtBox.min.clone();
+ srcBox.getSize(srcSize);
+ tgtBox.getSize(tgtSize);
+ const srcSx = srcSize.x > 1e-6 ? srcSize.x : 1;
+ const srcSy = srcSize.y > 1e-6 ? srcSize.y : 1;
+ const srcSz = srcSize.z > 1e-6 ? srcSize.z : 1;
+ const tgtSx = tgtSize.x > 1e-6 ? tgtSize.x : 1;
+ const tgtSy = tgtSize.y > 1e-6 ? tgtSize.y : 1;
+ const tgtSz = tgtSize.z > 1e-6 ? tgtSize.z : 1;
+
+ const srcNorm = new Array(nP);
+ const tgtNorm = new Array(nC);
+ for (let i = 0; i < nP; i++) {
+ srcNorm[i] = new THREE.Vector3(
+ (srcWorld[i].x - srcMin.x) / srcSx,
+ (srcWorld[i].y - srcMin.y) / srcSy,
+ (srcWorld[i].z - srcMin.z) / srcSz,
+ );
+ }
+ for (let j = 0; j < nC; j++) {
+ tgtNorm[j] = new THREE.Vector3(
+ (tgtWorld[j].x - tgtMin.x) / tgtSx,
+ (tgtWorld[j].y - tgtMin.y) / tgtSy,
+ (tgtWorld[j].z - tgtMin.z) / tgtSz,
+ );
+ }
+
+ const partner = buildRankMapping(srcNorm, tgtNorm, nP, nC);
+ const texW = Math.min(4096, Math.max(1, nP));
+ const texH = 4 * Math.ceil(nP / texW);
+ const morphData = new Float32Array(texW * texH * 4);
+
+ const catCenters = new Float32Array(nC * 3);
+ const catQuats = new Float32Array(nC * 4);
+ const catScales = new Float32Array(nC * 3);
+ const catRgb = new Float32Array(nC * 3);
+ const catOpac = new Float32Array(nC);
+
+ catMesh.forEachSplat((j, c, scales, q, opacity, color) => {
+ catCenters[j * 3 + 0] = c.x;
+ catCenters[j * 3 + 1] = c.y;
+ catCenters[j * 3 + 2] = c.z;
+ catQuats[j * 4 + 0] = q.x;
+ catQuats[j * 4 + 1] = q.y;
+ catQuats[j * 4 + 2] = q.z;
+ catQuats[j * 4 + 3] = q.w;
+ catScales[j * 3 + 0] = scales.x;
+ catScales[j * 3 + 1] = scales.y;
+ catScales[j * 3 + 2] = scales.z;
+ catRgb[j * 3 + 0] = color.r;
+ catRgb[j * 3 + 1] = color.g;
+ catRgb[j * 3 + 2] = color.b;
+ catOpac[j] = opacity;
+ });
+
+ const tc = new THREE.Vector3();
+ const tq = new THREE.Vector4();
+ const ts = new THREE.Vector3();
+ const row0 = new THREE.Vector4();
+ const row1 = new THREE.Vector4();
+ const row2 = new THREE.Vector4();
+ const row3 = new THREE.Vector4();
+
+ for (let i = 0; i < nP; i++) {
+ const j = partner[i];
+ tc.set(catCenters[j * 3 + 0], catCenters[j * 3 + 1], catCenters[j * 3 + 2]);
+ vWorld.copy(tc).applyMatrix4(catMesh.matrixWorld);
+ vWorld.applyMatrix4(invPenguin);
+
+ tq.set(
+ catQuats[j * 4 + 0],
+ catQuats[j * 4 + 1],
+ catQuats[j * 4 + 2],
+ catQuats[j * 4 + 3],
+ );
+
+ ts.set(catScales[j * 3 + 0], catScales[j * 3 + 1], catScales[j * 3 + 2]);
+
+ row0.set(vWorld.x, vWorld.y, vWorld.z, 0);
+ setMorphTexel(morphData, texW, i, 0, row0);
+ row1.set(tq.x, tq.y, tq.z, tq.w);
+ setMorphTexel(morphData, texW, i, 1, row1);
+ row2.set(ts.x, ts.y, ts.z, catOpac[j]);
+ setMorphTexel(morphData, texW, i, 2, row2);
+ row3.set(
+ catRgb[j * 3 + 0],
+ catRgb[j * 3 + 1],
+ catRgb[j * 3 + 2],
+ catOpac[j],
+ );
+ setMorphTexel(morphData, texW, i, 3, row3);
+ }
+
+ catMesh.dispose();
+
+ const morphTexture = new THREE.DataTexture(
+ morphData,
+ texW,
+ texH,
+ THREE.RGBAFormat,
+ THREE.FloatType,
+ );
+ morphTexture.magFilter = THREE.NearestFilter;
+ morphTexture.minFilter = THREE.NearestFilter;
+ morphTexture.needsUpdate = true;
+
+ const morphT = dyno.dynoFloat(0);
+ const morphSampler = dyno.dynoSampler2D(morphTexture, "morphTarget");
+
+ penguinMesh.objectModifier = createMorphModifier(morphT, morphSampler, texW);
+ penguinMesh.updateGenerator();
+ group.add(penguinMesh);
+
+ const holdDuration = 1.0;
+ const morphDuration = 1.0;
+ const cycleDuration =
+ holdDuration + morphDuration + holdDuration + morphDuration;
+ let elapsed = 0;
+
+ function update(dt) {
+ elapsed += dt;
+ const t = elapsed % cycleDuration;
+ if (t < holdDuration) {
+ morphT.value = 0;
+ } else if (t < holdDuration + morphDuration) {
+ morphT.value = (t - holdDuration) / morphDuration;
+ } else if (t < holdDuration + morphDuration + holdDuration) {
+ morphT.value = 1;
+ } else {
+ morphT.value =
+ 1 - (t - holdDuration - morphDuration - holdDuration) / morphDuration;
+ }
+ penguinMesh.updateVersion();
+ }
+
+ function dispose() {
+ disposed = true;
+ morphTexture.dispose();
+ penguinMesh.dispose();
+ scene.remove(group);
+ }
+
+ return { group, update, dispose };
+}
diff --git a/effects/splat-transitions/effects/spheric.js b/effects/splat-transitions/effects/spheric.js
new file mode 100644
index 00000000..040ba720
--- /dev/null
+++ b/effects/splat-transitions/effects/spheric.js
@@ -0,0 +1,278 @@
+import { SplatMesh, dyno } from "@sparkjsdev/spark";
+import * as THREE from "three";
+import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
+import { getAssetFileURL } from "/examples/js/get-asset-url.js";
+
+export async function init({ THREE: _THREE, scene, camera, renderer, spark }) {
+ const group = new THREE.Group();
+ scene.add(group);
+ let disposed = false;
+
+ // Params and uniforms
+ const PARAMETERS = {
+ splatCoverage: 1.0,
+ spereRadius: 1.0,
+ sphereHeight: 2.0,
+ speedMultiplier: 1.0,
+ rotation: true,
+ pause: false,
+ };
+
+ const time = dyno.dynoFloat(0.0);
+
+ // Camera baseline
+ const _prevPos = camera.position.clone();
+ const prevLook = new THREE.Vector3();
+ camera.getWorldDirection(prevLook);
+
+ const SPHERICAL_TARGET = new THREE.Vector3(0, 2, 0);
+ camera.position.set(5, 4, 7);
+ camera.lookAt(SPHERICAL_TARGET);
+
+ const skyFile = "dali-env.glb";
+ const sceneFile = "dali-table.glb";
+ const splatFiles = ["penguin.spz", "dessert.spz", "woobles.spz"];
+
+ async function loadDelitGLB(filename, isEnv = false) {
+ const url = await getAssetFileURL(filename);
+ const gltfLoader = new GLTFLoader();
+ const gltf = await new Promise((resolve, reject) => {
+ gltfLoader.load(url, resolve, undefined, reject);
+ });
+ const root = gltf.scene;
+ root.traverse((child) => {
+ if (child.isMesh && child.material) {
+ const original = child.material;
+ const basic = new THREE.MeshBasicMaterial();
+ if (original.color) basic.color.copy(original.color);
+ if (original.map) basic.map = original.map;
+ if (isEnv) {
+ basic.side = THREE.BackSide;
+ if (basic.map) {
+ basic.map.mapping = THREE.EquirectangularReflectionMapping;
+ basic.map.colorSpace = THREE.LinearSRGBColorSpace;
+ basic.map.needsUpdate = true;
+ }
+ }
+ child.material = basic;
+ }
+ });
+ return root;
+ }
+
+ function getTransitionState(t, fadeInTime, fadeOutTime, period) {
+ const dynoOne = dyno.dynoFloat(1.0);
+ const wrapT = dyno.mod(t, period);
+ const normT = dyno.mod(t, dynoOne);
+ const isFadeIn = dyno.and(
+ dyno.greaterThan(wrapT, fadeInTime),
+ dyno.lessThan(wrapT, dyno.add(fadeInTime, dynoOne)),
+ );
+ const isFadeOut = dyno.and(
+ dyno.greaterThan(wrapT, fadeOutTime),
+ dyno.lessThan(wrapT, dyno.add(fadeOutTime, dynoOne)),
+ );
+ const inTransition = dyno.or(isFadeIn, isFadeOut);
+ return { inTransition, isFadeIn, normT };
+ }
+
+ function contractionDyno() {
+ return new dyno.Dyno({
+ inTypes: {
+ gsplat: dyno.Gsplat,
+ inTransition: "bool",
+ fadeIn: "bool",
+ t: "float",
+ splatScale: "float",
+ spereRadius: "float",
+ sphereHeight: "float",
+ },
+ outTypes: { gsplat: dyno.Gsplat },
+ globals: () => [
+ dyno.unindent(`
+ vec3 applyCenter(vec3 center, float t, float spereRadius, float sphereHeight) {
+ float heightModifier = 0.5 + 0.5 * pow(abs(1.0 - 2.0*t), 0.2);
+ vec3 targetCenter = vec3(0.0, heightModifier * sphereHeight, 0.0);
+ vec3 dir = normalize(center - targetCenter);
+ vec3 targetPoint = targetCenter + dir * spereRadius;
+ if (t < 0.25 || t > 0.75) {
+ return center;
+ } else if (t < 0.45) {
+ return mix(center, targetPoint, pow((t - 0.25) * 5.0, 4.0));
+ } else if (t < 0.55) {
+ float churn = 0.1;
+ float transitionT = (t - 0.45) * 10.0;
+ float angle = transitionT * 2.0 * PI;
+ vec3 rotvec = vec3(sin(angle), 0.0, cos(angle));
+ float strength = sin(transitionT * PI);
+ return targetPoint + cross(dir, rotvec) * churn * strength;
+ } else {
+ return mix(targetPoint, center, pow((t - 0.55) * 5.0, 4.0));
+ }
+ }
+ vec3 applyScale(vec3 scales, float t, float targetScale) {
+ vec3 targetScales = targetScale * vec3(1.0);
+ if (t < 0.25) return scales;
+ else if (t < 0.45) return mix(scales, targetScales, pow((t - 0.25) * 5.0, 2.0));
+ else if (t < 0.55) return targetScales;
+ else if (t < 0.75) return mix(targetScales, scales, pow((t - 0.55) * 5.0, 2.0));
+ else return scales;
+ }
+ float applyOpacity(float opacity, float t, bool fadeIn) {
+ if (fadeIn) {
+ if (t < 0.4) return 0.0;
+ else if (t < 0.6) return mix(0.0, opacity, pow((t - 0.4) * 5.0, 2.0));
+ else return opacity;
+ } else {
+ if (t < 0.4) return opacity;
+ else if (t < 0.6) return mix(opacity, 0.0, pow((t - 0.4) * 5.0, 2.0));
+ else return 0.0;
+ }
+ }
+ `),
+ ],
+ statements: ({ inputs, outputs }) =>
+ dyno.unindentLines(`
+ ${outputs.gsplat} = ${inputs.gsplat};
+ ${outputs.gsplat}.center = applyCenter(${inputs.gsplat}.center, ${inputs.t}, ${inputs.spereRadius}, ${inputs.sphereHeight});
+ ${outputs.gsplat}.scales = applyScale(${inputs.gsplat}.scales, ${inputs.t}, ${inputs.splatScale});
+ if (${inputs.inTransition}) {
+ ${outputs.gsplat}.rgba.a = applyOpacity(${inputs.gsplat}.rgba.a, ${inputs.t}, ${inputs.fadeIn});
+ } else {
+ ${outputs.gsplat}.rgba.a = 0.0;
+ }
+ `),
+ });
+ }
+
+ function getTransitionModifier(
+ inTransition,
+ fadeIn,
+ t,
+ splatScale,
+ spereRadius,
+ sphereHeight,
+ ) {
+ const contraction = contractionDyno();
+ return dyno.dynoBlock(
+ { gsplat: dyno.Gsplat },
+ { gsplat: dyno.Gsplat },
+ ({ gsplat }) => {
+ gsplat = contraction.apply({
+ gsplat,
+ inTransition,
+ fadeIn,
+ t,
+ splatScale,
+ spereRadius,
+ sphereHeight,
+ }).gsplat;
+ return { gsplat };
+ },
+ );
+ }
+
+ async function morphableSplatMesh(
+ assetName,
+ time,
+ fadeInTime,
+ fadeOutTime,
+ period,
+ splatCoverage,
+ spereRadius,
+ sphereHeight,
+ ) {
+ const url = await getAssetFileURL(assetName);
+ const splatMesh = new SplatMesh({ url });
+ await splatMesh.initialized;
+ const splatScale = dyno.div(
+ dyno.mul(splatCoverage, spereRadius),
+ dyno.dynoFloat(splatMesh.packedSplats.numSplats / 1000.0),
+ );
+ const { inTransition, isFadeIn, normT } = getTransitionState(
+ time,
+ fadeInTime,
+ fadeOutTime,
+ period,
+ );
+ splatMesh.worldModifier = getTransitionModifier(
+ inTransition,
+ isFadeIn,
+ normT,
+ splatScale,
+ spereRadius,
+ sphereHeight,
+ );
+ splatMesh.updateGenerator();
+ splatMesh.quaternion.set(1, 0, 0, 0);
+ return splatMesh;
+ }
+
+ // Load env and assets
+ const sky = await loadDelitGLB(skyFile, true);
+ if (!disposed) group.add(sky);
+ const table = await loadDelitGLB(sceneFile, false);
+ const sceneScale = 3.5;
+ table.scale.set(sceneScale, sceneScale, sceneScale);
+ table.position.set(-1, 0, -0.8);
+ if (!disposed) group.add(table);
+
+ const period = dyno.dynoFloat(splatFiles.length);
+ const spereRadiusDyno = dyno.dynoFloat(PARAMETERS.spereRadius);
+ const splatCoverageDyno = dyno.dynoFloat(PARAMETERS.splatCoverage);
+ const sphereHeightDyno = dyno.dynoFloat(PARAMETERS.sphereHeight);
+
+ const meshes = [];
+ for (let i = 0; i < splatFiles.length; i++) {
+ const mesh = await morphableSplatMesh(
+ splatFiles[i],
+ time,
+ dyno.dynoFloat(i),
+ dyno.dynoFloat((i + 1) % splatFiles.length),
+ period,
+ splatCoverageDyno,
+ spereRadiusDyno,
+ sphereHeightDyno,
+ );
+ if (!disposed) group.add(mesh);
+ meshes.push(mesh);
+ }
+
+ function update(dt, _t) {
+ if (!PARAMETERS.pause) {
+ time.value += dt * 0.5 * PARAMETERS.speedMultiplier;
+ if (PARAMETERS.rotation) {
+ for (const m of meshes) {
+ m.rotation.y += dt * PARAMETERS.speedMultiplier;
+ }
+ }
+ }
+ // Keep camera centered on spherical target
+ camera.lookAt(SPHERICAL_TARGET);
+ }
+
+ function setupGUI(folder) {
+ folder.add(PARAMETERS, "spereRadius", 0.1, 8.0, 0.01).onChange((v) => {
+ spereRadiusDyno.value = v;
+ });
+ folder.add(PARAMETERS, "sphereHeight", -1.0, 4.0, 0.01).onChange((v) => {
+ sphereHeightDyno.value = v;
+ });
+ folder.add(PARAMETERS, "splatCoverage", 0.1, 2.0, 0.01).onChange((v) => {
+ splatCoverageDyno.value = v;
+ });
+ folder.add(PARAMETERS, "speedMultiplier", 0.25, 4.0, 0.01);
+ folder.add(PARAMETERS, "rotation");
+ folder.add(PARAMETERS, "pause");
+ return folder;
+ }
+
+ function dispose() {
+ disposed = true;
+ // Remove group
+ scene.remove(group);
+ // No global listeners here; controls are managed by main
+ }
+
+ return { group, update, dispose, setupGUI };
+}
diff --git a/effects/splat-transitions/index.html b/effects/splat-transitions/index.html
new file mode 100644
index 00000000..329202e0
--- /dev/null
+++ b/effects/splat-transitions/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ Spark • Splat Transitions
+
+
+
+
+ Loading...
+
+
+
+
+
+
diff --git a/effects/splat-transitions/main.js b/effects/splat-transitions/main.js
new file mode 100644
index 00000000..3267974c
--- /dev/null
+++ b/effects/splat-transitions/main.js
@@ -0,0 +1,131 @@
+import { SparkRenderer } from "@sparkjsdev/spark";
+import { GUI } from "lil-gui";
+import * as THREE from "three";
+
+// Central renderer/scene/camera shared by effects
+const canvas = document.getElementById("canvas");
+const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
+renderer.setPixelRatio(window.devicePixelRatio);
+renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
+renderer.setClearColor(0x000000, 1);
+
+const scene = new THREE.Scene();
+const spark = new SparkRenderer({ renderer });
+scene.add(spark);
+
+const camera = new THREE.PerspectiveCamera(
+ 50,
+ canvas.clientWidth / canvas.clientHeight,
+ 0.01,
+ 2000,
+);
+camera.position.set(0, 3, 8);
+camera.lookAt(0, 0, 0);
+scene.add(camera);
+
+// Resize handling
+function handleResize() {
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ renderer.setSize(w, h, false);
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+}
+window.addEventListener("resize", handleResize);
+
+// GUI
+const gui = new GUI();
+const effectFiles = {
+ Spherical: () => import("./effects/spheric.js"),
+ Explosion: () => import("./effects/explosion.js"),
+ Flow: () => import("./effects/flow.js"),
+ Morph: () => import("./effects/morph.js"),
+ "Line Wipe": () => import("./effects/line-wipe.js"),
+};
+const params = { Effect: Object.keys(effectFiles)[0] };
+
+let active = null; // { api, group }
+let last = 0;
+let effectFolder = null; // GUI folder for current effect
+let switchCounter = 0; // guards concurrent effect switches
+
+async function switchEffect(name) {
+ const myToken = ++switchCounter;
+ const loading = document.getElementById("loading");
+ loading.textContent = `Loading ${name}...`;
+ loading.style.display = "block";
+
+ // Dispose previous
+ if (active) {
+ try {
+ active.api.dispose?.();
+ } catch {}
+ if (active.group) scene.remove(active.group);
+ active = null;
+ }
+
+ // Destroy previous GUI folder to avoid accumulation
+ if (effectFolder) {
+ try {
+ effectFolder.destroy();
+ } catch {}
+ effectFolder = null;
+ }
+
+ const loader = effectFiles[name];
+ if (!loader) return;
+ const preChildren = new Set(scene.children);
+ const mod = await loader();
+ if (myToken !== switchCounter) {
+ // A newer switch started; ignore this one
+ return;
+ }
+
+ const context = { THREE, scene, camera, renderer, spark };
+ const api = await mod.init(context);
+ if (myToken !== switchCounter) {
+ try {
+ api.dispose?.();
+ } catch {}
+ // Remove any children added during this init
+ for (const child of [...scene.children]) {
+ if (!preChildren.has(child)) scene.remove(child);
+ }
+ return;
+ }
+
+ if (api.group) scene.add(api.group);
+ active = { api, group: api.group };
+
+ // Setup a per-effect GUI folder if exposed
+ if (api.setupGUI) {
+ effectFolder = gui.addFolder(name);
+ api.setupGUI(effectFolder);
+ }
+
+ loading.style.display = "none";
+
+ if (window.matchMedia("(max-width: 768px)").matches) {
+ gui.close();
+ }
+
+ // Give focus back to the canvas so keyboard controls work immediately
+ try {
+ canvas.focus();
+ } catch {}
+}
+
+gui.add(params, "Effect", Object.keys(effectFiles)).onChange(switchEffect);
+
+// Animation loop
+renderer.setAnimationLoop((timeMs) => {
+ const t = timeMs * 0.001;
+ const dt = t - (last || t);
+ last = t;
+
+ if (active?.api?.update) active.api.update(dt, t);
+ renderer.render(scene, camera);
+});
+
+// Kickoff
+switchEffect(params.Effect);
diff --git a/effects/thumbnails/interactive-assembly.jpg b/effects/thumbnails/interactive-assembly.jpg
new file mode 100644
index 00000000..5ce3d092
Binary files /dev/null and b/effects/thumbnails/interactive-assembly.jpg differ
diff --git a/effects/thumbnails/interactive-deform.jpg b/effects/thumbnails/interactive-deform.jpg
new file mode 100644
index 00000000..5626b28a
Binary files /dev/null and b/effects/thumbnails/interactive-deform.jpg differ
diff --git a/effects/thumbnails/interactive-holes.jpg b/effects/thumbnails/interactive-holes.jpg
new file mode 100644
index 00000000..d29c622c
Binary files /dev/null and b/effects/thumbnails/interactive-holes.jpg differ
diff --git a/effects/thumbnails/interactive-ripples.jpg b/effects/thumbnails/interactive-ripples.jpg
new file mode 100644
index 00000000..6ca26a3c
Binary files /dev/null and b/effects/thumbnails/interactive-ripples.jpg differ
diff --git a/effects/thumbnails/spark.png b/effects/thumbnails/spark.png
new file mode 100644
index 00000000..18ac1216
Binary files /dev/null and b/effects/thumbnails/spark.png differ
diff --git a/effects/thumbnails/splat-disintegration.jpg b/effects/thumbnails/splat-disintegration.jpg
new file mode 100644
index 00000000..6b5c7098
Binary files /dev/null and b/effects/thumbnails/splat-disintegration.jpg differ
diff --git a/effects/thumbnails/splat-matrix.jpg b/effects/thumbnails/splat-matrix.jpg
new file mode 100644
index 00000000..4f853f87
Binary files /dev/null and b/effects/thumbnails/splat-matrix.jpg differ
diff --git a/effects/thumbnails/splat-ninja.jpg b/effects/thumbnails/splat-ninja.jpg
new file mode 100644
index 00000000..6231efab
Binary files /dev/null and b/effects/thumbnails/splat-ninja.jpg differ
diff --git a/effects/thumbnails/splat-portal.jpg b/effects/thumbnails/splat-portal.jpg
new file mode 100644
index 00000000..467effaa
Binary files /dev/null and b/effects/thumbnails/splat-portal.jpg differ
diff --git a/effects/thumbnails/splat-reveal.jpg b/effects/thumbnails/splat-reveal.jpg
new file mode 100644
index 00000000..f6788df5
Binary files /dev/null and b/effects/thumbnails/splat-reveal.jpg differ
diff --git a/effects/thumbnails/splat-shader.jpg b/effects/thumbnails/splat-shader.jpg
new file mode 100644
index 00000000..3ab66bac
Binary files /dev/null and b/effects/thumbnails/splat-shader.jpg differ
diff --git a/effects/thumbnails/splat-shatter.jpg b/effects/thumbnails/splat-shatter.jpg
new file mode 100644
index 00000000..471d549c
Binary files /dev/null and b/effects/thumbnails/splat-shatter.jpg differ
diff --git a/effects/thumbnails/splat-transitions.jpg b/effects/thumbnails/splat-transitions.jpg
new file mode 100644
index 00000000..19ee12d8
Binary files /dev/null and b/effects/thumbnails/splat-transitions.jpg differ
diff --git a/examples/splat-disintegration/index.html b/examples/splat-disintegration/index.html
new file mode 100644
index 00000000..26c70ec9
--- /dev/null
+++ b/examples/splat-disintegration/index.html
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+ Spark • Splat Disintegration
+
+
+
+
+
+
+
+
+