Skip to content

Commit 937262b

Browse files
authored
Interactive deform effect with bounce (#223)
* Interactive deform effect with bounce * Added bounce speed control to interactive deform effect
1 parent c9c488c commit 937262b

File tree

2 files changed

+367
-0
lines changed

2 files changed

+367
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<!DOCTYPE html>
2+
<html>
3+
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Spark • Splat Experiment</title>
8+
<style>
9+
body {
10+
margin: 0;
11+
overflow: hidden;
12+
font-family: Arial, sans-serif;
13+
background: #000;
14+
}
15+
header {
16+
position: absolute;
17+
color: silver;
18+
font-family: sans-serif;
19+
padding: 12px 16px;
20+
text-align: left;
21+
width: 100vw;
22+
pointer-events: none;
23+
text-shadow:
24+
2px 2px 4px rgba(0, 0, 0, 0.8),
25+
-1px -1px 2px rgba(0, 0, 0, 0.6),
26+
1px -1px 2px rgba(0, 0, 0, 0.6),
27+
-1px 1px 2px rgba(0, 0, 0, 0.6);
28+
-webkit-text-stroke: 0.5px rgba(0, 0, 0, 0.7);
29+
}
30+
</style>
31+
</head>
32+
33+
<body>
34+
<header>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</header>
35+
<script type="importmap">
36+
{
37+
"imports": {
38+
"three": "/examples/js/vendor/three/build/three.module.js",
39+
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
40+
"@sparkjsdev/spark": "/dist/spark.module.js",
41+
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js"
42+
}
43+
}
44+
</script>
45+
<script type="module" src="main.js"></script>
46+
</body>
47+
48+
</html>
49+
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
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

Comments
 (0)