diff --git a/examples/interactive-deform/index.html b/examples/interactive-deform/index.html new file mode 100644 index 0000000..4845ad0 --- /dev/null +++ b/examples/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/examples/interactive-deform/main.js b/examples/interactive-deform/main.js new file mode 100644 index 0000000..778b467 --- /dev/null +++ b/examples/interactive-deform/main.js @@ -0,0 +1,318 @@ +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(); + } + }); + +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); +});