Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions examples/interactive-deform/index.html
Original file line number Diff line number Diff line change
@@ -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>

318 changes: 318 additions & 0 deletions examples/interactive-deform/main.js
Original file line number Diff line number Diff line change
@@ -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);
});