diff --git a/examples/splat-transitions/effects/explosion.js b/examples/splat-transitions/effects/explosion.js new file mode 100644 index 0000000..d5ffa73 --- /dev/null +++ b/examples/splat-transitions/effects/explosion.js @@ -0,0 +1,411 @@ +import { SparkControls, SplatMesh, dyno, textSplats } from "@sparkjsdev/spark"; +import * as THREE from "three"; +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(5.5, 5.5, 5.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) { + for (const m of Object.values(splatMeshes)) { + 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; + for (const [name, mesh] of Object.entries(splatMeshes)) { + 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]) { + for (const [name, mesh] of Object.entries(splatMeshes)) { + if (!mesh) continue; + 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: true }; + + 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) { + 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; + } + + 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..664ccd1 --- /dev/null +++ b/examples/splat-transitions/effects/flow.js @@ -0,0 +1,306 @@ +import { SplatMesh, dyno } from "@sparkjsdev/spark"; +import * as THREE from "three"; +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); + + const PARAMETERS = { + speedMultiplier: 0.5, + 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, + s ** 5, + ); + const radius = 4 + Math.abs(s - 0.5) ** 2 * 20; + let x; + let 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) { + for (const m of meshes) { + m.rotation.y += dt * PARAMETERS.speedMultiplier * 2; + } + } + } + updateCamera(); + } + + function setupGUI(folder) { + folder.add(PARAMETERS, "speedMultiplier", 0, 1, 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/morph.js b/examples/splat-transitions/effects/morph.js new file mode 100644 index 0000000..73718a7 --- /dev/null +++ b/examples/splat-transitions/effects/morph.js @@ -0,0 +1,226 @@ +import { SplatMesh, dyno } from "@sparkjsdev/spark"; +import * as THREE from "three"; +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); + + // Camera baseline for Morph effect + camera.position.set(0, 2.2, 6.5); + camera.lookAt(0, 1.0, 0); + + const PARAMETERS = { + speedMultiplier: 1.0, + rotation: true, + pause: false, + staySeconds: 1.5, + transitionSeconds: 2.0, + randomRadius: 1.3, + }; + + const time = dyno.dynoFloat(0.0); + + // Tres splats de comida + const splatFiles = [ + "branzino-amarin.spz", + "pad-thai.spz", + "primerib-tamos.spz", + ]; + + function morphDyno() { + return new dyno.Dyno({ + inTypes: { + gsplat: dyno.Gsplat, + gt: "float", + objectIndex: "int", + stay: "float", + trans: "float", + numObjects: "int", + randomRadius: "float", + offsetY: "float", + }, + outTypes: { gsplat: dyno.Gsplat }, + globals: () => [ + dyno.unindent(` + vec3 hash3(int n) { + float x = float(n); + return fract(sin(vec3(x, x + 1.0, x + 2.0)) * 43758.5453123); + } + float ease(float x) { return x*x*(3.0 - 2.0*x); } + vec3 randPos(int splatIndex, float radius) { + // Uniform disk sampling on XZ plane + vec3 h = hash3(splatIndex); + float theta = 6.28318530718 * h.x; + float r = radius * sqrt(h.y); + return vec3(r * cos(theta), 0.0, r * sin(theta)); + } + `), + ], + statements: ({ inputs, outputs }) => + dyno.unindentLines(` + ${outputs.gsplat} = ${inputs.gsplat}; + float stay = ${inputs.stay}; + float trans = ${inputs.trans}; + float cycle = stay + trans; + float tot = float(${inputs.numObjects}) * cycle; + float w = mod(${inputs.gt}, tot); + int cur = int(floor(w / cycle)); + int nxt = (cur + 1) % ${inputs.numObjects}; + float local = mod(w, cycle); + bool inTrans = local > stay; + float uPhase = inTrans ? clamp((local - stay) / trans, 0.0, 1.0) : 0.0; + bool phaseScatter = uPhase < 0.5; + float s = phaseScatter ? (uPhase / 0.5) : ((uPhase - 0.5) / 0.5); + int idx = ${inputs.objectIndex}; + + vec3 rp = randPos(int(${inputs.gsplat}.index), ${inputs.randomRadius}); + rp.y -= ${inputs.offsetY}; + vec3 rpMid = mix(${inputs.gsplat}.center, rp, 0.7); + + float alpha = 0.0; + vec3 pos = ${inputs.gsplat}.center; + vec3 origScale = ${inputs.gsplat}.scales; + vec3 small = ${inputs.gsplat}.scales*.2; + if (idx == cur) { + if (!inTrans) { + alpha = 1.0; + pos = ${inputs.gsplat}.center; + ${outputs.gsplat}.scales = origScale; + } else if (phaseScatter) { + alpha = 1.0 - ease(s)*.5; + pos = mix(${inputs.gsplat}.center, rpMid, ease(s)); + ${outputs.gsplat}.scales = mix(origScale, small, ease(s)); + } else { + alpha = 0.0; + pos = rpMid; + ${outputs.gsplat}.scales = small; + } + } else if (idx == nxt) { + if (!inTrans) { + alpha = 0.0; + pos = rpMid; + ${outputs.gsplat}.scales = small; + } else if (phaseScatter) { + alpha = 0.0; + pos = rpMid; + ${outputs.gsplat}.scales = small; + } else { + alpha = max(ease(s), 0.5); + pos = mix(rpMid, ${inputs.gsplat}.center, ease(s)); + ${outputs.gsplat}.scales = mix(small, origScale, ease(s)); + } + } else { + alpha = 0.0; + pos = ${inputs.gsplat}.center; + ${outputs.gsplat}.scales = origScale; + } + pos.y += ${inputs.offsetY}; + ${outputs.gsplat}.center = pos; + ${outputs.gsplat}.rgba.a = ${inputs.gsplat}.rgba.a * alpha; + `), + }); + } + + function getMorphModifier( + gt, + idx, + stay, + trans, + numObjects, + randomRadius, + offsetY, + ) { + const dyn = morphDyno(); + return dyno.dynoBlock( + { gsplat: dyno.Gsplat }, + { gsplat: dyno.Gsplat }, + ({ gsplat }) => ({ + gsplat: dyn.apply({ + gsplat, + gt, + objectIndex: idx, + stay, + trans, + numObjects, + randomRadius, + offsetY, + }).gsplat, + }), + ); + } + + const meshes = []; + const numObjectsDyn = dyno.dynoInt(splatFiles.length); + const stayDyn = dyno.dynoFloat(PARAMETERS.staySeconds); + const transDyn = dyno.dynoFloat(PARAMETERS.transitionSeconds); + const radiusDyn = dyno.dynoFloat(PARAMETERS.randomRadius); + const OFFSETS_Y = [ + dyno.dynoFloat(0.0), + dyno.dynoFloat(0.3), + dyno.dynoFloat(0.0), + ]; + + for (let i = 0; i < splatFiles.length; i++) { + const url = await getAssetFileURL(splatFiles[i]); + const mesh = new SplatMesh({ url }); + await mesh.initialized; + // Orientación base similar a otros efectos + mesh.rotateX(Math.PI); + mesh.position.set(0, 0, 0); + mesh.scale.set(1.5, 1.5, 1.5); + group.add(mesh); + meshes.push(mesh); + } + + // Asignar modificadores de morph (hold → scatter → morph) + meshes.forEach((m, i) => { + m.worldModifier = getMorphModifier( + time, + dyno.dynoInt(i), + stayDyn, + transDyn, + numObjectsDyn, + radiusDyn, + OFFSETS_Y[i] ?? dyno.dynoFloat(0.0), + ); + m.updateGenerator(); + }); + + function update(dt, _t) { + if (!PARAMETERS.pause) { + time.value += dt * PARAMETERS.speedMultiplier; + for (const m of meshes) { + if (PARAMETERS.rotation) { + m.rotation.y += dt * PARAMETERS.speedMultiplier; + } + // Ensure dyno uniform updates are applied even without rotation + m.updateVersion(); + } + } + } + + function setupGUI(folder) { + folder.add(PARAMETERS, "speedMultiplier", 0.1, 3.0, 0.01); + folder.add(PARAMETERS, "rotation"); + folder.add(PARAMETERS, "pause"); + folder.add(PARAMETERS, "staySeconds", 0.2, 5.0, 0.05).onChange((v) => { + stayDyn.value = v; + }); + folder + .add(PARAMETERS, "transitionSeconds", 1.0, 3.0, 0.05) + .onChange((v) => { + transDyn.value = v; + }); + folder.add(PARAMETERS, "randomRadius", 1, 5.0, 0.1).onChange((v) => { + radiusDyn.value = v; + }); + 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..aca7ebb --- /dev/null +++ b/examples/splat-transitions/effects/spheric.js @@ -0,0 +1,276 @@ +import { SplatMesh, dyno } from "@sparkjsdev/spark"; +import * as THREE from "three"; +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); + + // 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) { + for (const m of meshes) { + 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/index.html b/examples/splat-transitions/index.html index 6a9ab8f..329202e 100644 --- a/examples/splat-transitions/index.html +++ b/examples/splat-transitions/index.html @@ -1,29 +1,14 @@
- +