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 {
|
.example-link.active {
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
background-color: rgba(0, 0, 0, 0.15);
|
||||||
border-left: 4px solid .ffffff;
|
border-left: 4px solid #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-header {
|
.mobile-header {
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
|
|
||||||
.mobile-panel .example-link.active {
|
.mobile-panel .example-link.active {
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-right: 4px solid .ffffff;
|
border-right: 4px solid #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-panel .header {
|
.mobile-panel .header {
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
codeButton.title = 'View source code for this example on GitHub';
|
codeButton.title = 'View source code for this example on GitHub';
|
||||||
codeButton.innerHTML = `
|
codeButton.innerHTML = `
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="display:block;">
|
<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>
|
</svg>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(codeButton);
|
document.body.appendChild(codeButton);
|
||||||
|
|||||||
@@ -83,6 +83,10 @@
|
|||||||
"url": "https://forge.dev/assets/images/butterfly.png",
|
"url": "https://forge.dev/assets/images/butterfly.png",
|
||||||
"directory": "images"
|
"directory": "images"
|
||||||
},
|
},
|
||||||
|
"sky.jpeg": {
|
||||||
|
"url": "https://forge.dev/assets/images/sky.jpeg",
|
||||||
|
"directory": "images"
|
||||||
|
},
|
||||||
"furry-logo-pedestal.spz": {
|
"furry-logo-pedestal.spz": {
|
||||||
"url": "https://forge.dev/assets/splats/furry-logo-pedestal.spz",
|
"url": "https://forge.dev/assets/splats/furry-logo-pedestal.spz",
|
||||||
"directory": "splats"
|
"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>
|
<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/envmap/">Environment Map</a></li>
|
||||||
<li><a href="/examples/hello-world/">Hello World</a></li>
|
<li><a href="/examples/hello-world/">Hello World</a></li>
|
||||||
<li><a href="/examples/interactivity/">Interactivity</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/procedural-splats/">Procedural Splats</a></li>
|
||||||
<li><a href="/examples/raycasting/">Raycasting</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-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/vfx-particle-simulation/">VFX - Particle Simulation</a></li>
|
||||||
<li><a href="/examples/viewer/">Viewer</a></li>
|
<li><a href="/examples/viewer/">Viewer</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user