From f4a5c38c60a72efc384042f65730a64c47cc5af1 Mon Sep 17 00:00:00 2001 From: kali-shade Date: Tue, 16 Sep 2025 08:51:52 -0300 Subject: [PATCH 01/13] Transition effects --- examples/splat-transitions/explosion.html | 827 ++++++++++++++++++++++ examples/splat-transitions/flow.html | 259 +++++++ examples/splat-transitions/index.html | 457 ++++-------- examples/splat-transitions/spheric.html | 380 ++++++++++ 4 files changed, 1591 insertions(+), 332 deletions(-) create mode 100644 examples/splat-transitions/explosion.html create mode 100644 examples/splat-transitions/flow.html create mode 100644 examples/splat-transitions/spheric.html diff --git a/examples/splat-transitions/explosion.html b/examples/splat-transitions/explosion.html new file mode 100644 index 0000000..06e8d83 --- /dev/null +++ b/examples/splat-transitions/explosion.html @@ -0,0 +1,827 @@ + + + + + + Spark • Gravity Bounce Effect + + + + + + + + \ No newline at end of file diff --git a/examples/splat-transitions/flow.html b/examples/splat-transitions/flow.html new file mode 100644 index 0000000..4e237ff --- /dev/null +++ b/examples/splat-transitions/flow.html @@ -0,0 +1,259 @@ + + + + + + Spark • Splat Flow + + + + + + + + diff --git a/examples/splat-transitions/index.html b/examples/splat-transitions/index.html index 6a9ab8f..ceeed5f 100644 --- a/examples/splat-transitions/index.html +++ b/examples/splat-transitions/index.html @@ -1,378 +1,171 @@ - - - Spark • Splat Transitions + + + Spark • Splat Transitions - Effects Showcase - + +
Loading effect...
+ +
+ +
+ diff --git a/examples/splat-transitions/spheric.html b/examples/splat-transitions/spheric.html new file mode 100644 index 0000000..6a9ab8f --- /dev/null +++ b/examples/splat-transitions/spheric.html @@ -0,0 +1,380 @@ + + + + + + Spark • Splat Transitions + + + + + + + + From 2c5fd0921eba0ccc8fb0b422e7240417f6f98b5b Mon Sep 17 00:00:00 2001 From: kali-shade Date: Thu, 18 Sep 2025 21:09:34 -0300 Subject: [PATCH 02/13] Added two transition effects and refactored code into separate .js filesAdded two transition effects and refactorized code for effects in separate .js files --- .../splat-transitions/effects/explosion.js | 310 +++++++ examples/splat-transitions/effects/flow.js | 211 +++++ examples/splat-transitions/effects/spheric.js | 223 +++++ examples/splat-transitions/explosion.html | 827 ------------------ examples/splat-transitions/flow.html | 259 ------ examples/splat-transitions/index.html | 175 +--- examples/splat-transitions/main.js | 89 ++ 7 files changed, 849 insertions(+), 1245 deletions(-) create mode 100644 examples/splat-transitions/effects/explosion.js create mode 100644 examples/splat-transitions/effects/flow.js create mode 100644 examples/splat-transitions/effects/spheric.js delete mode 100644 examples/splat-transitions/explosion.html delete mode 100644 examples/splat-transitions/flow.html create mode 100644 examples/splat-transitions/main.js diff --git a/examples/splat-transitions/effects/explosion.js b/examples/splat-transitions/effects/explosion.js new file mode 100644 index 0000000..3b102c1 --- /dev/null +++ b/examples/splat-transitions/effects/explosion.js @@ -0,0 +1,310 @@ +import * as THREE from "three"; +import { dyno, SparkRenderer, SplatMesh, textSplats, SparkControls } from "@sparkjsdev/spark"; +import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; +import { getAssetFileURL } from "/examples/js/get-asset-url.js"; + +export async function init({ THREE: _THREE, scene, camera, renderer, spark }) { + const group = new THREE.Group(); + scene.add(group); + + // Basic lights + const ambient = new THREE.AmbientLight(0x404040, 0.6); + group.add(ambient); + const dir = new THREE.DirectionalLight(0xffffff, 1.0); + dir.position.set(5, 10, 5); + dir.castShadow = true; + group.add(dir); + + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + + // Camera baseline + camera.position.set(0, 2.5, 7); + camera.lookAt(0, 1, 0); + + // WASD + mouse controls + const controls = new SparkControls({ canvas: renderer.domElement }); + controls.fpsMovement.moveSpeed = 3.0; + + // Uniforms + const animationTime = dyno.dynoFloat(0.0); + const uDropProgress = dyno.dynoFloat(0.0); + const uGravity = dyno.dynoFloat(9.8); + const uBounceDamping = dyno.dynoFloat(0.4); + const uFloorLevel = dyno.dynoFloat(0.0); + const uRandomFactor = dyno.dynoFloat(1.0); + const uReformSpeed = dyno.dynoFloat(2.0); + const uCycleDuration = dyno.dynoFloat(1.0); + const uDropTime = dyno.dynoFloat(0.0); + const uFriction = dyno.dynoFloat(0.98); + const uShrinkSpeed = dyno.dynoFloat(5.0 - 3.0); + const uExplosionStrength = dyno.dynoFloat(4.5); + const uIsReforming = dyno.dynoFloat(0.0); + const uReformTime = dyno.dynoFloat(0.0); + const uReformDuration = dyno.dynoFloat(2.0); + + const uIsBirthing = dyno.dynoFloat(0.0); + const uBirthTime = dyno.dynoFloat(0.0); + const uBirthDuration = dyno.dynoFloat(0.5); + + function createDeathDynoshader() { + return dyno.dynoBlock( + { gsplat: dyno.Gsplat }, + { gsplat: dyno.Gsplat }, + ({ gsplat }) => { + const physicsShader = new dyno.Dyno({ + inTypes: { gsplat: dyno.Gsplat, time: "float", dropTime: "float", dropProgress: "float", gravity: "float", bounceDamping: "float", floorLevel: "float", randomFactor: "float", reformSpeed: "float", cycleDuration: "float", friction: "float", shrinkSpeed: "float", explosionStrength: "float", isReforming: "float", reformTime: "float", reformDuration: "float" }, + outTypes: { gsplat: dyno.Gsplat }, + globals: () => [dyno.unindent(` + mat2 rot(float angle) { float c = cos(angle); float s = sin(angle); return mat2(c, -s, s, c); } + float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); } + vec3 simulatePhysics(vec3 originalPos, float dropTime, float progress, float gravity, float damping, float floorLevel, float randomOffset, float friction, float explosionStrength) { + if (progress <= 0.0) return originalPos; + float timeVariation = hash(originalPos + vec3(42.0)) * 0.2 - 0.1; + float t = max(0.0, dropTime + timeVariation); + vec3 initialVelocity = vec3( + (hash(originalPos + vec3(1.0)) - 0.5) * explosionStrength * (0.3 + hash(originalPos + vec3(10.0)) * 0.4), + abs(hash(originalPos + vec3(3.0))) * explosionStrength * (0.8 + hash(originalPos + vec3(20.0)) * 0.4) + 0.5, + (hash(originalPos + vec3(2.0)) - 0.5) * explosionStrength * (0.3 + hash(originalPos + vec3(30.0)) * 0.4) + ); + float frictionDecay = pow(friction, t * 60.0); + vec3 position = originalPos; + position.x += initialVelocity.x * (1.0 - frictionDecay) / (1.0 - friction) / 60.0; + position.z += initialVelocity.z * (1.0 - frictionDecay) / (1.0 - friction) / 60.0; + position.y += initialVelocity.y * t - 0.5 * gravity * t * t; + if (position.y <= floorLevel) { + float bounceTime = t; + float bounceCount = floor(bounceTime * 3.0); + float timeSinceBounce = bounceTime - bounceCount / 3.0; + float bounceHeight = initialVelocity.y * pow(damping, bounceCount) * max(0.0, 1.0 - timeSinceBounce * 3.0); + if (bounceHeight > 0.1) { + position.y = floorLevel + abs(sin(timeSinceBounce * 3.14159 * 3.0)) * bounceHeight; + } else { + position.y = floorLevel; + float scatterFactor = hash(originalPos + vec3(50.0)) * 0.2; + position.x += (hash(originalPos + vec3(60.0)) - 0.5) * scatterFactor; + position.z += (hash(originalPos + vec3(70.0)) - 0.5) * scatterFactor; + } + } + return position; + } + vec3 elegantReform(vec3 currentPos, vec3 originalPos, float reformTime, float duration) { + if (reformTime <= 0.0) return currentPos; + if (reformTime >= duration) return originalPos; + float progress = reformTime / duration; + return mix(currentPos, originalPos, progress); + } + vec3 reformScale(vec3 currentScale, vec3 originalScale, float reformTime, float duration) { + if (reformTime <= 0.0) return currentScale; + if (reformTime >= duration) return originalScale; + float progress = reformTime / duration; + float easeOut = 1.0 - pow(1.0 - progress, 2.0); + return mix(currentScale, originalScale, easeOut); + } + `)], + statements: ({ inputs, outputs }) => dyno.unindentLines(` + ${outputs.gsplat} = ${inputs.gsplat}; + vec3 originalPos = ${inputs.gsplat}.center; + vec3 originalScale = ${inputs.gsplat}.scales; + vec3 physicsPos = originalPos; + vec3 currentScale = originalScale; + if (${inputs.dropProgress} > 0.0) { + float randomOffset = hash(originalPos) * ${inputs.randomFactor}; + physicsPos = simulatePhysics(originalPos, ${inputs.dropTime}, ${inputs.dropProgress}, ${inputs.gravity}, ${inputs.bounceDamping}, ${inputs.floorLevel}, randomOffset, ${inputs.friction}, ${inputs.explosionStrength}); + float factor = exp(-${inputs.dropTime} * ${inputs.shrinkSpeed}); + currentScale = mix(originalScale, vec3(0.005), 1.0 - factor); + } + vec3 finalPos = physicsPos; + vec3 finalScale = currentScale; + if (${inputs.isReforming} > 0.5) { + finalPos = elegantReform(physicsPos, originalPos, ${inputs.reformTime}, ${inputs.reformDuration}); + finalScale = reformScale(currentScale, originalScale, ${inputs.reformTime}, ${inputs.reformDuration}); + } + ${outputs.gsplat}.center = finalPos; + ${outputs.gsplat}.scales = finalScale; + `), + }); + gsplat = physicsShader.apply({ gsplat, time: animationTime, dropTime: uDropTime, dropProgress: uDropProgress, gravity: uGravity, bounceDamping: uBounceDamping, floorLevel: uFloorLevel, randomFactor: uRandomFactor, reformSpeed: uReformSpeed, cycleDuration: uCycleDuration, friction: uFriction, shrinkSpeed: uShrinkSpeed, explosionStrength: uExplosionStrength, isReforming: uIsReforming, reformTime: uReformTime, reformDuration: uReformDuration }).gsplat; + return { gsplat }; + } + ); + } + + function createBirthDynoshader() { + return dyno.dynoBlock( + { gsplat: dyno.Gsplat }, + { gsplat: dyno.Gsplat }, + ({ gsplat }) => { + const birthShader = new dyno.Dyno({ + inTypes: { gsplat: dyno.Gsplat, time: "float", isBirthing: "float", birthTime: "float", birthDuration: "float" }, + outTypes: { gsplat: dyno.Gsplat }, + globals: () => [dyno.unindent(` + float hash(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); } + `)], + statements: ({ inputs, outputs }) => dyno.unindentLines(` + ${outputs.gsplat} = ${inputs.gsplat}; + vec3 originalPos = ${inputs.gsplat}.center; + vec3 originalScale = ${inputs.gsplat}.scales; + if (${inputs.isBirthing} > 0.5 && ${inputs.birthTime} < ${inputs.birthDuration}) { + float progress = ${inputs.birthTime} / ${inputs.birthDuration}; + float birthOffset = hash(originalPos) * 0.1; + float adjusted = clamp((progress - birthOffset / ${inputs.birthDuration}) / (1.0 - birthOffset / ${inputs.birthDuration}), 0.0, 1.0); + float ease = pow(adjusted * adjusted * (3.0 - 2.0 * adjusted), 0.6); + vec3 birthPos = mix(vec3(0.0), originalPos, ease); + vec3 birthScale = mix(vec3(0.0), originalScale, ease); + ${outputs.gsplat}.center = birthPos; + ${outputs.gsplat}.scales = birthScale; + float alpha = ${inputs.gsplat}.rgba.a * ease; + ${outputs.gsplat}.rgba.a = alpha; + } + `), + }); + gsplat = birthShader.apply({ gsplat, time: animationTime, isBirthing: uIsBirthing, birthTime: uBirthTime, birthDuration: uBirthDuration }).gsplat; + return { gsplat }; + } + ); + } + + const splatMeshes = {}; + let currentSplatName = 'penguin'; + let nextSplatName = 'cat'; + + async function loadSplats() { + const splatNames = ['penguin.spz', 'cat.spz', 'woobles.spz']; + for (const splatName of splatNames) { + const splatURL = await getAssetFileURL(splatName); + const mesh = new SplatMesh({ url: splatURL }); + await mesh.initialized; + const nameKey = splatName.replace('.spz', ''); + mesh.worldModifier = createDeathDynoshader(); + mesh.updateGenerator(); + mesh.position.set(0, 0, 0); + mesh.rotation.set(Math.PI, 0, 0); + if (nameKey === 'woobles') mesh.scale.set(1.7, 2.0, 1.7); else mesh.scale.set(1, 1, 1); + mesh.visible = (nameKey === currentSplatName); + group.add(mesh); + splatMeshes[nameKey] = mesh; + } + } + + async function loadTable() { + const tableURL = await getAssetFileURL("table.glb"); + const loader = new GLTFLoader(); + await new Promise((resolve, reject) => { + loader.load(tableURL, (gltf) => { + const tableModel = gltf.scene; + tableModel.position.set(0, -0.5, 0); + tableModel.scale.set(3.5, 3.5, 3.5); + tableModel.rotation.set(0, 0, 0); + tableModel.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } }); + group.add(tableModel); + resolve(tableModel); + }, undefined, reject); + }); + } + + function switchToSplat(name) { + Object.values(splatMeshes).forEach(m => { if (m) m.visible = false; }); + if (splatMeshes[name]) { splatMeshes[name].visible = true; currentSplatName = name; } + } + + function getNextSplatName(current) { + const order = ['penguin', 'cat', 'woobles']; + return order[(order.indexOf(current) + 1) % order.length]; + } + + const transitionState = { isTransitioning: false, transitionTime: 0.0, transitionDuration: 3.0 }; + + function startExplosion() { + if (transitionState.isTransitioning) return; + transitionState.isTransitioning = true; + transitionState.transitionTime = 0.0; + Object.entries(splatMeshes).forEach(([name, mesh]) => { if (mesh) mesh.visible = (name === currentSplatName); }); + if (splatMeshes[currentSplatName]) { + splatMeshes[currentSplatName].worldModifier = createDeathDynoshader(); + splatMeshes[currentSplatName].updateGenerator(); + } + uDropProgress.value = 1.0; + uDropTime.value = 0.0; + uIsReforming.value = 0.0; + } + + function startTransition() { + startExplosion(); + nextSplatName = getNextSplatName(currentSplatName); + if (splatMeshes[nextSplatName]) { + Object.entries(splatMeshes).forEach(([name, mesh]) => { if (!mesh) return; if (name !== currentSplatName && name !== nextSplatName) mesh.visible = false; }); + splatMeshes[nextSplatName].worldModifier = createBirthDynoshader(); + splatMeshes[nextSplatName].updateGenerator(); + splatMeshes[nextSplatName].visible = true; + uIsBirthing.value = 1.0; + uBirthTime.value = 0.0; + } + } + + function completeTransition() { + uIsBirthing.value = 0.0; + uBirthTime.value = 0.0; + currentSplatName = nextSplatName; + transitionState.isTransitioning = false; + transitionState.transitionTime = 0.0; + } + + await Promise.all([loadSplats(), loadTable()]); + + // Instructional text + const instructionsText = textSplats({ text: "WASD + mouse to move\nSPACEBAR: Explosion!", font: "Arial", fontSize: 24, color: new THREE.Color(0xFFFFFF), textAlign: "center", lineHeight: 1.3 }); + instructionsText.scale.setScalar(0.15 / 24); + instructionsText.position.set(0, 0.2, 2.5); + group.add(instructionsText); + + let totalTime = 0; + const transitionParams = { autoTransition: false }; + + function onKeyDown(e) { + if (e.code === 'Space') { e.preventDefault(); if (transitionState.isTransitioning) completeTransition(); startTransition(); totalTime = 0; } + } + window.addEventListener('keydown', onKeyDown); + + function update(dt, t) { + animationTime.value = t; + // Update camera controls + controls.update(camera); + if (transitionParams.autoTransition) { + if (!transitionState.isTransitioning) totalTime += dt; + if (!transitionState.isTransitioning && totalTime >= 1.0) { startTransition(); totalTime = 0; } + } + if (transitionState.isTransitioning) { + transitionState.transitionTime += dt; + uBirthTime.value = transitionState.transitionTime; + if (transitionState.transitionTime >= transitionState.transitionDuration) completeTransition(); + } + uDropTime.value += dt; + const dying = splatMeshes[currentSplatName]; + const birthing = splatMeshes[nextSplatName]; + if (transitionState.isTransitioning) { + if (dying) dying.updateVersion(); + if (birthing) birthing.updateVersion(); + } else { + if (dying) dying.updateVersion(); + } + } + + function setupGUI(folder) { + folder.add(transitionParams, 'autoTransition').name('Auto Transition'); + folder.add({ explode: () => startTransition() }, 'explode').name('Trigger Explosion'); + return folder; + } + + function dispose() { + window.removeEventListener('keydown', onKeyDown); + // Disable controls to avoid interfering with other effects + controls.fpsMovement.enable = false; + controls.pointerControls.enable = false; + scene.remove(group); + } + + return { group, update, dispose, setupGUI }; +} + + diff --git a/examples/splat-transitions/effects/flow.js b/examples/splat-transitions/effects/flow.js new file mode 100644 index 0000000..967f4f9 --- /dev/null +++ b/examples/splat-transitions/effects/flow.js @@ -0,0 +1,211 @@ +import * as THREE from "three"; +import { dyno, SparkRenderer, SplatMesh } from "@sparkjsdev/spark"; +import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; +import { GUI } from "lil-gui"; +import { getAssetFileURL } from "/examples/js/get-asset-url.js"; + +export async function init({ THREE: _THREE, scene, camera, renderer, spark }) { + const group = new THREE.Group(); + scene.add(group); + + const PARAMETERS = { speedMultiplier: 1.0, objectRotation: true, pause: false, fixedMinScale: false, waves: 0.5, cameraRotation: true }; + const PAUSE_SECONDS = 2.0; + + function getTransitionState(t, fadeInTime, fadeOutTime, period) { + const one = dyno.dynoFloat(1.0); + const pauseTime = dyno.dynoFloat(PAUSE_SECONDS); + const cycleTime = dyno.add(one, pauseTime); + const total = dyno.mul(period, cycleTime); + const wrapT = dyno.mod(t, total); + const pos = dyno.mod(wrapT, cycleTime); + const inPause = dyno.greaterThan(pos, one); + const normT = dyno.select(inPause, one, pos); + const fadeIn = dyno.and( + dyno.greaterThan(wrapT, dyno.mul(fadeInTime, cycleTime)), + dyno.lessThan(wrapT, dyno.mul(dyno.add(fadeInTime, one), cycleTime)) + ); + const fadeOut = dyno.and( + dyno.greaterThan(wrapT, dyno.mul(fadeOutTime, cycleTime)), + dyno.lessThan(wrapT, dyno.mul(dyno.add(fadeOutTime, one), cycleTime)) + ); + return { inTransition: dyno.or(fadeIn, fadeOut), isFadeIn: fadeIn, normT }; + } + + function contractionDyno(centerGLSL) { + return new dyno.Dyno({ + inTypes: { gsplat: dyno.Gsplat, inTransition: "bool", fadeIn: "bool", t: "float", gt: "float", objectIndex: "int", fixedMinScale: "bool", waves: "float" }, + outTypes: { gsplat: dyno.Gsplat }, + globals: () => [ dyno.unindent(` + float hash13(vec3 p3) { p3 = fract(p3 * .1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } + float hash11(float p) { p = fract(p * .1031); p += dot(p, p + 33.33); return fract(p * p); } + float fadeInOut(float t) { return abs(mix(-1., 1., t)); } + ${centerGLSL} + float applyBrightness(float t) { return .5 + fadeInOut(t) * .5; } + vec3 applyCenter(vec3 center, float t, float id, int idx, float waves) { + int next = (idx + 1) % 3; + vec3 cNext = getCenterOfMass(next); + vec3 cOwn = getCenterOfMass(idx); + float f = fadeInOut(t); + float v = .5 + hash11(id) * 2.; + vec3 p = t < .5 ? mix(cNext, center, pow(f, v)) : mix(cOwn, center, pow(f, v)); + return p + length(sin(p*2.5)) * waves * (1.-f)*smoothstep(0.5,0.,t) * 2.; + } + vec3 applyScale(vec3 s, float t, bool fixedMin) { return mix(fixedMin ? vec3(.02) : s * .2, s, pow(fadeInOut(t), 3.)); } + float applyOpacity(float t, float gt, int idx) { + float p = float(${PAUSE_SECONDS}); + float c = 1.0 + p; + float tot = 3.0 * c; + float w = mod(gt + p + .5, tot); + int cur = int(floor(w / c)); + return cur == idx ? .1+fadeInOut(t) : 0.0; + } + `) ], + statements: ({ inputs, outputs }) => dyno.unindentLines(` + ${outputs.gsplat} = ${inputs.gsplat}; + ${outputs.gsplat}.center = applyCenter(${inputs.gsplat}.center, ${inputs.t}, float(${inputs.gsplat}.index), ${inputs.objectIndex}, ${inputs.waves}); + ${outputs.gsplat}.scales = applyScale(${inputs.gsplat}.scales, ${inputs.t}, ${inputs.fixedMinScale}); + ${outputs.gsplat}.rgba.a *= applyOpacity(${inputs.t}, ${inputs.gt}, ${inputs.objectIndex}); + ${outputs.gsplat}.rgba.rgb *= applyBrightness(${inputs.t}); + `) + }); + } + + function getTransitionModifier(inTrans, fadeIn, t, idx, gt, centerGLSL, fixedMinScale, waves) { + const dyn = contractionDyno(centerGLSL); + return dyno.dynoBlock( + { gsplat: dyno.Gsplat }, + { gsplat: dyno.Gsplat }, + ({ gsplat }) => ({ gsplat: dyn.apply({ gsplat, inTransition: inTrans, fadeIn, t, gt, objectIndex: idx, fixedMinScale, waves }).gsplat }) + ); + } + + async function loadGLB(file, isEnv = false) { + const url = await getAssetFileURL(file); + const loader = new GLTFLoader(); + const gltf = await new Promise((res, rej) => loader.load(url, res, undefined, rej)); + gltf.scene.traverse(child => { + if (child.isMesh && child.material) { + const mat = new THREE.MeshBasicMaterial({ color: child.material.color, map: child.material.map }); + if (isEnv) { + mat.side = THREE.BackSide; + if (mat.map) { + mat.map.mapping = THREE.EquirectangularReflectionMapping; + mat.map.colorSpace = THREE.LinearSRGBColorSpace; + mat.map.needsUpdate = true; + } + } + child.material = mat; + } + }); + return gltf.scene; + } + + const time = dyno.dynoFloat(0.0); + const splatFiles = ["woobles.spz", "dessert.spz", "robot-head.spz"]; + const skyFile = "dali-env.glb"; + + const env = await loadGLB(skyFile, true); + group.add(env); + + const meshes = []; + const period = dyno.dynoFloat(splatFiles.length); + const positions = [ + new THREE.Vector3(-5, -2.2, -3), + new THREE.Vector3(5, -2.5, 0), + new THREE.Vector3(0, 1.5, 2) + ]; + + for (let i = 0; i < splatFiles.length; i++) { + const url = await getAssetFileURL(splatFiles[i]); + const m = new SplatMesh({ url }); + await m.initialized; + m.position.copy(positions[i]); + m.rotateX(Math.PI); + group.add(m); + meshes.push(m); + } + + const centers = meshes.map(m => { + const box = new THREE.Box3(); + m.packedSplats.forEachSplat((_, c) => { box.expandByPoint(c); }); + const localCenter = box.getCenter(new THREE.Vector3()); + localCenter.y = -localCenter.y; + return localCenter.add(m.position); + }); + + const centerGLSL = ` + vec3 getCenterOfMass(int idx) { + if (idx == 0) return vec3(${centers[0].x}, ${centers[0].y}, ${centers[0].z}); + if (idx == 1) return vec3(${centers[1].x}, ${centers[1].y}, ${centers[1].z}); + if (idx == 2) return vec3(${centers[2].x}, ${centers[2].y}, ${centers[2].z}); + return vec3(0.0); + } + `; + + meshes.forEach((m, i) => { + const { inTransition, isFadeIn, normT } = getTransitionState(time, dyno.dynoFloat(i), dyno.dynoFloat((i+1)%splatFiles.length), period); + m.worldModifier = getTransitionModifier(inTransition, isFadeIn, normT, dyno.dynoInt(i), time, centerGLSL, dyno.dynoBool(PARAMETERS.fixedMinScale), dyno.dynoFloat(PARAMETERS.waves)); + m.updateGenerator(); + }); + + function updateCamera() { + const cTime = 1 + PAUSE_SECONDS; + const tot = meshes.length * cTime; + const w = (time.value + PAUSE_SECONDS) % tot; + const cur = Math.floor(w / cTime); + const nxt = (cur + 1) % meshes.length; + const tr = w / cTime - cur; + const s = tr * tr * (3 - 2 * tr); + const tgt = new THREE.Vector3().lerpVectors(meshes[cur].position, meshes[nxt].position, Math.pow(s, 5)); + const radius = 4 + (Math.abs(s-0.5)**2)*20; + let x, z; + if (PARAMETERS.cameraRotation) { + const angle = -time.value * 0.5; + x = Math.cos(angle) * radius; + z = Math.sin(angle) * radius; + } else { + x = 0; z = -radius; + } + const frm = tgt.clone().add(new THREE.Vector3(x, 2, z)); + camera.position.copy(frm); + camera.lookAt(tgt); + } + + function update(dt, t) { + if (!PARAMETERS.pause) { + time.value += dt * PARAMETERS.speedMultiplier; + if (PARAMETERS.objectRotation) meshes.forEach(m => m.rotation.y += dt * PARAMETERS.speedMultiplier * 2); + } + updateCamera(); + } + + function setupGUI(folder) { + folder.add(PARAMETERS, "speedMultiplier", 0.25, 4.0, 0.01); + folder.add(PARAMETERS, "objectRotation"); + folder.add(PARAMETERS, "pause"); + folder.add(PARAMETERS, "cameraRotation"); + folder.add(PARAMETERS, "fixedMinScale").onChange(() => { + meshes.forEach((m, i) => { + const { inTransition, isFadeIn, normT } = getTransitionState(time, dyno.dynoFloat(i), dyno.dynoFloat((i+1)%splatFiles.length), dyno.dynoFloat(splatFiles.length)); + m.worldModifier = getTransitionModifier(inTransition, isFadeIn, normT, dyno.dynoInt(i), time, centerGLSL, dyno.dynoBool(PARAMETERS.fixedMinScale), dyno.dynoFloat(PARAMETERS.waves)); + m.updateGenerator(); + }); + }); + folder.add(PARAMETERS, "waves", 0, 1, 0.01).onChange(() => { + meshes.forEach((m, i) => { + const { inTransition, isFadeIn, normT } = getTransitionState(time, dyno.dynoFloat(i), dyno.dynoFloat((i+1)%splatFiles.length), dyno.dynoFloat(splatFiles.length)); + m.worldModifier = getTransitionModifier(inTransition, isFadeIn, normT, dyno.dynoInt(i), time, centerGLSL, dyno.dynoBool(PARAMETERS.fixedMinScale), dyno.dynoFloat(PARAMETERS.waves)); + m.updateGenerator(); + }); + }); + return folder; + } + + function dispose() { + scene.remove(group); + } + + return { group, update, dispose, setupGUI }; +} + + diff --git a/examples/splat-transitions/effects/spheric.js b/examples/splat-transitions/effects/spheric.js new file mode 100644 index 0000000..bd463ff --- /dev/null +++ b/examples/splat-transitions/effects/spheric.js @@ -0,0 +1,223 @@ +import * as THREE from "three"; +import { dyno, SparkRenderer, SplatMesh } from "@sparkjsdev/spark"; +import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; +import { GUI } from "lil-gui"; +import { getAssetFileURL } from "/examples/js/get-asset-url.js"; + +export async function init({ THREE: _THREE, scene, camera, renderer, spark }) { + const group = new THREE.Group(); + scene.add(group); + + // Params and uniforms + const PARAMETERS = { + splatCoverage: 1.0, + spereRadius: 1.0, + sphereHeight: 2.0, + speedMultiplier: 1.0, + rotation: true, + pause: false, + }; + + const time = dyno.dynoFloat(0.0); + + // Camera baseline + const prevPos = camera.position.clone(); + const prevLook = new THREE.Vector3(); + camera.getWorldDirection(prevLook); + + const SPHERICAL_TARGET = new THREE.Vector3(0, 2, 0); + camera.position.set(5, 4, 7); + camera.lookAt(SPHERICAL_TARGET); + + const skyFile = "dali-env.glb"; + const sceneFile = "dali-table.glb"; + const splatFiles = ["penguin.spz", "dessert.spz", "woobles.spz"]; + + async function loadDelitGLB(filename, isEnv = false) { + const url = await getAssetFileURL(filename); + const gltfLoader = new GLTFLoader(); + const gltf = await new Promise((resolve, reject) => { + gltfLoader.load(url, resolve, undefined, reject); + }); + const root = gltf.scene; + root.traverse((child) => { + if (child.isMesh && child.material) { + const original = child.material; + const basic = new THREE.MeshBasicMaterial(); + if (original.color) basic.color.copy(original.color); + if (original.map) basic.map = original.map; + if (isEnv) { + basic.side = THREE.BackSide; + if (basic.map) { + basic.map.mapping = THREE.EquirectangularReflectionMapping; + basic.map.colorSpace = THREE.LinearSRGBColorSpace; + basic.map.needsUpdate = true; + } + } + child.material = basic; + } + }); + return root; + } + + function getTransitionState(t, fadeInTime, fadeOutTime, period) { + const dynoOne = dyno.dynoFloat(1.0); + const wrapT = dyno.mod(t, period); + const normT = dyno.mod(t, dynoOne); + const isFadeIn = dyno.and( + dyno.greaterThan(wrapT, fadeInTime), + dyno.lessThan(wrapT, dyno.add(fadeInTime, dynoOne)), + ); + const isFadeOut = dyno.and( + dyno.greaterThan(wrapT, fadeOutTime), + dyno.lessThan(wrapT, dyno.add(fadeOutTime, dynoOne)), + ); + const inTransition = dyno.or(isFadeIn, isFadeOut); + return { inTransition, isFadeIn, normT }; + } + + function contractionDyno() { + return new dyno.Dyno({ + inTypes: { + gsplat: dyno.Gsplat, + inTransition: "bool", + fadeIn: "bool", + t: "float", + splatScale: "float", + spereRadius: "float", + sphereHeight: "float", + }, + outTypes: { gsplat: dyno.Gsplat }, + globals: () => [dyno.unindent(` + vec3 applyCenter(vec3 center, float t, float spereRadius, float sphereHeight) { + float heightModifier = 0.5 + 0.5 * pow(abs(1.0 - 2.0*t), 0.2); + vec3 targetCenter = vec3(0.0, heightModifier * sphereHeight, 0.0); + vec3 dir = normalize(center - targetCenter); + vec3 targetPoint = targetCenter + dir * spereRadius; + if (t < 0.25 || t > 0.75) { + return center; + } else if (t < 0.45) { + return mix(center, targetPoint, pow((t - 0.25) * 5.0, 4.0)); + } else if (t < 0.55) { + float churn = 0.1; + float transitionT = (t - 0.45) * 10.0; + float angle = transitionT * 2.0 * PI; + vec3 rotvec = vec3(sin(angle), 0.0, cos(angle)); + float strength = sin(transitionT * PI); + return targetPoint + cross(dir, rotvec) * churn * strength; + } else { + return mix(targetPoint, center, pow((t - 0.55) * 5.0, 4.0)); + } + } + vec3 applyScale(vec3 scales, float t, float targetScale) { + vec3 targetScales = targetScale * vec3(1.0); + if (t < 0.25) return scales; + else if (t < 0.45) return mix(scales, targetScales, pow((t - 0.25) * 5.0, 2.0)); + else if (t < 0.55) return targetScales; + else if (t < 0.75) return mix(targetScales, scales, pow((t - 0.55) * 5.0, 2.0)); + else return scales; + } + float applyOpacity(float opacity, float t, bool fadeIn) { + if (fadeIn) { + if (t < 0.4) return 0.0; + else if (t < 0.6) return mix(0.0, opacity, pow((t - 0.4) * 5.0, 2.0)); + else return opacity; + } else { + if (t < 0.4) return opacity; + else if (t < 0.6) return mix(opacity, 0.0, pow((t - 0.4) * 5.0, 2.0)); + else return 0.0; + } + } + `)], + statements: ({ inputs, outputs }) => dyno.unindentLines(` + ${outputs.gsplat} = ${inputs.gsplat}; + ${outputs.gsplat}.center = applyCenter(${inputs.gsplat}.center, ${inputs.t}, ${inputs.spereRadius}, ${inputs.sphereHeight}); + ${outputs.gsplat}.scales = applyScale(${inputs.gsplat}.scales, ${inputs.t}, ${inputs.splatScale}); + if (${inputs.inTransition}) { + ${outputs.gsplat}.rgba.a = applyOpacity(${inputs.gsplat}.rgba.a, ${inputs.t}, ${inputs.fadeIn}); + } else { + ${outputs.gsplat}.rgba.a = 0.0; + } + `), + }); + } + + function getTransitionModifier(inTransition, fadeIn, t, splatScale, spereRadius, sphereHeight) { + const contraction = contractionDyno(); + return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => { + gsplat = contraction.apply({ gsplat, inTransition, fadeIn, t, splatScale, spereRadius, sphereHeight }).gsplat; + return { gsplat }; + }); + } + + async function morphableSplatMesh(assetName, time, fadeInTime, fadeOutTime, period, splatCoverage, spereRadius, sphereHeight) { + const url = await getAssetFileURL(assetName); + const splatMesh = new SplatMesh({ url }); + await splatMesh.initialized; + const splatScale = dyno.div(dyno.mul(splatCoverage, spereRadius), dyno.dynoFloat(splatMesh.packedSplats.numSplats / 1000.0)); + const { inTransition, isFadeIn, normT } = getTransitionState(time, fadeInTime, fadeOutTime, period); + splatMesh.worldModifier = getTransitionModifier(inTransition, isFadeIn, normT, splatScale, spereRadius, sphereHeight); + splatMesh.updateGenerator(); + splatMesh.quaternion.set(1, 0, 0, 0); + return splatMesh; + } + + // Load env and assets + const sky = await loadDelitGLB(skyFile, true); + group.add(sky); + const table = await loadDelitGLB(sceneFile, false); + const sceneScale = 3.5; + table.scale.set(sceneScale, sceneScale, sceneScale); + table.position.set(-1, 0, -0.8); + group.add(table); + + const period = dyno.dynoFloat(splatFiles.length); + const spereRadiusDyno = dyno.dynoFloat(PARAMETERS.spereRadius); + const splatCoverageDyno = dyno.dynoFloat(PARAMETERS.splatCoverage); + const sphereHeightDyno = dyno.dynoFloat(PARAMETERS.sphereHeight); + + const meshes = []; + for (let i = 0; i < splatFiles.length; i++) { + const mesh = await morphableSplatMesh( + splatFiles[i], + time, + dyno.dynoFloat(i), + dyno.dynoFloat((i + 1) % splatFiles.length), + period, + splatCoverageDyno, + spereRadiusDyno, + sphereHeightDyno, + ); + group.add(mesh); + meshes.push(mesh); + } + + function update(dt, t) { + if (!PARAMETERS.pause) { + time.value += dt * 0.5 * PARAMETERS.speedMultiplier; + if (PARAMETERS.rotation) meshes.forEach(m => { m.rotation.y += dt * PARAMETERS.speedMultiplier; }); + } + // Keep camera centered on spherical target + camera.lookAt(SPHERICAL_TARGET); + } + + function setupGUI(folder) { + folder.add(PARAMETERS, "spereRadius", 0.1, 8.0, 0.01).onChange(v => spereRadiusDyno.value = v); + folder.add(PARAMETERS, "sphereHeight", -1.0, 4.0, 0.01).onChange(v => sphereHeightDyno.value = v); + folder.add(PARAMETERS, "splatCoverage", 0.1, 2.0, 0.01).onChange(v => splatCoverageDyno.value = v); + folder.add(PARAMETERS, "speedMultiplier", 0.25, 4.0, 0.01); + folder.add(PARAMETERS, "rotation"); + folder.add(PARAMETERS, "pause"); + return folder; + } + + function dispose() { + // Remove group + scene.remove(group); + // No global listeners here; controls are managed by main + } + + return { group, update, dispose, setupGUI }; +} + + diff --git a/examples/splat-transitions/explosion.html b/examples/splat-transitions/explosion.html deleted file mode 100644 index 06e8d83..0000000 --- a/examples/splat-transitions/explosion.html +++ /dev/null @@ -1,827 +0,0 @@ - - - - - - Spark • Gravity Bounce Effect - - - - - - - - \ No newline at end of file diff --git a/examples/splat-transitions/flow.html b/examples/splat-transitions/flow.html deleted file mode 100644 index 4e237ff..0000000 --- a/examples/splat-transitions/flow.html +++ /dev/null @@ -1,259 +0,0 @@ - - - - - - Spark • Splat Flow - - - - - - - - diff --git a/examples/splat-transitions/index.html b/examples/splat-transitions/index.html index ceeed5f..87019fa 100644 --- a/examples/splat-transitions/index.html +++ b/examples/splat-transitions/index.html @@ -2,172 +2,29 @@ - - Spark • Splat Transitions - Effects Showcase + + Spark • Splat Transitions (Modular) - - - -
Loading effect...
- -
- -
- - + + +
Loading...
+ + + + diff --git a/examples/splat-transitions/main.js b/examples/splat-transitions/main.js new file mode 100644 index 0000000..c2718f9 --- /dev/null +++ b/examples/splat-transitions/main.js @@ -0,0 +1,89 @@ +import * as THREE from "three"; +import { GUI } from "lil-gui"; +import { SparkRenderer } from "@sparkjsdev/spark"; + +// Central renderer/scene/camera shared by effects +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, 3, 8); +camera.lookAt(0, 0, 0); +scene.add(camera); + +// Resize handling +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); + +// GUI +const gui = new GUI(); +const params = { Effect: "Spherical" }; +const effectFiles = { + Spherical: () => import("./effects/spheric.js"), + Explosion: () => import("./effects/explosion.js"), + Flow: () => import("./effects/flow.js"), +}; + +let active = null; // { api, group } +let last = 0; + +async function switchEffect(name) { + const loading = document.getElementById("loading"); + loading.textContent = `Loading ${name}...`; + loading.style.display = "block"; + + // Dispose previous + if (active) { + try { active.api.dispose?.(); } catch {} + if (active.group) scene.remove(active.group); + active = null; + } + + const loader = effectFiles[name]; + if (!loader) return; + const mod = await loader(); + + const context = { THREE, scene, camera, renderer, spark }; + const api = await mod.init(context); + + if (api.group) scene.add(api.group); + active = { api, group: api.group }; + + // Setup a per-effect GUI folder if exposed + if (api.setupGUI) { + if (active._folder) { try { active._folder.destroy(); } catch {} } + active._folder = api.setupGUI(gui.addFolder(name)); + } + + loading.style.display = "none"; +} + +gui.add(params, "Effect", Object.keys(effectFiles)).onChange(switchEffect); + +// Animation loop +renderer.setAnimationLoop((timeMs) => { + const t = timeMs * 0.001; + const dt = t - (last || t); + last = t; + + if (active?.api?.update) active.api.update(dt, t); + renderer.render(scene, camera); +}); + +// Kickoff +switchEffect(params.Effect); + + From 3833534b601a8b7bc92fb0f8f578cbe45efb1e50 Mon Sep 17 00:00:00 2001 From: kali-shade Date: Thu, 18 Sep 2025 21:12:07 -0300 Subject: [PATCH 03/13] Deleted unused html file --- examples/splat-transitions/spheric.html | 380 ------------------------ 1 file changed, 380 deletions(-) delete mode 100644 examples/splat-transitions/spheric.html diff --git a/examples/splat-transitions/spheric.html b/examples/splat-transitions/spheric.html deleted file mode 100644 index 6a9ab8f..0000000 --- a/examples/splat-transitions/spheric.html +++ /dev/null @@ -1,380 +0,0 @@ - - - - - - Spark • Splat Transitions - - - - - - - - From 02a3c2530bd6c47921c68c27d2b1497fb355f3f5 Mon Sep 17 00:00:00 2001 From: kali-shade Date: Thu, 18 Sep 2025 21:37:54 -0300 Subject: [PATCH 04/13] Fixed a GUI bug --- examples/splat-transitions/effects/explosion.js | 4 ++++ examples/splat-transitions/index.html | 2 +- examples/splat-transitions/main.js | 11 +++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/splat-transitions/effects/explosion.js b/examples/splat-transitions/effects/explosion.js index 3b102c1..e696742 100644 --- a/examples/splat-transitions/effects/explosion.js +++ b/examples/splat-transitions/effects/explosion.js @@ -291,6 +291,10 @@ export async function init({ THREE: _THREE, scene, camera, renderer, spark }) { } function setupGUI(folder) { + const params = { explosionStrength: uExplosionStrength.value }; + folder.add(params, 'explosionStrength', 0.0, 10.0, 0.1) + .name('Explosion Strength') + .onChange(v => { uExplosionStrength.value = v; }); folder.add(transitionParams, 'autoTransition').name('Auto Transition'); folder.add({ explode: () => startTransition() }, 'explode').name('Trigger Explosion'); return folder; diff --git a/examples/splat-transitions/index.html b/examples/splat-transitions/index.html index 87019fa..329202e 100644 --- a/examples/splat-transitions/index.html +++ b/examples/splat-transitions/index.html @@ -3,7 +3,7 @@ - Spark • Splat Transitions (Modular) + Spark • Splat Transitions