🎆 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 & 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