Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created May 24, 2025 13:17
Show Gist options
  • Save shricodev/a75775c3b97af037478079d95703dffe to your computer and use it in GitHub Desktop.
Save shricodev/a75775c3b97af037478079d95703dffe to your computer and use it in GitHub Desktop.
Blog - Morph Particles (Gemini 2.5 Pro)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GPU Particle Morphing Demo</title>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #000000;
color: #fff;
}
canvas {
display: block;
}
#ui-container {
position: absolute;
top: 10px;
left: 10px;
background: rgba(20, 20, 20, 0.8);
padding: 15px;
border-radius: 5px;
font-family: Arial, sans-serif;
font-size: 12px;
min-width: 250px;
}
#ui-container div {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
#ui-container label {
margin-right: 10px;
}
#ui-container input[type="range"] {
flex-grow: 1;
max-width: 120px;
}
#ui-container input[type="color"] {
border: none;
background: none;
width: 30px;
height: 20px;
padding: 0;
}
#ui-container select {
padding: 3px;
background-color: #333;
color: white;
border: 1px solid #555;
border-radius: 3px;
}
.value-span {
min-width: 30px;
text-align: right;
}
</style>
</head>
<body>
<div id="ui-container">
<div>
<label for="particleSize">Particle Size:</label>
<input
type="range"
id="particleSize"
min="0.1"
max="5.0"
step="0.1"
value="0.8"
/>
<span id="particleSizeValue" class="value-span">0.8</span>
</div>
<div>
<label for="rotationSpeed">Rotation Speed:</label>
<input
type="range"
id="rotationSpeed"
min="-1.0"
max="1.0"
step="0.05"
value="0.1"
/>
<span id="rotationSpeedValue" class="value-span">0.1</span>
</div>
<div>
<label for="particleColor">Particle Color:</label>
<input type="color" id="particleColor" value="#00ffff" />
</div>
<div>
<label for="bloomStrength">Bloom Strength:</label>
<input
type="range"
id="bloomStrength"
min="0.0"
max="3.0"
step="0.05"
value="0.6"
/>
<span id="bloomStrengthValue" class="value-span">0.6</span>
</div>
<div>
<label for="motionTrail">Motion Trail:</label>
<input
type="range"
id="motionTrail"
min="0.0"
max="0.95"
step="0.01"
value="0.2"
/>
<span id="motionTrailValue" class="value-span">0.2</span>
</div>
<div>
<label for="morphTarget">Morph Target:</label>
<select id="morphTarget">
<option value="0">Sphere</option>
<option value="1">Bird</option>
<option value="2">Face</option>
<option value="3">Tree</option>
</select>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { GammaCorrectionShader } from "three/addons/shaders/GammaCorrectionShader.js";
import { MeshSurfaceSampler } from "three/addons/math/MeshSurfaceSampler.js";
let scene, camera, renderer, controls;
let composer, bloomPass, gammaCorrectionPass;
let particleSystem, particleMaterial;
const clock = new THREE.Clock();
const NUM_PARTICLES = 30000;
const MORPH_DURATION = 2.0; // seconds
// Morphing state
let currentShapeIndex = 0;
let previousShapeIndex = 0;
let morphProgress = 0;
let isMorphing = false;
let morphStartTime = 0;
// UI control values
const guiControls = {
particleSize: 0.8,
rotationSpeed: 0.1,
particleColor: new THREE.Color(0x00ffff),
bloomStrength: 0.6,
motionTrailOpacity: 0.2, // 0 = no trail, 0.95 = heavy trail
};
// For motion trail effect
let trailScene, trailCamera, trailMaterial, trailQuad;
// Array to store morph target data (arrays of THREE.Vector3)
const morphTargetData = [];
init();
animate();
function init() {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
// Camera
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.z = 10;
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.minDistance = 2;
controls.maxDistance = 30;
// Lighting (minimal, as particles are typically unlit but good for context)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.2);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
// Generate morph target geometries
console.log("Generating morph targets...");
morphTargetData.push(generateSpherePoints(NUM_PARTICLES, 3.5)); // Sphere
morphTargetData.push(generateBirdPoints(NUM_PARTICLES, 4.0)); // Bird
morphTargetData.push(generateFacePoints(NUM_PARTICLES, 3.5)); // Face
morphTargetData.push(generateTreePoints(NUM_PARTICLES, 4.5)); // Tree
console.log("Morph targets generated.");
// Particle System
setupParticleSystem();
// Post-processing
setupPostProcessing();
// Motion Trail Effect Setup
setupMotionTrail();
// UI
setupUI();
// Event Listeners
window.addEventListener("resize", onWindowResize);
}
/**
* How morph targets are built:
* Each morph target is an array of NUM_PARTICLES THREE.Vector3 positions.
* These are generated procedurally:
* - Sphere: Points distributed evenly on a sphere surface (Fibonacci lattice).
* - Bird, Face, Tree: More complex shapes are created using Three.js geometries
* (ExtrudeGeometry, LatheGeometry, Cylinder/Sphere primitives). Then, MeshSurfaceSampler
* is used to sample NUM_PARTICLES points uniformly from the surface of these meshes.
* These sets of points are then stored as attributes in a single BufferGeometry.
*/
function generateSpherePoints(count, radius) {
const points = [];
const phi = Math.PI * (Math.sqrt(5) - 1); // Golden angle in radians
for (let i = 0; i < count; i++) {
const y = 1 - (i / (count - 1)) * 2; // y goes from 1 to -1
const R = Math.sqrt(1 - y * y); // radius at y
const theta = phi * i;
const x = Math.cos(theta) * R;
const z = Math.sin(theta) * R;
points.push(new THREE.Vector3(x, y, z).multiplyScalar(radius));
}
return points;
}
function samplePointsFromMesh(mesh, numPoints) {
const sampler = new MeshSurfaceSampler(mesh).build();
const points = [];
const tempPosition = new THREE.Vector3();
for (let i = 0; i < numPoints; i++) {
sampler.sample(tempPosition);
points.push(tempPosition.clone());
}
return points;
}
function generateBirdPoints(count, scale) {
const birdShape = new THREE.Shape();
birdShape.moveTo(0, 0);
birdShape.bezierCurveTo(0.2, 0.1, 0.5, 0.5, 0.6, 0.3);
birdShape.bezierCurveTo(0.8, 0.4, 1.0, 0.2, 1.2, 0.1);
birdShape.lineTo(1.1, 0.0);
birdShape.bezierCurveTo(0.8, -0.1, 0.5, -0.3, 0.3, -0.2);
birdShape.lineTo(0, 0);
const extrudeSettings = { depth: 0.1, bevelEnabled: false };
const birdGeom = new THREE.ExtrudeGeometry(birdShape, extrudeSettings);
birdGeom.scale(scale, scale, scale);
birdGeom.center();
return samplePointsFromMesh(new THREE.Mesh(birdGeom), count);
}
function generateFacePoints(count, scale) {
const faceProfilePoints = [
new THREE.Vector2(0.01, -1.0),
new THREE.Vector2(0.3, -0.95),
new THREE.Vector2(0.35, -0.6),
new THREE.Vector2(0.25, -0.3),
new THREE.Vector2(0.4, -0.15),
new THREE.Vector2(0.2, 0.1),
new THREE.Vector2(0.45, 0.05),
new THREE.Vector2(0.4, 0.5),
new THREE.Vector2(0.3, 0.8),
new THREE.Vector2(0.01, 1.0),
];
const faceLatheGeom = new THREE.LatheGeometry(faceProfilePoints, 20);
faceLatheGeom.scale(scale, scale, scale);
faceLatheGeom.center();
return samplePointsFromMesh(new THREE.Mesh(faceLatheGeom), count);
}
function generateTreePoints(count, scale) {
const points = [];
const trunkHeight = 1.5 * scale;
const trunkRadius = 0.15 * scale;
const canopyRadius = 0.8 * scale;
const canopyHeight = 1.2 * scale;
const trunkGeom = new THREE.CylinderGeometry(
trunkRadius * 0.8,
trunkRadius,
trunkHeight,
12,
);
trunkGeom.translate(0, trunkHeight / 2, 0);
const canopyGeom = new THREE.ConeGeometry(
canopyRadius,
canopyHeight,
16,
);
canopyGeom.translate(0, trunkHeight + canopyHeight / 2, 0);
const numTrunkParticles = Math.floor(count * 0.2);
const numCanopyParticles = count - numTrunkParticles;
if (numTrunkParticles > 0) {
points.push(
...samplePointsFromMesh(
new THREE.Mesh(trunkGeom),
numTrunkParticles,
),
);
}
if (numCanopyParticles > 0) {
points.push(
...samplePointsFromMesh(
new THREE.Mesh(canopyGeom),
numCanopyParticles,
),
);
}
// Center the tree model after points are generated
const groupBox = new THREE.Box3();
points.forEach((p) => groupBox.expandByPoint(p));
const centerOffset = new THREE.Vector3();
groupBox.getCenter(centerOffset);
points.forEach((p) => p.sub(centerOffset));
return points;
}
function setupParticleSystem() {
const geometry = new THREE.BufferGeometry();
// Store initial positions (sphere) also as morphTarget0
const positions = new Float32Array(NUM_PARTICLES * 3);
const morphTarget0 = new Float32Array(NUM_PARTICLES * 3);
const morphTarget1 = new Float32Array(NUM_PARTICLES * 3);
const morphTarget2 = new Float32Array(NUM_PARTICLES * 3);
const morphTarget3 = new Float32Array(NUM_PARTICLES * 3);
for (let i = 0; i < NUM_PARTICLES; i++) {
morphTargetData[0][i].toArray(morphTarget0, i * 3);
morphTargetData[1][i].toArray(morphTarget1, i * 3);
morphTargetData[2][i].toArray(morphTarget2, i * 3);
morphTargetData[3][i].toArray(morphTarget3, i * 3);
}
// Initial positions are from morphTarget0 (Sphere)
geometry.setAttribute(
"position",
new THREE.BufferAttribute(morphTarget0, 3),
);
geometry.setAttribute(
"morphTarget0",
new THREE.BufferAttribute(morphTarget0, 3),
);
geometry.setAttribute(
"morphTarget1",
new THREE.BufferAttribute(morphTarget1, 3),
);
geometry.setAttribute(
"morphTarget2",
new THREE.BufferAttribute(morphTarget2, 3),
);
geometry.setAttribute(
"morphTarget3",
new THREE.BufferAttribute(morphTarget3, 3),
);
/**
* Shader logic for GPU particle updates:
* The vertex shader receives four morph target position attributes (morphTarget0-3).
* It also receives uniforms: uSourceIndex, uDestinationIndex, and uProgress.
* - uSourceIndex: Integer index (0-3) of the starting shape.
* - uDestinationIndex: Integer index (0-3) of the target shape.
* - uProgress: Float (0-1) indicating the interpolation amount between source and destination.
* The shader selects the two relevant morph target positions based on uSourceIndex and uDestinationIndex,
* then uses `mix()` (GLSL's linear interpolation) with uProgress to calculate the current particle position.
* This calculation happens per-vertex on the GPU, enabling smooth animation of many particles.
* Particle size is also calculated in the vertex shader, with perspective attenuation.
* The fragment shader colors the particles and can create soft circular shapes.
*/
particleMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0.0 },
uParticleSize: { value: guiControls.particleSize },
uParticleColor: { value: guiControls.particleColor },
uSourceIndex: { value: 0 },
uDestinationIndex: { value: 0 },
uProgress: { value: 0.0 },
uPointSizeConstant: { value: window.innerHeight / 2.0 }, // For size attenuation
},
vertexShader: `
attribute vec3 morphTarget0;
attribute vec3 morphTarget1;
attribute vec3 morphTarget2;
attribute vec3 morphTarget3;
uniform float uTime;
uniform float uParticleSize;
uniform int uSourceIndex;
uniform int uDestinationIndex;
uniform float uProgress;
uniform float uPointSizeConstant;
vec3 getTargetPos(int index) {
if (index == 0) return morphTarget0;
if (index == 1) return morphTarget1;
if (index == 2) return morphTarget2;
if (index == 3) return morphTarget3;
return morphTarget0; // Default
}
void main() {
vec3 posSource = getTargetPos(uSourceIndex);
vec3 posDest = getTargetPos(uDestinationIndex);
// Add a tiny bit of individual motion to make it more dynamic (optional)
// float randomFactor = fract(sin(dot(posSource.xy ,vec2(12.9898,78.233))) * 43758.5453);
// vec3 offset = vec3(sin(uTime * 0.1 + randomFactor * 6.28) * 0.05, cos(uTime * 0.1 + randomFactor * 6.28) * 0.05, 0.0);
vec3 morphedPosition = mix(posSource, posDest, uProgress);
// morphedPosition += offset;
vec4 mvPosition = modelViewMatrix * vec4(morphedPosition, 1.0);
gl_Position = projectionMatrix * mvPosition;
gl_PointSize = uParticleSize * (uPointSizeConstant / -mvPosition.z);
}
`,
fragmentShader: `
uniform vec3 uParticleColor;
void main() {
float dist = length(gl_PointCoord - vec2(0.5));
if (dist > 0.5) discard; // Circular points
float alpha = 1.0 - smoothstep(0.4, 0.5, dist); // Soft edges
gl_FragColor = vec4(uParticleColor, alpha);
}
`,
transparent: true,
depthTest: true, // Enable depth test for correct occlusion
depthWrite: false, // Disable depth write for transparent particles (especially with bloom)
blending: THREE.AdditiveBlending, // Brighter where particles overlap
});
particleSystem = new THREE.Points(geometry, particleMaterial);
scene.add(particleSystem);
}
/**
* How postprocessing pipeline is assembled:
* 1. An EffectComposer is created, which manages a chain of postprocessing passes.
* 2. A RenderPass is added first. This pass renders the main scene (with particles) into a buffer.
* 3. An UnrealBloomPass is added next. It takes the output of the RenderPass and applies a bloom effect,
* making bright areas (like our particles) glow. Its parameters (strength, radius, threshold) can be tuned.
* 4. A ShaderPass with GammaCorrectionShader is added last. This corrects the color space of the final image,
* ensuring colors look correct on the display. This is typically the final pass before rendering to screen.
* In the animation loop, `composer.render()` is called instead of `renderer.render()`, which processes all passes.
*/
function setupPostProcessing() {
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.5,
0.4,
0.85,
);
bloomPass.threshold = 0; // Adjust as needed, 0 means almost everything blooms a bit
bloomPass.strength = guiControls.bloomStrength;
bloomPass.radius = 0.5;
composer.addPass(bloomPass);
gammaCorrectionPass = new ShaderPass(GammaCorrectionShader);
composer.addPass(gammaCorrectionPass);
}
function setupMotionTrail() {
trailScene = new THREE.Scene();
trailCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const trailQuadGeo = new THREE.PlaneGeometry(2, 2);
trailMaterial = new THREE.MeshBasicMaterial({
color: scene.background, // Use scene background color
transparent: true,
depthTest: false,
depthWrite: false,
});
trailQuad = new THREE.Mesh(trailQuadGeo, trailMaterial);
trailScene.add(trailQuad);
}
function setupUI() {
document
.getElementById("particleSize")
.addEventListener("input", (e) => {
guiControls.particleSize = parseFloat(e.target.value);
document.getElementById("particleSizeValue").textContent =
guiControls.particleSize.toFixed(1);
});
document
.getElementById("rotationSpeed")
.addEventListener("input", (e) => {
guiControls.rotationSpeed = parseFloat(e.target.value);
document.getElementById("rotationSpeedValue").textContent =
guiControls.rotationSpeed.toFixed(2);
});
document
.getElementById("particleColor")
.addEventListener("input", (e) => {
guiControls.particleColor.set(e.target.value);
});
document
.getElementById("bloomStrength")
.addEventListener("input", (e) => {
guiControls.bloomStrength = parseFloat(e.target.value);
document.getElementById("bloomStrengthValue").textContent =
guiControls.bloomStrength.toFixed(2);
});
document
.getElementById("motionTrail")
.addEventListener("input", (e) => {
guiControls.motionTrailOpacity = parseFloat(e.target.value);
document.getElementById("motionTrailValue").textContent =
guiControls.motionTrailOpacity.toFixed(2);
});
document
.getElementById("morphTarget")
.addEventListener("change", (e) => {
const newTargetIndex = parseInt(e.target.value);
startMorph(newTargetIndex);
});
// Initialize UI values display
document.getElementById("particleSizeValue").textContent =
guiControls.particleSize.toFixed(1);
document.getElementById("rotationSpeedValue").textContent =
guiControls.rotationSpeed.toFixed(2);
document.getElementById("bloomStrengthValue").textContent =
guiControls.bloomStrength.toFixed(2);
document.getElementById("motionTrailValue").textContent =
guiControls.motionTrailOpacity.toFixed(2);
}
/**
* How morph targets are interpolated:
* When a new morph target is selected via the UI:
* 1. `startMorph(newTargetIndex)` is called.
* 2. `previousShapeIndex` is set to the current shape index (the shape we are morphing FROM).
* 3. `currentShapeIndex` is updated to `newTargetIndex` (the shape we are morphing TO).
* 4. `isMorphing` flag is set to true, and `morphStartTime` is recorded.
* 5. Shader uniforms `uSourceIndex` and `uDestinationIndex` are updated to `previousShapeIndex` and `currentShapeIndex` respectively.
* In the `animate` loop:
* 1. If `isMorphing` is true, `morphProgress` is calculated based on elapsed time since `morphStartTime`, over `MORPH_DURATION`.
* 2. This progress (0 to 1, smoothed with a smoothstep function) is passed to the shader as `uProgress`.
* 3. The vertex shader uses this `uProgress` to interpolate between `posSource` and `posDest`.
* 4. When `morphProgress` reaches 1.0, `isMorphing` is set to false. The `uSourceIndex` and `uDestinationIndex` in the shader
* are both set to `currentShapeIndex` and `uProgress` to 0, effectively "locking" the particles to the new shape.
*/
function startMorph(newTargetIndex) {
if (newTargetIndex === currentShapeIndex && !isMorphing) return; // Already at this shape
if (
isMorphing &&
newTargetIndex === particleMaterial.uniforms.uDestinationIndex.value
)
return; // Already morphing to this
if (isMorphing) {
// If a morph is in progress, use its current destination as the new source
previousShapeIndex =
particleMaterial.uniforms.uDestinationIndex.value;
} else {
previousShapeIndex = currentShapeIndex;
}
currentShapeIndex = newTargetIndex; // This now represents the target we are heading towards
particleMaterial.uniforms.uSourceIndex.value = previousShapeIndex;
particleMaterial.uniforms.uDestinationIndex.value = currentShapeIndex;
isMorphing = true;
morphStartTime = clock.getElapsedTime();
particleMaterial.uniforms.uProgress.value = 0.0; // Start progress from 0 for the new morph
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
particleMaterial.uniforms.uPointSizeConstant.value =
window.innerHeight / 2.0;
}
function animate() {
requestAnimationFrame(animate);
const deltaTime = clock.getDelta();
const elapsedTime = clock.getElapsedTime();
controls.update();
// Update uniforms
particleMaterial.uniforms.uTime.value = elapsedTime;
particleMaterial.uniforms.uParticleSize.value =
guiControls.particleSize;
particleMaterial.uniforms.uParticleColor.value.copy(
guiControls.particleColor,
);
bloomPass.strength = guiControls.bloomStrength;
// Particle system rotation
if (particleSystem) {
particleSystem.rotation.y += guiControls.rotationSpeed * deltaTime;
}
// Morphing logic
if (isMorphing) {
const morphElapsedTime = elapsedTime - morphStartTime;
let progress = Math.min(morphElapsedTime / MORPH_DURATION, 1.0);
// Smoothstep: t * t * (3 - 2 * t)
progress = progress * progress * (3.0 - 2.0 * progress);
particleMaterial.uniforms.uProgress.value = progress;
if (progress >= 1.0) {
isMorphing = false;
// Lock to the destination shape
particleMaterial.uniforms.uSourceIndex.value = currentShapeIndex;
particleMaterial.uniforms.uDestinationIndex.value =
currentShapeIndex;
particleMaterial.uniforms.uProgress.value = 0.0;
// currentShapeIndex already reflects the target shape.
// previousShapeIndex is now effectively currentShapeIndex for the next morph.
}
}
// Motion trail effect
// We need to render the trail quad *before* the main scene is rendered by the composer.
// This "fades" the previous frame.
renderer.autoClearColor = false; // We will manually clear (or fade) with the quad
const currentRenderTarget = renderer.getRenderTarget(); // Preserve render target for composer
renderer.setRenderTarget(null); // Ensure quad renders to screen buffer, which composer will read
trailMaterial.color.copy(scene.background); // Match scene background for fading
trailMaterial.opacity = 1.0 - guiControls.motionTrailOpacity; // 0 means full clear, 0.95 means heavy trail
renderer.render(trailScene, trailCamera);
renderer.setRenderTarget(currentRenderTarget); // Restore render target for composer
renderer.autoClearColor = true; // Composer will handle its own clearing for its passes
// Render with post-processing
composer.render(deltaTime);
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment