Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created May 24, 2025 13:18
Show Gist options
  • Save shricodev/5998f132718738d10d643c9af2e85b05 to your computer and use it in GitHub Desktop.
Save shricodev/5998f132718738d10d643c9af2e85b05 to your computer and use it in GitHub Desktop.
Blog - Morph Particles (OpenAI o3)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Three.js GPU Particle Morphing Demo</title>
<!-- ============================== BASIC STYLES ============================== -->
<style>
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
background: #000;
font-family: Arial;
}
#container {
width: 100%;
height: 100%;
}
/* lil-gui tweaks – keep panel readable on dark bg */
.lil-gui {
--background-color: #20202080;
--widget-color: #404040;
--text-alt: black;
color: white;
}
.lil-gui input {
color: black;
}
</style>
</head>
<body>
<div id="container"></div>
<!-- ============================== MODULE IMPORT MAP ============================== -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/",
"lil-gui": "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
}
}
</script>
<!-- ============================== MAIN SCRIPT ============================== -->
<script type="module">
/* ---------------------------------------------------------
Import dependencies
--------------------------------------------------------- */
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 { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { GammaCorrectionShader } from "three/addons/shaders/GammaCorrectionShader.js";
import { AfterimagePass } from "three/addons/postprocessing/AfterimagePass.js";
import GUI from "lil-gui";
/* ---------------------------------------------------------
User-tuneable parameters (populated into the GUI)
--------------------------------------------------------- */
const params = {
shape: "Sphere", // current morph-target
particleSize: 1.5, // world units
rotationSpeed: 0.3, // radians / sec
color: "#ff8844",
bloomStrength: 1.2,
trail: 0.85, // AfterimagePass damp (0 = infinite trail, 1 = off)
};
/* ---------------------------------------------------------
Scene, Camera, Renderer, Controls
--------------------------------------------------------- */
const container = document.getElementById("container");
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
camera.position.set(0, 0, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(devicePixelRatio);
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
/* lights – just enough to give some depth cues */
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
dir.position.set(5, 5, 5);
scene.add(dir);
/* ---------------------------------------------------------
GPU Particle System
--------------------------------------------------------- */
const COUNT = 40_000; // 40k particles
const positions = new Float32Array(COUNT * 3); // current positions (gl_VertexID fetch)
const targets = new Float32Array(COUNT * 3); // next morph target (attribute "target")
const colors = new Float32Array(COUNT * 3); // per-particle colour
const randomness = new Float32Array(COUNT); // small per-particle noise
fillSphere(positions, 3); // start as sphere
positions.forEach((v, i) => (targets[i] = v)); // same target initially
for (let i = 0; i < COUNT; i++) {
// random colour + jitter
const c = new THREE.Color(params.color);
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
randomness[i] = Math.random();
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
);
geometry.setAttribute("target", new THREE.BufferAttribute(targets, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.setAttribute("rand", new THREE.BufferAttribute(randomness, 1));
/* ---------------- Vertex / Fragment shaders ----------------
position → current location coming from BufferGeometry
target → destination position */
const material = new THREE.ShaderMaterial({
uniforms: {
uSize: { value: params.particleSize },
uProgress: { value: 1.0 }, // 0 → old shape, 1 → new shape
uTime: { value: 0.0 },
},
vertexShader: `
uniform float uSize;
uniform float uProgress;
uniform float uTime;
attribute vec3 target;
attribute float rand;
varying vec3 vColor;
void main(){
/* ------------- MORPH BLEND ON GPU ----------------
mix() gives us smooth interpolation entirely on
the graphics card with one uniform "uProgress". */
vec3 pos = mix(position, target, uProgress);
/* subtle flutter while transitioning */
pos += 0.25 * (1.0 - uProgress) * sin(uTime + rand*6.283) * normalize(pos);
vColor = color;
vec4 mv = modelViewMatrix * vec4(pos,1.0);
gl_PointSize = uSize * (300.0 / -mv.z); // size-attenuation
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: `
varying vec3 vColor;
void main(){
/* soft-edge circular sprite */
float d = length(gl_PointCoord - 0.5);
if(d>0.5) discard;
gl_FragColor = vec4(vColor, 1.0 - d*2.0);
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
vertexColors: true,
});
const particles = new THREE.Points(geometry, material);
scene.add(particles);
/* ---------------------------------------------------------
Post-Processing Pipeline
(Render → Bloom → Motion-Trail → Gamma-Correct)
--------------------------------------------------------- */
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(),
params.bloomStrength,
0.4,
0.85,
);
composer.addPass(bloomPass);
const afterPass = new AfterimagePass(params.trail); // “motion trail” slider
composer.addPass(afterPass);
composer.addPass(new ShaderPass(GammaCorrectionShader));
/* ---------------------------------------------------------
GUI – built with lil-gui (smaller + maintained fork of dat.gui)
--------------------------------------------------------- */
const gui = new GUI();
gui
.add(params, "shape", ["Sphere", "Bird", "Face", "Tree"])
.name("Morph Target")
.onChange((v) => setMorphTarget(v));
gui
.add(params, "particleSize", 0.1, 5.0, 0.1)
.name("Particle Size")
.onChange((v) => (material.uniforms.uSize.value = v));
gui.add(params, "rotationSpeed", -2.0, 2.0, 0.01).name("Rotation Speed");
gui
.addColor(params, "color")
.name("Particle Colour")
.onChange(updateColours);
gui
.add(params, "bloomStrength", 0, 3, 0.01)
.name("Bloom Strength")
.onChange((v) => (bloomPass.strength = v));
gui
.add(params, "trail", 0, 1, 0.01)
.name("Trail Opacity")
.onChange((v) => (afterPass.uniforms["damp"].value = v));
/* ---------------------------------------------------------
Morph-ing bookkeeping
--------------------------------------------------------- */
let morphStart = 0; // ms timestamp of morph launch
const MORPH_TIME = 2000; // duration in ms (tween is linear)
/* Load first target (sphere) */
setMorphTarget("Sphere");
/* ---------------------------------------------------------
RENDER LOOP
--------------------------------------------------------- */
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
material.uniforms.uTime.value += dt;
particles.rotation.y += dt * params.rotationSpeed;
/* drive uProgress based on time since morph started */
const pr = Math.min((performance.now() - morphStart) / MORPH_TIME, 1);
material.uniforms.uProgress.value = pr;
controls.update();
composer.render();
}
animate();
/* ---------------------------------------------------------
Handle window resizing
--------------------------------------------------------- */
function onResize() {
const { innerWidth: w, innerHeight: h } = window;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
composer.setSize(w, h);
}
window.addEventListener("resize", onResize);
onResize();
/* =======================================================================
Helper : update vertex colours when GUI colour picker changes
======================================================================= */
function updateColours() {
const c = new THREE.Color(params.color);
const arr = geometry.attributes.color.array;
for (let i = 0; i < COUNT; i++) {
arr[i * 3] = c.r;
arr[i * 3 + 1] = c.g;
arr[i * 3 + 2] = c.b;
}
geometry.attributes.color.needsUpdate = true;
}
/* =======================================================================
Helper : initiate a new morph
======================================================================= */
function setMorphTarget(name) {
/* build Float32Array with *destination* positions for each particle */
const dest = new Float32Array(COUNT * 3);
switch (name) {
case "Sphere":
fillSphere(dest, 3);
break;
case "Bird":
fillBird(dest);
break;
case "Face":
fillFace(dest);
break;
case "Tree":
fillTree(dest);
break;
}
/* copy into attribute & flag for upload to GPU */
geometry.attributes.target.array.set(dest);
geometry.attributes.target.needsUpdate = true;
/* reset interpolation timer */
morphStart = performance.now();
material.uniforms.uProgress.value = 0;
}
/* =======================================================================
=== Procedural shape builders ======================================
Every function below fills the supplied Float32Array (dest) with
COUNT*3 floats representing x,y,z. All “art” is just maths – no
external meshes, textures or models are fetched.
======================================================================= */
/* ---------------- 1. perfect sphere (Fibonacci spiral distribution) ---- */
function fillSphere(dest, radius = 1) {
for (let i = 0; i < COUNT; i++) {
const y = 1 - (i / (COUNT - 1)) * 2; // y ∈ [1..-1]
const r = Math.sqrt(1 - y * y);
const phi = i * 2.399963; // golden angle
const x = Math.cos(phi) * r;
const z = Math.sin(phi) * r;
dest[i * 3] = x * radius;
dest[i * 3 + 1] = y * radius;
dest[i * 3 + 2] = z * radius;
}
}
/* ---------------- 2. stylised bird in flight --------------------------- */
function fillBird(dest) {
for (let i = 0; i < COUNT; i++) {
let x, y, z;
const t = Math.random();
if (t < 0.6) {
// wings (60 %)
const wing = t < 0.3 ? -1 : 1; // left / right
x = wing * (Math.random() * 3);
y = (Math.random() - 0.5) * 1;
z = (Math.random() - 0.5) * 0.4;
} else if (t < 0.9) {
// body (30 %)
x = (Math.random() - 0.5) * 1;
y = (Math.random() - 0.1) * 1.2;
z = (Math.random() - 0.5) * 0.6;
} else {
// head (10 %)
x = 0.5 + (Math.random() - 0.5) * 0.4;
y = 0.6 + (Math.random() - 0.5) * 0.3;
z = (Math.random() - 0.5) * 0.3;
}
dest[i * 3] = x;
dest[i * 3 + 1] = y;
dest[i * 3 + 2] = z;
}
}
/* ---------------- 3. human face (ellipsoid + extras) ------------------- */
function fillFace(dest) {
for (let i = 0; i < COUNT; i++) {
let x, y, z;
const phi = Math.acos(2 * Math.random() - 1);
const theta = 2 * Math.PI * Math.random();
const r = 1.5 + Math.random() * 0.05;
x = r * Math.sin(phi) * Math.cos(theta) * 0.9; // squash width
y = r * Math.cos(phi) * 1.1; // stretch height
z = r * Math.sin(phi) * Math.sin(theta);
/* recess eye-sockets by pushing z backwards */
if (
(x > 0.3 && x < 0.9 && y > 0.2 && y < 1) ||
(x < -0.3 && x > -0.9 && y > 0.2 && y < 1)
) {
z += 0.3;
}
/* add occasional nose ridge points */
if (i % 50 === 0) {
x = 0;
y = 0.3 + Math.random() * 0.3;
z = 1.2 + Math.random() * 0.2;
}
dest[i * 3] = x * 2;
dest[i * 3 + 1] = y * 2;
dest[i * 3 + 2] = z * 2;
}
}
/* ---------------- 4. tree (cylinder trunk + leafy cone) ---------------- */
function fillTree(dest) {
for (let i = 0; i < COUNT; i++) {
let x, y, z;
const t = Math.random();
if (t < 0.25) {
// trunk 25 %
const a = Math.random() * Math.PI * 2;
const r = Math.random() * 0.2;
x = Math.cos(a) * r;
z = Math.sin(a) * r;
y = -2 + Math.random() * 2.5; // trunk height
} else {
// canopy 75 %
const a = Math.random() * Math.PI * 2;
const h = Math.random() * 3;
const r = (3 - h) * 0.6 * Math.random();
x = Math.cos(a) * r;
z = Math.sin(a) * r;
y = h - 0.5;
}
dest[i * 3] = x;
dest[i * 3 + 1] = y;
dest[i * 3 + 2] = z;
}
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment