|
| 1 | +import { SparkRenderer, SplatMesh, dyno } from "@sparkjsdev/spark"; |
| 2 | +import { GUI } from "lil-gui"; |
| 3 | +import * as THREE from "three"; |
| 4 | +import { getAssetFileURL } from "/examples/js/get-asset-url.js"; |
| 5 | + |
| 6 | +const scene = new THREE.Scene(); |
| 7 | +const camera = new THREE.PerspectiveCamera( |
| 8 | + 60, |
| 9 | + window.innerWidth / window.innerHeight, |
| 10 | + 0.1, |
| 11 | + 1000, |
| 12 | +); |
| 13 | +const renderer = new THREE.WebGLRenderer({ antialias: false }); |
| 14 | +renderer.setSize(window.innerWidth, window.innerHeight); |
| 15 | +document.body.appendChild(renderer.domElement); |
| 16 | + |
| 17 | +const spark = new SparkRenderer({ renderer }); |
| 18 | +scene.add(spark); |
| 19 | + |
| 20 | +window.addEventListener("resize", onWindowResize, false); |
| 21 | +function onWindowResize() { |
| 22 | + camera.aspect = window.innerWidth / window.innerHeight; |
| 23 | + camera.updateProjectionMatrix(); |
| 24 | + renderer.setSize(window.innerWidth, window.innerHeight); |
| 25 | +} |
| 26 | + |
| 27 | +let rotationAngle = 0; |
| 28 | +let zoomDistance = 5.5; |
| 29 | +const minZoom = 1; |
| 30 | +const maxZoom = 20; |
| 31 | +const rotationSpeed = 0.02; |
| 32 | +const zoomSpeed = 0.1; |
| 33 | + |
| 34 | +camera.position.set(0, 3, zoomDistance); |
| 35 | +camera.lookAt(0, 1, 0); |
| 36 | + |
| 37 | +const keys = {}; |
| 38 | +window.addEventListener("keydown", (event) => { |
| 39 | + keys[event.key.toLowerCase()] = true; |
| 40 | +}); |
| 41 | +window.addEventListener("keyup", (event) => { |
| 42 | + keys[event.key.toLowerCase()] = false; |
| 43 | +}); |
| 44 | + |
| 45 | +// Dyno uniforms for drag and bounce effects |
| 46 | +const dragPoint = dyno.dynoVec3(new THREE.Vector3(0, 0, 0)); |
| 47 | +const dragDisplacement = dyno.dynoVec3(new THREE.Vector3(0, 0, 0)); |
| 48 | +const dragRadius = dyno.dynoFloat(0.5); |
| 49 | +const dragActive = dyno.dynoFloat(0.0); |
| 50 | +const bounceTime = dyno.dynoFloat(0.0); |
| 51 | +const bounceBaseDisplacement = dyno.dynoVec3(new THREE.Vector3(0, 0, 0)); |
| 52 | +const dragIntensity = dyno.dynoFloat(5.0); |
| 53 | +const bounceAmount = dyno.dynoFloat(0.5); |
| 54 | +const bounceSpeed = dyno.dynoFloat(0.5); |
| 55 | +let isBouncing = false; |
| 56 | + |
| 57 | +const gui = new GUI(); |
| 58 | +const guiParams = { |
| 59 | + intensity: dragIntensity.value, |
| 60 | + radius: 0.5, |
| 61 | + bounceAmount: 0.5, |
| 62 | + bounceSpeed: 0.5, |
| 63 | +}; |
| 64 | +gui |
| 65 | + .add(guiParams, "intensity", 0, 10.0, 0.1) |
| 66 | + .name("Deformation Strength") |
| 67 | + .onChange((value) => { |
| 68 | + dragIntensity.value = value; |
| 69 | + if (splatMesh) { |
| 70 | + splatMesh.updateVersion(); |
| 71 | + } |
| 72 | + }); |
| 73 | +gui |
| 74 | + .add(guiParams, "radius", 0.25, 1.0, 0.1) |
| 75 | + .name("Drag Radius") |
| 76 | + .onChange((value) => { |
| 77 | + dragRadius.value = value; |
| 78 | + if (splatMesh) { |
| 79 | + splatMesh.updateVersion(); |
| 80 | + } |
| 81 | + }); |
| 82 | +gui |
| 83 | + .add(guiParams, "bounceAmount", 0, 1.0, 0.1) |
| 84 | + .name("Bounce Strength") |
| 85 | + .onChange((value) => { |
| 86 | + bounceAmount.value = value; |
| 87 | + if (splatMesh) { |
| 88 | + splatMesh.updateVersion(); |
| 89 | + } |
| 90 | + }); |
| 91 | +gui |
| 92 | + .add(guiParams, "bounceSpeed", 0, 1.0, 0.01) |
| 93 | + .name("Bounce Speed") |
| 94 | + .onChange((value) => { |
| 95 | + bounceSpeed.value = value; |
| 96 | + if (splatMesh) { |
| 97 | + splatMesh.updateVersion(); |
| 98 | + } |
| 99 | + }); |
| 100 | + |
| 101 | +let isDragging = false; |
| 102 | +let dragStartPoint = null; |
| 103 | +let currentDragPoint = null; |
| 104 | +const raycaster = new THREE.Raycaster(); |
| 105 | +raycaster.params.Points = { threshold: 0.5 }; |
| 106 | + |
| 107 | +function createDragBounceDynoshader() { |
| 108 | + return dyno.dynoBlock( |
| 109 | + { gsplat: dyno.Gsplat }, |
| 110 | + { gsplat: dyno.Gsplat }, |
| 111 | + ({ gsplat }) => { |
| 112 | + const shader = new dyno.Dyno({ |
| 113 | + inTypes: { |
| 114 | + gsplat: dyno.Gsplat, |
| 115 | + dragPoint: "vec3", |
| 116 | + dragDisplacement: "vec3", |
| 117 | + dragRadius: "float", |
| 118 | + dragActive: "float", |
| 119 | + bounceTime: "float", |
| 120 | + bounceBaseDisplacement: "vec3", |
| 121 | + dragIntensity: "float", |
| 122 | + bounceAmount: "float", |
| 123 | + bounceSpeed: "float", |
| 124 | + }, |
| 125 | + outTypes: { gsplat: dyno.Gsplat }, |
| 126 | + statements: ({ inputs, outputs }) => |
| 127 | + dyno.unindentLines(` |
| 128 | + ${outputs.gsplat} = ${inputs.gsplat}; |
| 129 | + vec3 originalPos = ${inputs.gsplat}.center; |
| 130 | + |
| 131 | + // Calculate influence based on distance from drag point |
| 132 | + float distToDrag = distance(originalPos, ${inputs.dragPoint}); |
| 133 | + float dragInfluence = 1.0 - smoothstep(0.0, ${inputs.dragRadius}*2., distToDrag); |
| 134 | + float time = ${inputs.bounceTime}; |
| 135 | +
|
| 136 | + // Apply drag deformation |
| 137 | + if (${inputs.dragActive} > 0.5 && ${inputs.dragRadius} > 0.0) { |
| 138 | + vec3 dragOffset = ${inputs.dragDisplacement} * dragInfluence * ${inputs.dragIntensity} * 50.0; |
| 139 | + originalPos += dragOffset; |
| 140 | + } |
| 141 | + |
| 142 | + // Apply elastic bounce effect |
| 143 | + float bounceFrequency = 1.0 + ${inputs.bounceSpeed} * 8.0; |
| 144 | + vec3 bounceOffset = ${inputs.bounceBaseDisplacement} * dragInfluence * ${inputs.dragIntensity} * 50.0; |
| 145 | + originalPos += bounceOffset * cos(time*bounceFrequency) * exp(-time*2.0*(1.0-${inputs.bounceAmount}*.9)); |
| 146 | +
|
| 147 | + ${outputs.gsplat}.center = originalPos; |
| 148 | + `), |
| 149 | + }); |
| 150 | + |
| 151 | + return { |
| 152 | + gsplat: shader.apply({ |
| 153 | + gsplat, |
| 154 | + dragPoint: dragPoint, |
| 155 | + dragDisplacement: dragDisplacement, |
| 156 | + dragRadius: dragRadius, |
| 157 | + dragActive: dragActive, |
| 158 | + bounceTime: bounceTime, |
| 159 | + bounceBaseDisplacement: bounceBaseDisplacement, |
| 160 | + dragIntensity: dragIntensity, |
| 161 | + bounceAmount: bounceAmount, |
| 162 | + bounceSpeed: bounceSpeed, |
| 163 | + }).gsplat, |
| 164 | + }; |
| 165 | + }, |
| 166 | + ); |
| 167 | +} |
| 168 | + |
| 169 | +let splatMesh = null; |
| 170 | + |
| 171 | +async function loadSplat() { |
| 172 | + const splatURL = await getAssetFileURL("penguin.spz"); |
| 173 | + splatMesh = new SplatMesh({ url: splatURL }); |
| 174 | + splatMesh.quaternion.set(1, 0, 0, 0); |
| 175 | + splatMesh.position.set(0, 0, 0); |
| 176 | + scene.add(splatMesh); |
| 177 | + |
| 178 | + await splatMesh.initialized; |
| 179 | + |
| 180 | + splatMesh.worldModifier = createDragBounceDynoshader(); |
| 181 | + splatMesh.updateGenerator(); |
| 182 | +} |
| 183 | + |
| 184 | +loadSplat().catch((error) => { |
| 185 | + console.error("Error loading splat:", error); |
| 186 | +}); |
| 187 | + |
| 188 | +// Convert mouse coordinates to normalized device coordinates |
| 189 | +function getMouseNDC(event) { |
| 190 | + const rect = renderer.domElement.getBoundingClientRect(); |
| 191 | + return new THREE.Vector2( |
| 192 | + ((event.clientX - rect.left) / rect.width) * 2 - 1, |
| 193 | + -((event.clientY - rect.top) / rect.height) * 2 + 1, |
| 194 | + ); |
| 195 | +} |
| 196 | + |
| 197 | +// Raycast to find intersection point on splat |
| 198 | +function getHitPoint(ndc) { |
| 199 | + if (!splatMesh) return null; |
| 200 | + raycaster.setFromCamera(ndc, camera); |
| 201 | + const hits = raycaster.intersectObject(splatMesh, false); |
| 202 | + if (hits && hits.length > 0) { |
| 203 | + return hits[0].point.clone(); |
| 204 | + } |
| 205 | + return null; |
| 206 | +} |
| 207 | + |
| 208 | +let dragStartNDC = null; |
| 209 | +let dragScale = 1.0; |
| 210 | + |
| 211 | +renderer.domElement.addEventListener("pointerdown", (event) => { |
| 212 | + if (!splatMesh) return; |
| 213 | + |
| 214 | + const ndc = getMouseNDC(event); |
| 215 | + const hitPoint = getHitPoint(ndc); |
| 216 | + |
| 217 | + if (hitPoint) { |
| 218 | + isDragging = true; |
| 219 | + dragStartNDC = ndc.clone(); |
| 220 | + dragStartPoint = hitPoint.clone(); |
| 221 | + currentDragPoint = hitPoint.clone(); |
| 222 | + |
| 223 | + // Calculate scale factor for screen-to-world conversion |
| 224 | + const distanceToCamera = camera.position.distanceTo(hitPoint); |
| 225 | + const fov = camera.fov * (Math.PI / 180); |
| 226 | + const screenHeight = 2.0 * Math.tan(fov / 2.0) * distanceToCamera; |
| 227 | + dragScale = screenHeight / window.innerHeight; |
| 228 | + |
| 229 | + dragPoint.value.copy(hitPoint); |
| 230 | + dragActive.value = 1.0; |
| 231 | + dragRadius.value = guiParams.radius; |
| 232 | + dragDisplacement.value.set(0, 0, 0); |
| 233 | + |
| 234 | + bounceTime.value = -1.0; |
| 235 | + bounceBaseDisplacement.value.set(0, 0, 0); |
| 236 | + isBouncing = false; |
| 237 | + } |
| 238 | +}); |
| 239 | + |
| 240 | +renderer.domElement.addEventListener("pointermove", (event) => { |
| 241 | + if (!isDragging || !splatMesh || !dragStartPoint || !dragStartNDC) return; |
| 242 | + |
| 243 | + const ndc = getMouseNDC(event); |
| 244 | + |
| 245 | + // Convert screen space movement to world space |
| 246 | + const mouseDelta = new THREE.Vector2( |
| 247 | + (ndc.x - dragStartNDC.x) * dragScale, |
| 248 | + (ndc.y - dragStartNDC.y) * dragScale, |
| 249 | + ); |
| 250 | + |
| 251 | + const cameraRight = new THREE.Vector3(); |
| 252 | + const cameraUp = new THREE.Vector3(); |
| 253 | + camera.getWorldDirection(new THREE.Vector3()); |
| 254 | + cameraRight.setFromMatrixColumn(camera.matrixWorld, 0).normalize(); |
| 255 | + cameraUp.setFromMatrixColumn(camera.matrixWorld, 1).normalize(); |
| 256 | + |
| 257 | + const worldDisplacement = new THREE.Vector3() |
| 258 | + .addScaledVector(cameraRight, mouseDelta.x) |
| 259 | + .addScaledVector(cameraUp, mouseDelta.y); |
| 260 | + |
| 261 | + currentDragPoint = dragStartPoint.clone().add(worldDisplacement); |
| 262 | + dragDisplacement.value.copy(worldDisplacement); |
| 263 | +}); |
| 264 | + |
| 265 | +renderer.domElement.addEventListener("pointerup", (event) => { |
| 266 | + if (!isDragging) return; |
| 267 | + |
| 268 | + isDragging = false; |
| 269 | + |
| 270 | + // Start bounce animation with final displacement |
| 271 | + if (currentDragPoint && dragStartPoint) { |
| 272 | + const finalDisplacement = currentDragPoint.clone().sub(dragStartPoint); |
| 273 | + bounceBaseDisplacement.value.copy(dragDisplacement.value); |
| 274 | + bounceTime.value = 0.0; |
| 275 | + isBouncing = true; |
| 276 | + } |
| 277 | + |
| 278 | + dragActive.value = 0.0; |
| 279 | + dragDisplacement.value.set(0, 0, 0); |
| 280 | + dragStartNDC = null; |
| 281 | +}); |
| 282 | + |
| 283 | +renderer.setAnimationLoop(() => { |
| 284 | + // Update bounce animation |
| 285 | + if (isBouncing) { |
| 286 | + bounceTime.value += 0.1; |
| 287 | + if (splatMesh) { |
| 288 | + splatMesh.updateVersion(); |
| 289 | + } |
| 290 | + } |
| 291 | + |
| 292 | + // Keyboard controls |
| 293 | + if (keys.a) { |
| 294 | + rotationAngle -= rotationSpeed; |
| 295 | + } |
| 296 | + if (keys.d) { |
| 297 | + rotationAngle += rotationSpeed; |
| 298 | + } |
| 299 | + |
| 300 | + if (keys.w) { |
| 301 | + zoomDistance = Math.max(minZoom, zoomDistance - zoomSpeed); |
| 302 | + } |
| 303 | + if (keys.s) { |
| 304 | + zoomDistance = Math.min(maxZoom, zoomDistance + zoomSpeed); |
| 305 | + } |
| 306 | + |
| 307 | + // Update camera orbit |
| 308 | + camera.position.x = Math.sin(rotationAngle) * zoomDistance; |
| 309 | + camera.position.z = Math.cos(rotationAngle) * zoomDistance; |
| 310 | + camera.position.y = 3; |
| 311 | + camera.lookAt(0, 1.5, 0); |
| 312 | + |
| 313 | + if (splatMesh) { |
| 314 | + splatMesh.updateVersion(); |
| 315 | + } |
| 316 | + |
| 317 | + renderer.render(scene, camera); |
| 318 | +}); |
0 commit comments