mirror of
https://github.com/storytold/spark.git
synced 2026-10-18 07:40:38 +00:00
Transition Effects (#172)
* Transition effects * Added two transition effects and refactored code into separate .js filesAdded two transition effects and refactorized code for effects in separate .js files * Deleted unused html file * Fixed a GUI bug * Fixed focus given to effect when dropdown changes * Fixed github verification errors * fix lints in splat-transitions and tweak defaults * lint fix on splat-transitions * lint fix on splat-transitions * Morph transition * Morph transition - better random function * Morph transition - better morphing of dishes * fixed lint errors
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user