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 + + + + +
Splat Matrix Experiment
+ + + + + + 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 + + + + + + + + +