Skip to content

Commit f432de1

Browse files
committed
Interactive deform effect with bounce
1 parent c9c488c commit f432de1

2 files changed

Lines changed: 353 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)