main.ts
examples/spark/particle-animation/main.ts · raw · open the example · all examples · site home
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
// Spark's particle-animation: a cloud layer of CPU-made splats drifting
// with the wind inside a sky sphere. Every frame moves each splat on the
// CPU, encodes them again and uploads the table (GpuArrays.upload), like
// Spark's forEachSplat / setSplat / needsUpdate. A setting change makes a
// new cloud; the old one's arrays are freed in its object's release.
import * as THREE from "three";
import GUI from "lil-gui";
import { CameraControls, GpuArrays } from "@sparkjsdev/radix";
import { asset } from "../js/assets.js";
import { SplatBuilder, writeSplats } from "../js/construct.js";
import { SplatModel, SplatSet } from "../js/objects.js";
import { examplePage, windowRenderer } from "../js/page.js";
const MIN = new THREE.Vector3(-8, -0.33, -8);
const MAX = new THREE.Vector3(8, 0.33, 10);
const noiseParams = { octaves: 4, frequency: 0.3, amplitude: 0.5, lacunarity: 2, persistence: 0.5, phase: 0.1 };
const WHITE = { color1: new THREE.Color(1, 1, 1), color2: new THREE.Color(0.87, 0.87, 0.87) };
const PRESETS = {
"Partially Cloudy": { ...WHITE, particleCount: 10000, opacity: 0.3, windSpeed: -0.5, fluffiness: 0.5, turbulence: 0.5, cloudDensity: 0.5 },
Cloudy: { ...WHITE, particleCount: 20000, 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, turbulence: 0.8, cloudDensity: 1 },
};
const params = { ...PRESETS.Cloudy };
function noise(x: number, y: number, z: number, t: number) {
let value = 0;
let amp = noiseParams.amplitude;
let freq = noiseParams.frequency;
for (let i = 0; i < noiseParams.octaves; i += 1) {
const to = t * noiseParams.phase * (i + 1);
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;
}
// A splat's pseudo-random number in (-1, 1) of its seed, per key
const hash = (seed: number, key: number) => (Math.sin(seed * key) * 43758.5453) % 1;
const wrap = (value: number, min: number, max: number) => ((((value - min) % (max - min)) + (max - min)) % (max - min)) + min;
const page = examplePage("particle-animation");
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x08a2d3);
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
// createRenderer, sized to the window and added to the page; it follows
// window resizes, the camera's aspect too (js/page.ts)
const renderer = await windowRenderer(camera);
const controls = new CameraControls({ canvas: renderer.domElement, object: camera, storageKey: "spark-particle-animation", settings: { mode: "fly" } });
const sky = new THREE.Mesh(
new THREE.SphereGeometry(10, 60, 40),
new THREE.MeshBasicMaterial({ map: new THREE.TextureLoader().load(asset("sky.jpeg")), side: THREE.BackSide, depthWrite: false }),
);
sky.scale.set(-1, 1, 1);
scene.add(sky);
// The clouds move every frame: on the CPU sort (WebGL2) it sorts again
// every render
const splats = new SplatSet({ renderer, scene, animate: true });
// A cloud: its builder's values (14 floats per splat), table and arrays
function makeClouds() {
const count = Math.floor(params.particleCount * params.cloudDensity);
const builder = new SplatBuilder(count);
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 < count; i += 1) {
const seed = i * 0.12345;
const random = new THREE.Vector3(hash(seed, 12.9898), hash(seed, 78.233), hash(seed, 37.719)).multiplyScalar(0.5).addScalar(0.5);
const w = hash(seed, 93.989) * 0.5 + 0.5;
center.subVectors(MAX, MIN).multiply(random).add(MIN);
const fluffiness = Math.sin(w * Math.PI * 2) * params.fluffiness * 0.5;
center.y += fluffiness;
center.x += Math.sin(w * Math.PI * 8) * params.turbulence * 0.3;
center.y += noise(center.x, center.y, center.z, now) * params.fluffiness * 0.1;
color.copy(params.color2).lerp(params.color1, THREE.MathUtils.clamp((center.y - MIN.y) / (MAX.y - MIN.y), 0, 1));
center.set(wrap(center.x, MIN.x, MAX.x), wrap(center.y, MIN.y, MAX.y), wrap(center.z, MIN.z, MAX.z));
builder.pushSplat(center, scales, quaternion, Math.max(0.1, params.opacity * (0.8 + 0.4 * Math.abs(fluffiness))), color);
}
const data = builder.build();
const arrays = GpuArrays.fromData(renderer, data);
const model = new SplatModel({ arrays, data, release: () => arrays.dispose() });
model.position.set(0, -0.5, -2);
return { model, builder, data, arrays };
}
let clouds = makeClouds();
splats.add(clouds.model);
function remakeClouds() {
splats.remove(clouds.model);
clouds.model.removeFromParent();
clouds = makeClouds();
splats.add(clouds.model);
}
// Spark's onFrame: each splat's z from its seed and the wind
function animateClouds(time: number) {
const { builder, data, arrays } = clouds;
for (let i = 0; i < builder.count; i += 1) {
const z = THREE.MathUtils.lerp(MIN.z, MAX.z, Math.abs(hash(i * 0.12345, 37.719))) + params.windSpeed * time;
builder.values[i * 14 + 2] = wrap(z, MIN.z, MAX.z);
}
writeSplats(data.bundles.extSplats, builder.values, 0, builder.count);
arrays.upload(data);
}
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(remakeClouds);
gui.add(params, "fluffiness", 0, 2, 0.01).name("Fluffiness").onChange(remakeClouds);
gui.add(params, "turbulence", 0, 1, 0.01).name("Turbulence").onChange(remakeClouds);
gui.add(params, "cloudDensity", 0.1, 1, 0.01).name("Cloud density").onChange(remakeClouds);
const advanced = gui.addFolder("Advanced").close();
advanced.add(noiseParams, "octaves", 1, 8, 1).name("Noise octaves").onChange(remakeClouds);
advanced.add(noiseParams, "frequency", 0.1, 1, 0.01).name("Noise frequency").onChange(remakeClouds);
const weather = gui.addFolder("Weather");
for (const [name, preset] of Object.entries(PRESETS)) {
weather.add({ [name]: () => {
Object.assign(params, preset);
remakeClouds();
gui.controllersRecursive().forEach((controller) => controller.updateDisplay());
} }, name);
}
page.ready(renderer);
renderer.setAnimationLoop((time) => {
controls.update();
animateClouds(time / 1000);
renderer.render(scene, camera);
});