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.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { ParticleMorph } from "@/components/particlemorph"; <ParticleMorph />Customise
Uploaded once at mount, so this costs memory rather than per-frame time.
0 holds the first formation.
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.