-
-
Save shricodev/5998f132718738d10d643c9af2e85b05 to your computer and use it in GitHub Desktop.
Blog - Morph Particles (OpenAI o3)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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