mirror of
https://github.com/storytold/spark.git
synced 2026-10-23 08:26:01 +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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,29 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Spark • Splat Transitions</title>
|
<title>Spark • Splat Transitions</title>
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
html, body { margin: 0; height: 100%; width: 100%; background-color: black; }
|
||||||
margin: 0;
|
#canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; outline: none; touch-action: none; }
|
||||||
height: 100%;
|
.loading { position: absolute; top: 12px; left: 12px; color: white; font-family: Arial, sans-serif; font-size: 12px; opacity: .8; }
|
||||||
width: 100%;
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
#canvas {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
outline: none; /* Remove default focus outline */
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="canvas" tabindex="0"></canvas>
|
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -34,347 +19,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module">
|
</head>
|
||||||
import {
|
<body>
|
||||||
dyno,
|
<div class="loading" id="loading">Loading...</div>
|
||||||
SparkRenderer,
|
<canvas id="canvas" tabindex="0"></canvas>
|
||||||
SplatMesh,
|
<script type="module" src="./main.js"></script>
|
||||||
} from "@sparkjsdev/spark";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
||||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
||||||
import { GLTFExporter } from "three/addons/exporters/GLTFExporter.js";
|
|
||||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
|
||||||
import { GUI } from "lil-gui";
|
|
||||||
|
|
||||||
const splatFiles = [
|
|
||||||
"penguin.spz",
|
|
||||||
"dessert.spz",
|
|
||||||
"woobles.spz",
|
|
||||||
];
|
|
||||||
const sceneFile = "dali-table.glb"
|
|
||||||
const skyFile = "dali-env.glb"
|
|
||||||
|
|
||||||
const PARAMETERS = {
|
|
||||||
splatCoverage: 1.0,
|
|
||||||
spereRadius: 1.0,
|
|
||||||
sphereHeight: 2.0,
|
|
||||||
speedMultiplier: 1.0,
|
|
||||||
rotation: true,
|
|
||||||
pause: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getTransitionState(t, fadeInTime, fadeOutTime, period) {
|
|
||||||
// inputs:
|
|
||||||
// unnormalized time t
|
|
||||||
// fade in and fade out (assumed to take 1.0 unnormalized time units)
|
|
||||||
// period (assumed to be an integer number of unnormalized time units)
|
|
||||||
// returns:
|
|
||||||
// dynobool for whether transition is active
|
|
||||||
// dynobool for whether transition is fading in or out
|
|
||||||
// dynofloat for the normalized time of the transition
|
|
||||||
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() {
|
|
||||||
// this is a looping shader that periodically contracts and expands the splat.
|
|
||||||
// t is assumed to be in normalized time [0, 1], any retiming should be done before this function.
|
|
||||||
// fadeIn is a boolean that indicates whether the splat is fading in or out.
|
|
||||||
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, 1.0, 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: url,
|
|
||||||
onFrame: ({ mesh, time }) => {
|
|
||||||
mesh.needsUpdate = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await splatMesh.initialized; // wait to get splatCount
|
|
||||||
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();
|
|
||||||
return splatMesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDelitGLB(filename, isEnv = false) {
|
|
||||||
const url = await getAssetFileURL(filename);
|
|
||||||
console.log("Loading GLB", url);
|
|
||||||
const gltfLoader = new GLTFLoader();
|
|
||||||
const gltf = await new Promise((resolve, reject) => {
|
|
||||||
gltfLoader.load(url, resolve, undefined, reject);
|
|
||||||
});
|
|
||||||
const scene = gltf.scene;
|
|
||||||
// Switch all materials to albedo slot
|
|
||||||
scene.traverse((child) => {
|
|
||||||
if (child.isMesh && child.material) {
|
|
||||||
const originalMaterial = child.material;
|
|
||||||
const basicMaterial = new THREE.MeshBasicMaterial();
|
|
||||||
if (originalMaterial.color) {
|
|
||||||
basicMaterial.color.copy(originalMaterial.color);
|
|
||||||
}
|
|
||||||
if (originalMaterial.map) {
|
|
||||||
basicMaterial.map = originalMaterial.map;
|
|
||||||
}
|
|
||||||
if (isEnv) { // sky sphere handling
|
|
||||||
basicMaterial.side = THREE.BackSide;
|
|
||||||
basicMaterial.map.mapping = THREE.EquirectangularReflectionMapping;
|
|
||||||
basicMaterial.map.colorSpace = THREE.LinearSRGBColorSpace;
|
|
||||||
basicMaterial.map.needsUpdate = true;
|
|
||||||
}
|
|
||||||
child.material = basicMaterial;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return scene;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const canvas = document.getElementById("canvas");
|
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
|
|
||||||
renderer.setClearColor(new THREE.Color(0x000000), 1);
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const spark = new SparkRenderer({
|
|
||||||
renderer,
|
|
||||||
});
|
|
||||||
scene.add(spark);
|
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(
|
|
||||||
50,
|
|
||||||
window.innerWidth / window.innerHeight,
|
|
||||||
0.01,
|
|
||||||
1000,
|
|
||||||
);
|
|
||||||
camera.position.set(5, 4, 7);
|
|
||||||
camera.lookAt(0, 4, 0);
|
|
||||||
scene.add(camera);
|
|
||||||
|
|
||||||
const orbitControls = new OrbitControls(camera, renderer.domElement);
|
|
||||||
orbitControls.target.set(0, 2, 0);
|
|
||||||
function handleResize() {
|
|
||||||
const width = canvas.clientWidth;
|
|
||||||
const height = canvas.clientHeight;
|
|
||||||
renderer.setSize(width, height, false);
|
|
||||||
camera.aspect = width / height;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleResize();
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
|
|
||||||
const time = dyno.dynoFloat(0.0);
|
|
||||||
|
|
||||||
async function loadAssets(splatCoverage, spereRadius, sphereHeight) {
|
|
||||||
console.log("Loading initial scene...");
|
|
||||||
|
|
||||||
// Load sky sphere
|
|
||||||
const skySphere = await loadDelitGLB(skyFile, true);
|
|
||||||
scene.add(skySphere);
|
|
||||||
|
|
||||||
// Load table GLB
|
|
||||||
const sceneAssets = await loadDelitGLB(sceneFile);
|
|
||||||
const sceneScale = 3.5;
|
|
||||||
sceneAssets.scale.set(sceneScale, sceneScale, sceneScale);
|
|
||||||
sceneAssets.position.set(-1, 0, -0.8);
|
|
||||||
scene.add(sceneAssets);
|
|
||||||
|
|
||||||
const splatMeshes = [];
|
|
||||||
const period = dyno.dynoFloat(splatFiles.length);
|
|
||||||
for (let i=0; i<splatFiles.length; i++) {
|
|
||||||
console.log(splatFiles[i], (i+1) % splatFiles.length);
|
|
||||||
const splatMesh = await morphableSplatMesh(
|
|
||||||
splatFiles[i],
|
|
||||||
time,
|
|
||||||
dyno.dynoFloat(i), //fadeInTime
|
|
||||||
dyno.dynoFloat((i+1) % splatFiles.length), //fadeOutTime
|
|
||||||
period,
|
|
||||||
splatCoverage,
|
|
||||||
spereRadius,
|
|
||||||
sphereHeight,
|
|
||||||
);
|
|
||||||
splatMesh.quaternion.set(1, 0, 0, 0);
|
|
||||||
scene.add(splatMesh);
|
|
||||||
splatMeshes.push(splatMesh);
|
|
||||||
}
|
|
||||||
return splatMeshes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spereRadiusDyno = dyno.dynoFloat(PARAMETERS.spereRadius)
|
|
||||||
const splatCoverageDyno = dyno.dynoFloat(PARAMETERS.splatCoverage)
|
|
||||||
const sphereHeightDyno = dyno.dynoFloat(PARAMETERS.sphereHeight)
|
|
||||||
const splatMeshes = await loadAssets(splatCoverageDyno, spereRadiusDyno, sphereHeightDyno);
|
|
||||||
|
|
||||||
const gui = new GUI();
|
|
||||||
gui.add(PARAMETERS, "spereRadius").min(0.1).max(8.0).step(0.01).onChange((value) => {
|
|
||||||
spereRadiusDyno.value = value;
|
|
||||||
});
|
|
||||||
gui.add(PARAMETERS, "sphereHeight").min(-1.0).max(4.0).step(0.01).onChange((value) => {
|
|
||||||
sphereHeightDyno.value = value;
|
|
||||||
});
|
|
||||||
gui.add(PARAMETERS, "splatCoverage").min(0.1).max(2.0).step(0.01).onChange((value) => {
|
|
||||||
splatCoverageDyno.value = value;
|
|
||||||
});
|
|
||||||
gui.add(PARAMETERS, "speedMultiplier").min(0.25).max(4.0).step(0.01);
|
|
||||||
gui.add(PARAMETERS, "rotation");
|
|
||||||
gui.add(PARAMETERS, "pause");
|
|
||||||
|
|
||||||
console.log("Starting render loop");
|
|
||||||
|
|
||||||
// Animation loop
|
|
||||||
let lastTime = 0;
|
|
||||||
renderer.setAnimationLoop((rawTime) => {
|
|
||||||
rawTime *= 0.0005;
|
|
||||||
const deltaTime = rawTime - (lastTime ?? rawTime);
|
|
||||||
lastTime = rawTime;
|
|
||||||
orbitControls.update();
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
|
|
||||||
if (!PARAMETERS.pause) {
|
|
||||||
time.value +=deltaTime * PARAMETERS.speedMultiplier;
|
|
||||||
if (PARAMETERS.rotation) {
|
|
||||||
for (const splatMesh of splatMeshes) {
|
|
||||||
splatMesh.rotation.y += deltaTime * PARAMETERS.speedMultiplier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { SparkRenderer } from "@sparkjsdev/spark";
|
||||||
|
import { GUI } from "lil-gui";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
// 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"),
|
||||||
|
Morph: () => import("./effects/morph.js"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let active = null; // { api, group }
|
||||||
|
let last = 0;
|
||||||
|
let effectFolder = null; // GUI folder for current effect
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy previous GUI folder to avoid accumulation
|
||||||
|
if (effectFolder) {
|
||||||
|
try {
|
||||||
|
effectFolder.destroy();
|
||||||
|
} catch {}
|
||||||
|
effectFolder = 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) {
|
||||||
|
effectFolder = gui.addFolder(name);
|
||||||
|
api.setupGUI(effectFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.style.display = "none";
|
||||||
|
|
||||||
|
// Give focus back to the canvas so keyboard controls work immediately
|
||||||
|
try {
|
||||||
|
canvas.focus();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
Reference in New Issue
Block a user