mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
Interactive deform effect with bounce (#223)
* Interactive deform effect with bounce * Added bounce speed control to interactive deform effect
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spark • Splat Experiment</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #000;
|
||||
}
|
||||
header {
|
||||
position: absolute;
|
||||
color: silver;
|
||||
font-family: sans-serif;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
width: 100vw;
|
||||
pointer-events: none;
|
||||
text-shadow:
|
||||
2px 2px 4px rgba(0, 0, 0, 0.8),
|
||||
-1px -1px 2px rgba(0, 0, 0, 0.6),
|
||||
1px -1px 2px rgba(0, 0, 0, 0.6),
|
||||
-1px 1px 2px rgba(0, 0, 0, 0.6);
|
||||
-webkit-text-stroke: 0.5px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<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>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
|
||||
"@sparkjsdev/spark": "/dist/spark.module.js",
|
||||
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user