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
30 changes: 30 additions & 0 deletions examples/interactive-ripples/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>spark | splat-shockwave</title>
<style>
html, body, #canvas { width: 100%; height: 100%; margin: 0; padding: 0; display: block; }
body { background: #000; overflow: hidden; }
.legend {
position: absolute;
top: 20px;
left: 20px;
color: white;
font-family: Arial, sans-serif;
font-size: 16px;
background: rgba(0, 0, 0, 0.7);
padding: 10px 15px;
border-radius: 5px;
pointer-events: none;
z-index: 100;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="legend">Click to generate ripples • WASD + Mouse to move camera</div>
<script type="module" src="./main.js"></script>
</body>
</html>
156 changes: 156 additions & 0 deletions examples/interactive-ripples/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
SparkControls,
SparkRenderer,
SplatMesh,
dyno,
} from "@sparkjsdev/spark";
import * as THREE from "three";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";

const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.setClearColor(0x000000, 1);

const scene = new THREE.Scene();
const spark = new SparkRenderer({ renderer });
scene.add(spark);

const camera = new THREE.PerspectiveCamera(
50,
canvas.clientWidth / canvas.clientHeight,
0.01,
2000,
);
camera.position.set(0, 0, 3);
camera.lookAt(0, 0, 0);
scene.add(camera);

function handleResize() {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener("resize", handleResize);

// Camera controls with mouse and WASD enabled
const controls = new SparkControls({ canvas: renderer.domElement });
controls.fpsMovement.enable = true; // Enable WASD movement
controls.pointerControls.enable = true; // Enable mouse controls

// Dyno shader with time and shockwave function
function passthroughDyno(timeUniform, hitpointUniform) {
return dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
const shader = new dyno.Dyno({
inTypes: {
gsplat: dyno.Gsplat,
time: "float",
hitpoint: "vec3",
},
outTypes: { gsplat: dyno.Gsplat },
globals: () => [
dyno.unindent(`
vec3 shockwave(vec3 center, float t, vec3 hitpoint) {
vec3 direction = center - hitpoint;
float distance = length(direction);
center += normalize(direction)*sin(t*4.-distance*5.)*exp(-t)*smoothstep(t*2.,0.,distance)*.5;
return center;
}
vec4 shockwaveColor(vec4 rgba, vec3 center, float t, vec3 hitpoint) {
vec3 direction = center - hitpoint;
float distance = length(direction);
float wave = sin(t*4.-distance*5.)*exp(-t*.7)*smoothstep(t*2.,0.,distance);
float brightness = pow(abs(wave),3.) * 10.; // Increase brightness on wave crests
rgba.rgb += brightness;
return rgba;
}
`),
],
statements: ({ inputs, outputs }) =>
dyno.unindentLines(`
${outputs.gsplat} = ${inputs.gsplat};
// Apply shockwave function to position
${outputs.gsplat}.center = shockwave(${inputs.gsplat}.center, ${inputs.time}, ${inputs.hitpoint});
// Apply shockwave function to color
${outputs.gsplat}.rgba = shockwaveColor(${inputs.gsplat}.rgba, ${inputs.gsplat}.center, ${inputs.time}, ${inputs.hitpoint});
`),
});
return {
gsplat: shader.apply({
gsplat,
time: timeUniform,
hitpoint: hitpointUniform,
}).gsplat,
};
},
);
}

async function run() {
// Time and hitpoint uniforms for dyno shader
const timeUniform = dyno.dynoFloat(0.0);
const hitpointUniform = dyno.dynoVec3(new THREE.Vector3(0, 0, 1000)); // Initialize far away to avoid initial effect

// Load valley.spz
const splatURL = await getAssetFileURL("valley.spz");
const valley = new SplatMesh({ url: splatURL });
await valley.initialized;

// Fix orientation - rotate 180 degrees around X axis
valley.rotateX(Math.PI);

// Apply dyno shader with time and hitpoint uniforms
valley.objectModifier = passthroughDyno(timeUniform, hitpointUniform);
valley.updateGenerator();

scene.add(valley);

// Raycaster for click detection
const raycaster = new THREE.Raycaster();
raycaster.params.Points = { threshold: 1.0 }; // Increased threshold for better hit detection

// Simple time counter that resets on click
let timeCounter = 0;

// Click event listener to set hitpoint and reset time
renderer.domElement.addEventListener("pointerdown", (event) => {
const rect = renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObject(valley, false);
const hit = hits?.length ? hits[0] : null;

if (!hit) {
return;
}

const localPoint = valley.worldToLocal(hit.point.clone());
// Don't invert Y or Z - keep original coordinates

hitpointUniform.value.copy(localPoint);
timeCounter = 0; // Reset time counter
});

renderer.setAnimationLoop((timeMs) => {
// Increment time counter each frame
timeCounter += 0.016; // ~60fps increment
timeUniform.value = timeCounter;

// Update dyno uniforms to propagate to the mesh each frame
valley.updateVersion();

controls.update(camera);
renderer.render(scene, camera);
});
}

run();