Skip to content
3D

Particle Morph

A point cloud that morphs between a sphere, a torus, a grid or scatter, and pushes away from the pointer. Every particle's home position for BOTH formations is uploaded once as a static attribute and the vertex shader interpolates between them from a single uniform — morphing 9,000 points costs one float upload per frame, not 9,000 buffer writes. Recomputing positions in JavaScript and re-uploading each frame is what caps most implementations at a couple of thousand points.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { ParticleMorph } from "@/components/particlemorph"; <ParticleMorph />

Customise

9000

Uploaded once at mount, so this costs memory rather than per-frame time.

90
8s

0 holds the first formation.

1

The component

components/craft/3d/ParticleMorph.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react";import { createPointerSource } from "../lib/pointerSource"; /** * A point cloud that morphs between formations and scatters away from the * pointer. * * Every particle's home position for BOTH formations is uploaded once as * static attributes, and the vertex shader interpolates between them using a * single uniform. Nothing is rewritten per frame — morphing 20,000 points * costs one float upload, not 20,000 buffer writes. The usual approach, * recomputing positions in JavaScript and re-uploading the buffer each frame, * is what limits those implementations to a couple of thousand points. * * Raw WebGL rather than three.js: this is one draw call of gl.POINTS, and a * scene graph would be ~150KB to issue it. */ const VERTEX = `attribute vec3 aFrom;attribute vec3 aTo;attribute float aSeed; uniform float uMix;        /* 0 = from, 1 = to */uniform float uTime;uniform vec2  uPointer;uniform float uRepel;uniform float uSize;uniform mat4  uProjection; varying float vDepth; void main() {  /* Eased per-particle so the cloud arrives in a wave rather than as a block.     The offset is derived from the seed, which is uploaded once. */  float staggered = clamp(uMix * 1.6 - aSeed * 0.6, 0.0, 1.0);  float eased = staggered * staggered * (3.0 - 2.0 * staggered);  vec3 pos = mix(aFrom, aTo, eased);   /* Slow drift, so a settled formation still breathes instead of freezing. */  pos.x += sin(uTime * 0.6 + aSeed * 6.28) * 0.06;  pos.y += cos(uTime * 0.5 + aSeed * 5.13) * 0.06;   /* Pointer repulsion in screen-ish space, falling off with distance. */  vec2 delta = pos.xy - uPointer;  float dist = length(delta);  float push = smoothstep(0.9, 0.0, dist) * uRepel;  pos.xy += normalize(delta + 1e-6) * push * 0.45;   vec4 clip = uProjection * vec4(pos, 1.0);  gl_Position = clip;   vDepth = clamp(1.0 - (pos.z * 0.5 + 0.5), 0.0, 1.0);  /* Perspective-correct point size: nearer particles are larger, which is     what stops a flat cloud looking like a sticker. */  gl_PointSize = uSize * (1.0 + vDepth) / max(clip.w, 0.001);}`; const FRAGMENT = `precision mediump float;uniform vec3 uColorNear;uniform vec3 uColorFar;varying float vDepth; void main() {  /* Round, soft-edged points. gl_PointCoord is the only way to shape a point     sprite — without this they render as hard squares. */  vec2 c = gl_PointCoord - 0.5;  float d = length(c);  if (d > 0.5) discard;  float alpha = smoothstep(0.5, 0.15, d);   gl_FragColor = vec4(mix(uColorFar, uColorNear, vDepth), alpha);}`; function compile(gl: WebGLRenderingContext, type: number, src: string) {  const s = gl.createShader(type);  if (!s) return null;  gl.shaderSource(s, src);  gl.compileShader(s);  return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null;} function toRgb(hex: string): [number, number, number] {  const m = /^#?([\da-f]{6})$/i.exec(hex.trim());  if (!m) return [1, 1, 1];  const n = parseInt(m[1], 16);  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];} /** Deterministic PRNG, so the cloud is identical on every load and build. */function rng(seed: number) {  return () => {    seed |= 0;    seed = (seed + 0x6d2b79f5) | 0;    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;  };} export type Formation = "sphere" | "grid" | "torus" | "scatter"; function shape(kind: Formation, i: number, total: number, random: () => number) {  const t = i / total;  switch (kind) {    case "sphere": {      // Fibonacci sphere — even coverage without the polar clustering that      // naive lat/long sampling produces.      const phi = Math.acos(1 - 2 * (i + 0.5) / total);      const theta = Math.PI * (1 + Math.sqrt(5)) * i;      return [        Math.sin(phi) * Math.cos(theta),        Math.sin(phi) * Math.sin(theta),        Math.cos(phi),      ] as const;    }    case "torus": {      const u = t * Math.PI * 2 * 7;      const v = t * Math.PI * 2 * 23;      const r = 0.38;      return [        (1 + r * Math.cos(v)) * Math.cos(u) * 0.8,        (1 + r * Math.cos(v)) * Math.sin(u) * 0.8,        r * Math.sin(v),      ] as const;    }    case "grid": {      const side = Math.ceil(Math.sqrt(total));      const x = (i % side) / (side - 1) - 0.5;      const y = Math.floor(i / side) / (side - 1) - 0.5;      return [x * 2, y * 2, 0] as const;    }    default:      return [random() * 2 - 1, random() * 2 - 1, random() * 2 - 1] as const;  }} export interface ParticleMorphProps {  count?: number;  from?: Formation;  to?: Formation;  colorNear?: string;  colorFar?: string;  /** Point size in pixels at unit depth. */  size?: number;  /** Seconds for a full there-and-back cycle. 0 holds the `from` shape. */  cycle?: number;  /** Pointer push strength. */  repel?: number;  still?: boolean;  className?: string;} export function ParticleMorph({  count = 9000,  from = "sphere",  to = "torus",  colorNear = "#c3f53c",  colorFar = "#1a3d2e",  size = 90,  cycle = 8,  repel = 1,  still = false,  className = "",}: ParticleMorphProps) {  const canvasRef = useRef<HTMLCanvasElement>(null);   useEffect(() => {    const canvas = canvasRef.current;    if (!canvas) return;    const gl = canvas.getContext("webgl", { alpha: false, antialias: true });    if (!gl) return;     const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);    const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);    if (!vs || !fs) return;    const program = gl.createProgram();    if (!program) return;    gl.attachShader(program, vs);    gl.attachShader(program, fs);    gl.linkProgram(program);    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;    gl.useProgram(program);     const total = Math.max(64, Math.round(count));    const random = rng(11);    const fromData = new Float32Array(total * 3);    const toData = new Float32Array(total * 3);    const seeds = new Float32Array(total);     for (let i = 0; i < total; i += 1) {      const a = shape(from, i, total, random);      const b = shape(to, i, total, random);      fromData.set(a, i * 3);      toData.set(b, i * 3);      seeds[i] = random();    }     const attach = (name: string, data: Float32Array, componentsPer: number) => {      const buffer = gl.createBuffer();      gl.bindBuffer(gl.ARRAY_BUFFER, buffer);      gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);      const loc = gl.getAttribLocation(program, name);      gl.enableVertexAttribArray(loc);      gl.vertexAttribPointer(loc, componentsPer, gl.FLOAT, false, 0, 0);      return buffer;    };     const buffers = [      attach("aFrom", fromData, 3),      attach("aTo", toData, 3),      attach("aSeed", seeds, 1),    ];     const uniform = (n: string) => gl.getUniformLocation(program, n);    const uMix = uniform("uMix");    const uTime = uniform("uTime");    const uPointer = uniform("uPointer");    const uProjection = uniform("uProjection");    gl.uniform1f(uniform("uSize"), size);    gl.uniform1f(uniform("uRepel"), repel);    gl.uniform3fv(uniform("uColorNear"), toRgb(colorNear));    gl.uniform3fv(uniform("uColorFar"), toRgb(colorFar));     // Additive blending: overlapping points accumulate rather than occluding,    // which is what gives a cloud its density falloff.    gl.enable(gl.BLEND);    gl.blendFunc(gl.SRC_ALPHA, gl.ONE);     const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);     let uploadedWidth = 0;    let uploadedHeight = 0;     const resize = () => {      const dpr = Math.min(devicePixelRatio || 1, 2);      const w = Math.floor(canvas.clientWidth * dpr);      const h = Math.floor(canvas.clientHeight * dpr);      if (!w || !h) return;      if (w === uploadedWidth && h === uploadedHeight) return;      uploadedWidth = w;      uploadedHeight = h;      canvas.width = w;      canvas.height = h;      gl.viewport(0, 0, w, h);       // Minimal perspective matrix — a full matrix library would be more code      // than the six numbers actually used here.      const aspect = w / h;      const fov = 1.2;      const near = 0.1;      const far = 10;      const f = 1 / Math.tan(fov / 2);      gl.uniformMatrix4fv(uProjection, false, new Float32Array([        f / aspect, 0, 0, 0,        0, f, 0, 0,        0, 0, (far + near) / (near - far), -1,        0, 0, (2 * far * near) / (near - far), 0,      ]));    };     /* Drifts itself on a coarse pointer, so the cloud still parts and reforms       on a phone instead of sitting inert. */    const pointer = createPointerSource(canvas);     const start = performance.now();     const draw = (now: number) => {      resize();      const elapsed = (now - start) / 1000;      // Ping-pong through the cycle, so it morphs there and back forever      // rather than snapping home.      const mix = cycle > 0 ? (Math.sin((elapsed / cycle) * Math.PI * 2) + 1) / 2 : 0;       gl.uniform1f(uMix, mix);      gl.uniform1f(uTime, elapsed);      const local = pointer.read(elapsed);      // Element space (0-1, Y up) to the clip-ish space the shader works in.      gl.uniform2f(uPointer, local.x * 2 - 1, local.y * 2 - 1);       gl.clearColor(0.039, 0.043, 0.055, 1);      gl.clear(gl.COLOR_BUFFER_BIT);      gl.drawArrays(gl.POINTS, 0, total);    };     let frame = 0;    const loop = (now: number) => {      draw(now);      frame = requestAnimationFrame(loop);    };     // One frame synchronously, so there is never a blank panel waiting on rAF.    draw(reduced ? start + 2000 : start);    if (!reduced) frame = requestAnimationFrame(loop);     const observer = new ResizeObserver(() => {      resize();      if (reduced) draw(start + 2000);    });    observer.observe(canvas);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();      pointer.dispose();      gl.deleteProgram(program);      gl.deleteShader(vs);      gl.deleteShader(fs);      buffers.forEach((b) => gl.deleteBuffer(b));      // Not losing the context — see MeshGradient. It cannot be reacquired.    };  }, [count, from, to, colorNear, colorFar, size, cycle, repel, still]);   return <canvas ref={canvasRef} aria-hidden className={`block h-full w-full ${className}`} />;} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+4.1KB gzipped · no new dependencies

Runs on

GPU

Work happens in a shader. The main thread only issues draw calls, so JavaScript stays free, but it costs GPU time and battery on mobile.

Reduced motion

Renders one composed frame mid-morph and never schedules another. A still cloud, not an empty canvas.

Accessibility
  • Canvas is aria-hidden; decorative.
  • No focusable elements and no tab stops.
  • Pointer repulsion simply never triggers on touch.
Watch out
  • Attribute buffers are count x 7 floats, uploaded once. 30,000 particles is about 840KB of GPU memory — fine on desktop, worth halving on mobile.
  • Additive blending means overlapping points brighten. On a light background the cloud will wash out; it is built for dark grounds.
  • Needs WebGL. Renders nothing without it, so set a background colour behind it.