mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
937262b3ae
* Interactive deform effect with bounce * Added bounce speed control to interactive deform effect
319 lines
9.0 KiB
JavaScript
319 lines
9.0 KiB
JavaScript
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);
|
|
});
|