From 1ecabe08a0f5546a8efcdc92185a431b3ae52f5c Mon Sep 17 00:00:00 2001
From: kali <64813012+kali-shade@users.noreply.github.com>
Date: Thu, 16 Oct 2025 15:19:20 -0300
Subject: [PATCH] Interactive ripples effect (#194)
---
examples/interactive-ripples/index.html | 30 +++++
examples/interactive-ripples/main.js | 156 ++++++++++++++++++++++++
2 files changed, 186 insertions(+)
create mode 100644 examples/interactive-ripples/index.html
create mode 100644 examples/interactive-ripples/main.js
diff --git a/examples/interactive-ripples/index.html b/examples/interactive-ripples/index.html
new file mode 100644
index 0000000..94fbb81
--- /dev/null
+++ b/examples/interactive-ripples/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+ spark | splat-shockwave
+
+
+
+
+ Click to generate ripples • WASD + Mouse to move camera
+
+
+
diff --git a/examples/interactive-ripples/main.js b/examples/interactive-ripples/main.js
new file mode 100644
index 0000000..0dc848f
--- /dev/null
+++ b/examples/interactive-ripples/main.js
@@ -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();