mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
Add VFX Particle Animation Demo (#24)
* add snow demo to vfx particle simulation * refactor particle simulation into one file * remove clouds demo link (extraneous) * init clouds example * fix wrapping and clumping for clouds at all wind speeds * move clouds example code to one file * add vfx particle animation clouds demo * add vfx particle animation to examples.html * fix indentation to 2 spaces * fix merge examples.html * add snow demo to vfx particle simulation * refactor particle simulation into one file
This commit is contained in:
committed by
GitHub
parent
76c6c002c2
commit
0a2ce484ff
+3
-3
@@ -112,7 +112,7 @@
|
||||
|
||||
.example-link.active {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
border-left: 4px solid .ffffff;
|
||||
border-left: 4px solid #ffffff;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
@@ -173,7 +173,7 @@
|
||||
|
||||
.mobile-panel .example-link.active {
|
||||
border-left: none;
|
||||
border-right: 4px solid .ffffff;
|
||||
border-right: 4px solid #ffffff;
|
||||
}
|
||||
|
||||
.mobile-panel .header {
|
||||
@@ -206,7 +206,7 @@
|
||||
codeButton.title = 'View source code for this example on GitHub';
|
||||
codeButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="display:block;">
|
||||
<path fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 8l-4 4l4 4m6-8l4 4l-4 4"/>
|
||||
<path fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 8l-4 4l4 4m6-8l4 4l-4 4"/>
|
||||
</svg>
|
||||
`;
|
||||
document.body.appendChild(codeButton);
|
||||
|
||||
@@ -83,6 +83,10 @@
|
||||
"url": "https://forge.dev/assets/images/butterfly.png",
|
||||
"directory": "images"
|
||||
},
|
||||
"sky.jpeg": {
|
||||
"url": "https://forge.dev/assets/images/sky.jpeg",
|
||||
"directory": "images"
|
||||
},
|
||||
"furry-logo-pedestal.spz": {
|
||||
"url": "https://forge.dev/assets/splats/furry-logo-pedestal.spz",
|
||||
"directory": "splats"
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Forge • VFX 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",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js",
|
||||
"lil-gui": "../node_modules/lil-gui/dist/lil-gui.esm.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
ForgeRenderer,
|
||||
PointerControls,
|
||||
SplatMesh,
|
||||
VRButton,
|
||||
} from "@forge-gfx/forge";
|
||||
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 forge = new ForgeRenderer({ renderer });
|
||||
scene.add(forge);
|
||||
|
||||
function handleResize() {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
renderer.setSize(width, height, false);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
const vrButton = VRButton.createButton(renderer);
|
||||
if (vrButton) document.body.appendChild(vrButton);
|
||||
|
||||
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>
|
||||
+3
-1
@@ -131,7 +131,8 @@
|
||||
|
||||
<h2>Examples</h2>
|
||||
|
||||
<ul class="links">
|
||||
<ul class="links">
|
||||
<li><a href="/examples/atmospheric-clouds/">Atmospheric - Clouds</a></li>
|
||||
<li><a href="/examples/envmap/">Environment Map</a></li>
|
||||
<li><a href="/examples/hello-world/">Hello World</a></li>
|
||||
<li><a href="/examples/interactivity/">Interactivity</a></li>
|
||||
@@ -140,6 +141,7 @@
|
||||
<li><a href="/examples/procedural-splats/">Procedural Splats</a></li>
|
||||
<li><a href="/examples/raycasting/">Raycasting</a></li>
|
||||
<li><a href="/examples/vfx-dynamic-lighting/">VFX - Dynamic Lighting</a></li>
|
||||
<li><a href="/examples/vfx-particle-animation/">VFX - Particle Animation</a></li>
|
||||
<li><a href="/examples/vfx-particle-simulation/">VFX - Particle Simulation</a></li>
|
||||
<li><a href="/examples/viewer/">Viewer</a></li>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user