Files

380 lines
10 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • Particle Animation</title>
<style>
html, body {
margin: 0;
height: 100%;
width: 100%;
background-color: black;
}
#canvas {
position: absolute;
width: 100%;
height: 100%;
touch-action: none;
}
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js",
"@sparkjsdev/spark": "/dist/spark.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
import {
SparkRenderer,
PointerControls,
SplatMesh,
VRButton,
} from "@sparkjsdev/spark";
import GUI from "lil-gui";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
// cloud bounds
const BOUNDS = {
MIN_X: -8,
MAX_X: 8,
MIN_Y: -0.33,
MAX_Y: 0.33,
MIN_Z: -8,
MAX_Z: 10,
};
// cloud appearance parameters
const params = {
windSpeed: -0.3,
particleCount: 20000,
opacity: 0.5,
fluffiness: 0.5,
turbulence: 0.5,
cloudDensity: 0.7,
color1: new THREE.Color(1.0, 1.0, 1.0),
color2: new THREE.Color(0.87, 0.87, 0.87),
octaves: 4,
frequency: 0.3,
amplitude: 0.5,
lacunarity: 2.0,
persistence: 0.5,
phase: 0.1,
};
const presets = {
"Partially Cloudy": {
particleCount: 10000,
color1: new THREE.Color(1.0, 1.0, 1.0),
color2: new THREE.Color(0.87, 0.87, 0.87),
opacity: 0.3,
windSpeed: -0.5,
fluffiness: 0.5,
turbulence: 0.5,
cloudDensity: 0.5,
},
Cloudy: {
particleCount: 20000,
color1: new THREE.Color(1.0, 1.0, 1.0),
color2: new THREE.Color(0.87, 0.87, 0.87),
opacity: 0.5,
windSpeed: -0.3,
fluffiness: 0.5,
turbulence: 0.5,
cloudDensity: 0.7,
},
Storm: {
particleCount: 30000,
color1: new THREE.Color(0.66, 0.66, 0.66),
color2: new THREE.Color(0.44, 0.44, 0.44),
opacity: 0.7,
windSpeed: -0.7,
fluffiness: 1.0,
turbulence: 0.8,
cloudDensity: 1.0,
},
};
// fBM noise implementation
function noise(x, y, z, t, noiseParams) {
let value = 0;
let amp = noiseParams.amplitude;
let freq = noiseParams.frequency;
for (let i = 0; i < noiseParams.octaves; i++) {
const to = t * noiseParams.phase * (i + 1);
// 3D grid of sines
value +=
amp *
Math.sin(x * freq + to) *
Math.sin(y * freq + to) *
Math.sin(z * freq + to);
freq *= noiseParams.lacunarity;
amp *= noiseParams.persistence;
}
return value;
}
function wrap(val, min, max) {
const range = max - min;
return ((((val - min) % range) + range) % range) + min;
}
// create splats from current params
function createClouds(splats, particleCount) {
const topColor = new THREE.Color().copy(params.color1);
const bottomColor = new THREE.Color().copy(params.color2);
const center = new THREE.Vector3();
const scales = new THREE.Vector3(0.12, 0.08, 0.12);
const quaternion = new THREE.Quaternion();
const color = new THREE.Color();
const now = performance.now() * 0.001 * 0.2;
for (let i = 0; i < particleCount; i++) {
// Generate consistent randomness per particle
// (prevents flickering during recreation)
// based on common rand() one-liner
// see https://web.archive.org/web/20080211204527/http://lumina.sourceforge.net/Tutorials/Noise.html
const seed = i * 0.12345;
const random = new THREE.Vector4(
(Math.sin(seed * 12.9898) * 43758.5453) % 1,
(Math.sin(seed * 78.233) * 43758.5453) % 1,
(Math.sin(seed * 37.719) * 43758.5453) % 1,
(Math.sin(seed * 93.989) * 43758.5453) % 1,
)
.multiplyScalar(0.5)
.addScalar(0.5);
// Set base positions within bounds
let x = THREE.MathUtils.lerp(BOUNDS.MIN_X, BOUNDS.MAX_X, random.x);
let y = THREE.MathUtils.lerp(BOUNDS.MIN_Y, BOUNDS.MAX_Y, random.y);
let z = THREE.MathUtils.lerp(BOUNDS.MIN_Z, BOUNDS.MAX_Z, random.z);
// apply vertical variation (fluffiness)
const fluffiness =
Math.sin(random.w * Math.PI * 2) * params.fluffiness * 0.5;
y += fluffiness;
// apply horizontal variation (turbulence)
const turbulence =
Math.sin(random.w * Math.PI * 8) * params.turbulence * 0.3;
x += turbulence;
// Add fBM noise
const n = noise(x, y, z, now, params) * params.fluffiness * 0.1;
y += n;
// Color gradient based on normalized height
const t = THREE.MathUtils.clamp(
(y - BOUNDS.MIN_Y) / (BOUNDS.MAX_Y - BOUNDS.MIN_Y),
0,
1,
);
color.copy(bottomColor).lerp(topColor, t);
x = wrap(x, BOUNDS.MIN_X, BOUNDS.MAX_X);
y = wrap(y, BOUNDS.MIN_Y, BOUNDS.MAX_Y);
z = wrap(z, BOUNDS.MIN_Z, BOUNDS.MAX_Z);
const opacity = Math.max(
0.1,
params.opacity * (0.8 + 0.4 * Math.abs(fluffiness)),
);
center.set(x, y, z);
splats.pushSplat(center, scales, quaternion, opacity, color);
}
}
// animate splats within bounds
function createCloudAnimation(params, BOUNDS) {
return ({ mesh, time, deltaTime }) => {
mesh.packedSplats.forEachSplat(
(index, center, scales, quaternion, opacity, color) => {
const seed = index * 0.12345;
const randomZ = (Math.sin(seed * 37.719) * 43758.5453) % 1;
const zZero = THREE.MathUtils.lerp(
BOUNDS.MIN_Z,
BOUNDS.MAX_Z,
Math.abs(randomZ),
);
const displacement = params.windSpeed * time;
const range = BOUNDS.MAX_Z - BOUNDS.MIN_Z;
let newZ = zZero + displacement;
newZ =
((((newZ - BOUNDS.MIN_Z) % range) + range) % range) + BOUNDS.MIN_Z;
center.set(center.x, center.y, newZ);
mesh.packedSplats.setSplat(
index,
center,
scales,
quaternion,
opacity,
color,
);
},
);
mesh.packedSplats.needsUpdate = true;
mesh.needsUpdate = true;
};
}
// creates splat mesh from cloud params
function createCloudMesh(params, BOUNDS) {
const actualParticleCount = Math.floor(
params.particleCount * params.cloudDensity,
);
return new SplatMesh({
maxSplats: actualParticleCount,
constructSplats: (splats) => {
createClouds(splats, actualParticleCount);
},
onFrame: createCloudAnimation(params, BOUNDS),
});
}
// setup scene + environment
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
scene.background = new THREE.Color(0x08a2d3);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const spark = new SparkRenderer({ renderer });
scene.add(spark);
function handleResize() {
const width = window.innerWidth;
const height = window.innerHeight;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
handleResize();
window.addEventListener("resize", handleResize);
// add sky background
const skyGeometry = new THREE.SphereGeometry(10, 60, 40);
const skyMaterial = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load((await getAssetFileURL("sky.jpeg"))),
side: THREE.BackSide,
depthWrite: false,
});
const skySphere = new THREE.Mesh(skyGeometry, skyMaterial);
skySphere.scale.set(-1, 1, 1);
skySphere.renderDepth = 1000.0;
scene.add(skySphere);
camera.position.set(0, 0, 0);
skySphere.position.copy(camera.position);
const controls = new PointerControls({ canvas: renderer.domElement });
// create initial clouds on first load
let clouds = createCloudMesh(params, BOUNDS);
clouds.position.set(0, -0.5, -2);
scene.add(clouds);
function recreateClouds() {
if (clouds) {
scene.remove(clouds);
clouds.dispose();
}
clouds = createCloudMesh(params, BOUNDS);
clouds.position.set(0, -0.5, -2);
scene.add(clouds);
}
// cloud parameter controls
const gui = new GUI({ title: "Clouds" });
gui.add(params, "windSpeed", -1, 1, 0.1).name("Wind Speed");
gui
.add(params, "opacity", 0.05, 1, 0.01)
.name("Thickness")
.onChange(recreateClouds);
gui
.add(params, "fluffiness", 0, 2, 0.01)
.name("Fluffiness")
.onChange(recreateClouds);
gui
.add(params, "turbulence", 0, 1, 0.01)
.name("Turbulence")
.onChange(recreateClouds);
gui
.add(params, "cloudDensity", 0.1, 1, 0.01)
.name("Cloud Density")
.onChange(recreateClouds);
// advanced noise params
const advancedFolder = gui.addFolder("Advanced");
advancedFolder
.add(params, "octaves", 1, 8, 1)
.name("Noise Octaves")
.onChange(recreateClouds);
advancedFolder
.add(params, "frequency", 0.1, 1, 0.01)
.name("Noise Frequency")
.onChange(recreateClouds);
const presetsFolder = gui.addFolder("Weather");
for (const [name, preset] of Object.entries(presets)) {
presetsFolder.add(
{
[name]: () => {
Object.assign(params, preset);
recreateClouds();
for (const controller of gui.controllersRecursive()) {
controller.updateDisplay();
}
},
},
name,
);
}
let lastTime = 0;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = (time - lastTime) * 0.001;
lastTime = time;
controls.update(deltaTime, camera);
renderer.render(scene, camera);
});
</script>
</body>
</html>