Back to Components
Three.js & GLSL Particle Metamorphosis
Component

Three.js & GLSL Particle Metamorphosis

CodewithLord
November 29, 2025

A stunning morphing particle system built using Three.js, GLSL shaders, and post-processing. Created by @CodewithLord.

🎆 Three.js & GLSL Particle Metamorphosis

A high-performance particle system built using Three.js + GLSL shaders that smoothly morphs between complex 3D mathematical shapes like Torus Knot, Halvorsen Attractor, Dual Helix, and De Jong Attractor.

The animation includes:

  • ✨ 15,000 morphing particles
  • 🌟 7,000 animated stars
  • ⚡ 2,000 spark particles
  • 🎛️ Bloom, Afterimage, and Post-Processing Effects
  • 🌀 Real-time morphing physics
  • 🧭 Drag controls + Auto-rotation
  • 💫 GPU-accelerated shader animations

1<!DOCTYPE html> 2<html lang="en"> 3 4 <head> 5 <meta charset="UTF-8"> 6 <title>Three.js &amp; GLSL Particle Metamorphosis by @CodewithLord</title> 7 8 9 </head> 10 11 <body> 12 <meta charset="UTF-8" /> 13<meta name="viewport" content="width=device-width, initial-scale=1.0" /> 14<title>Morphing Shapes</title> 15 16<link rel="preconnect" href="https://fonts.googleapis.com"> 17<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 18<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet"> 19 20<style> 21 *, *::before, *::after { 22 margin: 0; 23 padding: 0; 24 box-sizing: border-box; 25 } 26 body { 27 font-family: "Inter", sans-serif; 28 overflow: hidden; 29 background: #040307; 30 background-image: 31 radial-gradient(circle at 50% 35%, #1d1431 0%, transparent 65%), 32 linear-gradient(180deg, #000000 0%, #070012 100%); 33 color: #eee; 34 } 35 #container { 36 position: fixed; 37 inset: 0; 38 } 39 .vignette { 40 position: fixed; 41 inset: 0; 42 pointer-events: none; 43 z-index: 9; 44 background: radial-gradient(circle at center, rgba(0,0,0,0) 65%, rgba(0,0,0,.5) 100%); 45 } 46 canvas { 47 display: block; 48 width: 100%; 49 height: 100%; 50 } 51 52 .instructions { 53 position: fixed; 54 left: 24px; 55 bottom: 24px; 56 transform: none; 57 padding: 10px 20px; 58 font-size: 12px; 59 text-align: left; 60 pointer-events: none; 61 color: #d0b0ff; 62 background: rgba(18, 15, 40, 0.25); 63 border: 1px solid rgba(122, 70, 255, 0.28); 64 border-radius: 12px; 65 backdrop-filter: blur(12px); 66 z-index: 10; 67 box-shadow: 0 4px 20px rgba(0,0,0,.45); 68 } 69 70 #morphButton { 71 position: fixed; 72 left: 50%; 73 bottom: 24px; 74 transform: translateX(-50%); 75 padding: 12px 30px; 76 font-size: 14px; 77 font-weight: 500; 78 color: rgba(230, 220, 255, 0.9); 79 background: rgba(255, 255, 255, 0.05); 80 border: 1px solid rgba(255, 255, 255, 0.2); 81 border-radius: 50px; 82 backdrop-filter: blur(10px) saturate(180%); 83 -webkit-backdrop-filter: blur(10px) saturate(180%); 84 box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); 85 cursor: pointer; 86 z-index: 10; 87 transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); 88 text-shadow: 0 1px 2px rgba(0,0,0,0.2); 89 } 90 91 #morphButton:hover { 92 background: rgba(255, 255, 255, 0.15); 93 transform: translateX(-50%) scale(1.05); 94 box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.45); 95 color: white; 96 } 97 98 #morphButton:active { 99 transform: translateX(-50%) scale(0.98); 100 } 101</style> 102 103<script type="importmap"> 104{ 105 "imports": { 106 "three": "https://cdn.jsdelivr.net/npm/three@0.162.0/build/three.module.js", 107 "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.162.0/examples/jsm/" 108 } 109} 110</script> 111 112<div id="container"></div> 113<div class="vignette"></div> 114<div class="instructions">Drag to explore</div> 115<button id="morphButton">Morph Shape</button> 116 117<script type="module"> 118import * as THREE from "three"; 119import { OrbitControls } from "three/addons/controls/OrbitControls.js"; 120import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js"; 121import { RenderPass } from "three/addons/postprocessing/RenderPass.js"; 122import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js"; 123import { AfterimagePass } from "three/addons/postprocessing/AfterimagePass.js"; 124import { OutputPass } from "three/addons/postprocessing/OutputPass.js"; 125 126const PARTICLE_COUNT = 15000; 127const SPARK_COUNT = 2000; 128const STAR_COUNT = 7000; 129 130let scene, camera, renderer, composer, controls; 131let particles, sparkles, stars; 132let clock = new THREE.Clock(); 133let currentPattern = 0, isTrans = false, prog = 0; 134const morphSpeed = .03; 135 136function normalise(points, size) { 137 if (points.length === 0) return []; 138 const box = new THREE.Box3().setFromPoints(points); 139 const maxDim = Math.max(...box.getSize(new THREE.Vector3()).toArray()) || 1; 140 const centre = box.getCenter(new THREE.Vector3()); 141 return points.map(p => p.clone().sub(centre).multiplyScalar(size / maxDim)); 142} 143 144function torusKnot(n) { 145 const geometry = new THREE.TorusKnotGeometry(10, 3, 200, 16, 2, 3); 146 const points = []; 147 const positionAttribute = geometry.attributes.position; 148 for (let i = 0; i < positionAttribute.count; i++) { 149 points.push(new THREE.Vector3().fromBufferAttribute(positionAttribute, i)); 150 } 151 const result = []; 152 for (let i = 0; i < n; i++) { 153 result.push(points[i % points.length].clone()); 154 } 155 return normalise(result, 50); 156} 157 158function halvorsen(n) { 159 const pts = []; 160 let x = 0.1, y = 0, z = 0; 161 const a = 1.89; 162 const dt = 0.005; 163 for (let i = 0; i < n * 25; i++) { 164 const dx = -a * x - 4 * y - 4 * z - y * y; 165 const dy = -a * y - 4 * z - 4 * x - z * z; 166 const dz = -a * z - 4 * x - 4 * y - x * x; 167 x += dx * dt; 168 y += dy * dt; 169 z += dz * dt; 170 if (i > 200 && i % 25 === 0) { 171 pts.push(new THREE.Vector3(x, y, z)); 172 } 173 if (pts.length >= n) break; 174 } 175 while(pts.length < n) pts.push(pts[Math.floor(Math.random()*pts.length)].clone()); 176 return normalise(pts, 60); 177} 178 179function dualHelix(n) { 180 const pts = []; 181 const turns = 5; 182 const radius = 15; 183 const height = 40; 184 for (let i = 0; i < n; i++) { 185 const isSecondHelix = i % 2 === 0; 186 const angle = (i / n) * Math.PI * 2 * turns; 187 const y = (i / n) * height - height / 2; 188 const r = radius + (isSecondHelix ? 5 : -5); 189 const x = Math.cos(angle) * r; 190 const z = Math.sin(angle) * r; 191 pts.push(new THREE.Vector3(x, y, z)); 192 } 193 return normalise(pts, 60); 194} 195 196function deJong(n) { 197 const pts = []; 198 let x = 0.1, y = 0.1; 199 const a = 1.4, b = -2.3, c = 2.4, d = -2.1; 200 for (let i = 0; i < n; i++) { 201 const xn = Math.sin(a * y) - Math.cos(b * x); 202 const yn = Math.sin(c * x) - Math.cos(d * y); 203 x = xn; 204 y = yn; 205 const z = Math.sin(x * y * 0.5); 206 pts.push(new THREE.Vector3(x, y, z)); 207 } 208 return normalise(pts, 55); 209} 210 211 212const PATTERNS = [torusKnot, halvorsen, dualHelix, deJong]; 213 214function createStars(){const geo=new THREE.BufferGeometry();const pos=new Float32Array(STAR_COUNT*3);const col=new Float32Array(STAR_COUNT*3);const size=new Float32Array(STAR_COUNT);const rnd=new Float32Array(STAR_COUNT);const R=900;for(let i=0;i<STAR_COUNT;i++){const i3=i*3,θ=Math.random()*2*Math.PI,φ=Math.acos(2*Math.random()-1),r=R*Math.cbrt(Math.random());pos[i3]=r*Math.sin(φ)*Math.cos(θ);pos[i3+1]=r*Math.sin(φ)*Math.sin(θ);pos[i3+2]=r*Math.cos(φ);const c=new THREE.Color().setHSL(Math.random()*.6,.3+.3*Math.random(),.55+.35*Math.random());col[i3]=c.r;col[i3+1]=c.g;col[i3+2]=c.b;size[i]=.25+Math.pow(Math.random(),4)*2.1;rnd[i]=Math.random()*Math.PI*2}geo.setAttribute("position",new THREE.BufferAttribute(pos,3));geo.setAttribute("color",new THREE.BufferAttribute(col,3));geo.setAttribute("size",new THREE.BufferAttribute(size,1));geo.setAttribute("random",new THREE.BufferAttribute(rnd,1));const mat=new THREE.ShaderMaterial({uniforms:{time:{value:0}},vertexShader:`attribute float size;attribute float random; 215varying vec3 vColor;varying float vRnd; 216void main(){vColor=color;vRnd=random;vec4 mv=modelViewMatrix*vec4(position,1.);gl_PointSize=size*(250./-mv.z);gl_Position=projectionMatrix*mv;}`,fragmentShader:`uniform float time;varying vec3 vColor;varying float vRnd; 217void main(){vec2 uv=gl_PointCoord-.5;float d=length(uv);float a=1.-smoothstep(.4,.5,d);a*=.7+.3*sin(time*(.6+vRnd*.3)+vRnd*5.);if(a<.02)discard;gl_FragColor=vec4(vColor,a);}`,transparent:true,depthWrite:false,vertexColors:true,blending:THREE.AdditiveBlending});return new THREE.Points(geo,mat)} 218 219function makeParticles(count,palette){ 220 const geo=new THREE.BufferGeometry(); 221 const pos=new Float32Array(count*3); 222 const col=new Float32Array(count*3); 223 const size=new Float32Array(count); 224 const rnd=new Float32Array(count*3); 225 for(let i=0;i<count;i++){ 226 const i3=i*3,base=palette[Math.random()*palette.length|0],hsl={h:0,s:0,l:0}; 227 base.getHSL(hsl); 228 hsl.h+=(Math.random()-.5)*.05; 229 hsl.s=Math.min(1,Math.max(.7,hsl.s+(Math.random()-.5)*.3)); 230 hsl.l=Math.min(.9,Math.max(.5,hsl.l+(Math.random()-.5)*.4)); 231 const c=new THREE.Color().setHSL(hsl.h,hsl.s,hsl.l); 232 col[i3]=c.r;col[i3+1]=c.g;col[i3+2]=c.b; 233 size[i]=.7+Math.random()*1.1; 234 rnd[i3]=Math.random()*10; 235 rnd[i3+1]=Math.random()*Math.PI*2; 236 rnd[i3+2]=.5+.5*Math.random(); 237 } 238 geo.setAttribute("position",new THREE.BufferAttribute(pos,3)); 239 geo.setAttribute("color",new THREE.BufferAttribute(col,3)); 240 geo.setAttribute("size",new THREE.BufferAttribute(size,1)); 241 geo.setAttribute("random",new THREE.BufferAttribute(rnd,3)); 242 243 const mat=new THREE.ShaderMaterial({ 244 uniforms:{time:{value:0},hueSpeed:{value:0.12}}, 245 vertexShader:`uniform float time;attribute float size;attribute vec3 random; 246varying vec3 vCol;varying float vR; 247void main(){ 248 vCol=color;vR=random.z; 249 vec3 p=position; 250 float t=time*.25*random.z; 251 float ax=t+random.y, ay=t*.75+random.x; 252 float amp=(.6+sin(random.x+t*.6)*.3)*random.z; 253 p.x+=sin(ax+p.y*.06+random.x*.1)*amp; 254 p.y+=cos(ay+p.z*.06+random.y*.1)*amp; 255 p.z+=sin(ax*.85+p.x*.06+random.z*.1)*amp; 256 vec4 mv=modelViewMatrix*vec4(p,1.); 257 float pulse=.9+.1*sin(time*1.15+random.y); 258 gl_PointSize=size*pulse*(350./-mv.z); 259 gl_Position=projectionMatrix*mv; 260}`, 261 fragmentShader:` 262uniform float time; 263uniform float hueSpeed; 264varying vec3 vCol; 265varying float vR; 266 267vec3 hueShift(vec3 c, float h) { 268 const vec3 k = vec3(0.57735); 269 float cosA = cos(h); 270 float sinA = sin(h); 271 return c * cosA + cross(k, c) * sinA + k * dot(k, c) * (1.0 - cosA); 272} 273 274void main() { 275 vec2 uv = gl_PointCoord - 0.5; 276 float d = length(uv); 277 278 float core = smoothstep(0.05, 0.0, d); 279 float angle = atan(uv.y, uv.x); 280 float flare = pow(max(0.0, sin(angle * 6.0 + time * 2.0 * vR)), 4.0); 281 flare *= smoothstep(0.5, 0.0, d); 282 float glow = smoothstep(0.4, 0.1, d); 283 284 float alpha = core * 1.0 + flare * 0.5 + glow * 0.2; 285 286 vec3 color = hueShift(vCol, time * hueSpeed); 287 vec3 finalColor = mix(color, vec3(1.0, 0.95, 0.9), core); 288 finalColor = mix(finalColor, color, flare * 0.5 + glow * 0.5); 289 290 if (alpha < 0.01) discard; 291 292 gl_FragColor = vec4(finalColor, alpha); 293}`, 294 transparent:true,depthWrite:false,vertexColors:true,blending:THREE.AdditiveBlending 295 }); 296 return new THREE.Points(geo,mat); 297} 298 299function createSparkles(count) { 300 const geo = new THREE.BufferGeometry(); 301 const pos = new Float32Array(count * 3); 302 const size = new Float32Array(count); 303 const rnd = new Float32Array(count * 3); 304 305 for (let i = 0; i < count; i++) { 306 size[i] = 0.5 + Math.random() * 0.8; 307 rnd[i*3] = Math.random() * 10; 308 rnd[i*3+1] = Math.random() * Math.PI * 2; 309 rnd[i*3+2] = 0.5 + 0.5 * Math.random(); 310 } 311 geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); 312 geo.setAttribute('size', new THREE.BufferAttribute(size, 1)); 313 geo.setAttribute('random', new THREE.BufferAttribute(rnd, 3)); 314 315 const mat = new THREE.ShaderMaterial({ 316 uniforms: { time: { value: 0 } }, 317 vertexShader: ` 318 uniform float time; 319 attribute float size; 320 attribute vec3 random; 321 void main() { 322 vec3 p = position; 323 float t = time * 0.25 * random.z; 324 float ax = t + random.y, ay = t * 0.75 + random.x; 325 float amp = (0.6 + sin(random.x + t * 0.6) * 0.3) * random.z; 326 p.x += sin(ax + p.y * 0.06 + random.x * 0.1) * amp; 327 p.y += cos(ay + p.z * 0.06 + random.y * 0.1) * amp; 328 p.z += sin(ax * 0.85 + p.x * 0.06 + random.z * 0.1) * amp; 329 vec4 mvPosition = modelViewMatrix * vec4(p, 1.0); 330 gl_PointSize = size * (300.0 / -mvPosition.z); 331 gl_Position = projectionMatrix * mvPosition; 332 }`, 333 fragmentShader: ` 334 uniform float time; 335 void main() { 336 float d = length(gl_PointCoord - vec2(0.5)); 337 float alpha = 1.0 - smoothstep(0.4, 0.5, d); 338 if (alpha < 0.01) discard; 339 gl_FragColor = vec4(1.0, 1.0, 1.0, alpha); 340 }`, 341 transparent: true, 342 depthWrite: false, 343 blending: THREE.AdditiveBlending 344 }); 345 346 return new THREE.Points(geo, mat); 347} 348 349 350function init() { 351 scene = new THREE.Scene(); 352 scene.fog = new THREE.FogExp2(0x050203, .012); 353 354 camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, .1, 2500); 355 camera.position.set(0, 0, 80); 356 357 renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); 358 renderer.setPixelRatio(devicePixelRatio); 359 renderer.setSize(innerWidth, innerHeight); 360 document.getElementById("container").appendChild(renderer.domElement); 361 362 controls = new OrbitControls(camera, renderer.domElement); 363 controls.enableDamping = true; 364 controls.dampingFactor = 0.05; 365 controls.screenSpacePanning = false; 366 controls.minDistance = 20; 367 controls.maxDistance = 200; 368 controls.target.set(0, 0, 0); 369 controls.autoRotate = true; 370 controls.autoRotateSpeed = 0.5; 371 372 stars = createStars(); 373 scene.add(stars); 374 375 const palette = [0xff3c78, 0xff8c00, 0xfff200, 0x00cfff, 0xb400ff, 0xffffff, 0xff4040].map(c => new THREE.Color(c)); 376 particles = makeParticles(PARTICLE_COUNT, palette); 377 sparkles = createSparkles(SPARK_COUNT); 378 scene.add(particles); 379 scene.add(sparkles); 380 381 composer = new EffectComposer(renderer); 382 composer.addPass(new RenderPass(scene, camera)); 383 composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), .45, .5, .85)); 384 const after = new AfterimagePass(); 385 after.uniforms.damp.value = .92; 386 composer.addPass(after); 387 composer.addPass(new OutputPass()); 388 389 applyPattern(currentPattern); 390 391 addEventListener("resize", () => { 392 camera.aspect = innerWidth / innerHeight; 393 camera.updateProjectionMatrix(); 394 renderer.setSize(innerWidth, innerHeight); 395 composer.setSize(innerWidth, innerHeight); 396 }); 397 398 document.getElementById('morphButton').addEventListener('click', () => { 399 if (!isTrans) { 400 beginMorph(); 401 } 402 }); 403} 404 405function applyPattern(i){ 406 const pts = PATTERNS[i](PARTICLE_COUNT); 407 const particleArr = particles.geometry.attributes.position.array; 408 const sparkleArr = sparkles.geometry.attributes.position.array; 409 for(let j=0; j<PARTICLE_COUNT; j++){ 410 const idx = j*3; 411 const p = pts[j] || new THREE.Vector3(); 412 particleArr[idx] = p.x; 413 particleArr[idx+1] = p.y; 414 particleArr[idx+2] = p.z; 415 if (j < SPARK_COUNT) { 416 sparkleArr[idx] = p.x; 417 sparkleArr[idx+1] = p.y; 418 sparkleArr[idx+2] = p.z; 419 } 420 } 421 particles.geometry.attributes.position.needsUpdate=true; 422 sparkles.geometry.attributes.position.needsUpdate=true; 423} 424 425function beginMorph(){ 426 isTrans=true; 427 prog=0; 428 const next=(currentPattern+1)%PATTERNS.length; 429 const fromPts = particles.geometry.attributes.position.array.slice(); 430 const toPts = PATTERNS[next](PARTICLE_COUNT); 431 432 const to = new Float32Array(PARTICLE_COUNT*3); 433 if(toPts.length > 0){ 434 for(let j=0; j<PARTICLE_COUNT; j++){ 435 const idx=j*3, p=toPts[j]; 436 to[idx]=p.x; to[idx+1]=p.y; to[idx+2]=p.z; 437 } 438 particles.userData={from: fromPts, to, next}; 439 sparkles.userData={from: fromPts, to, next}; 440 } 441} 442 443function animate() { 444 requestAnimationFrame(animate); 445 const dt = clock.getDelta(), t = clock.getElapsedTime(); 446 447 controls.update(); 448 449 particles.material.uniforms.time.value = t; 450 sparkles.material.uniforms.time.value = t; 451 452 if (isTrans) { 453 prog += morphSpeed; 454 const eased = prog >= 1 ? 1 : 1 - Math.pow(1 - prog, 3); 455 const { from, to } = particles.userData; 456 if (to) { 457 const particleArr = particles.geometry.attributes.position.array; 458 const sparkleArr = sparkles.geometry.attributes.position.array; 459 for (let i = 0; i < particleArr.length; i++) { 460 const val = from[i] + (to[i] - from[i]) * eased; 461 particleArr[i] = val; 462 if (i < sparkleArr.length) { 463 sparkleArr[i] = val; 464 } 465 } 466 particles.geometry.attributes.position.needsUpdate = true; 467 sparkles.geometry.attributes.position.needsUpdate = true; 468 } 469 if (prog >= 1) { 470 currentPattern = particles.userData.next; 471 isTrans = false; 472 } 473 } 474 475 composer.render(dt); 476} 477 478init(); 479animate(); 480</script> 481 482 </body> 483 484</html> 485

Love this component?

Explore more components and build amazing UIs.

View All Components