Particle Morph Between Shapes

Thirty thousand additive points morph between a sphere, a torus knot, a cube and a heart; every particle starts on its own random schedule and takes a swirling detour, so each change of shape reads as a swarm rather than a slide.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1547 words of exact values — rewritten live as you tune the Playground.

prompt.md · Particle Morph Between Shapes
### EFFECT: Particle Morph Between Shapes
**What it does (visual):** A full-viewport dark canvas holds a cloud of 30,000 tiny glowing points arranged as a sphere, slowly turning (≈0.12 rad/s) and tilted slightly toward the viewer. The points (base size 3px, scaled by depth) are coloured by position — the accent `#d4ff3a` at the lower left shading to white at the upper right — and rendered additively, so dense regions bloom brighter. 0.6s after load the sphere dissolves into a torus knot: particles do not slide in lockstep; each one departs at its own moment (spread over the first 0.35 of the morph, as a fraction), swings out on a sideways arc that peaks mid-flight, brightens slightly while travelling, and settles into its new place. The whole change takes 1.6s with the `power2.inOut` ease and reads like a swarm reorganising. 3s later it morphs again — torus knot → cube (a hollow cube surface) → heart (a puffy 3D heart) → sphere — looping forever. A small label in the top right ("02 · Torus knot") slides out and back in with each change, and a pill button "Next shape →" (or a click anywhere on the canvas) triggers the next morph immediately. Moving the pointer gently parallaxes the camera (up to ±0.45 units horizontally), lagging behind the cursor. At rest the shape breathes almost imperceptibly so it never looks frozen.

**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), `THREE.Points` + custom `ShaderMaterial`, `TorusKnotGeometry` used only as a vertex source.
- GSAP 3.13+ core to tween the `uProgress` uniform and schedule morphs (`gsap.delayedCall`).
- npm: `npm i three gsap`. CDN: importmap for three + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.

**HTML structure:**
```html
<section class="pm">
  <canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
  <p class="pm__eyebrow">WebGL · 30 000 points</p>
  <div class="pm__hud">
    <p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
    <button class="pm__next" type="button">Next shape →</button>
  </div>
  <p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>
```

**CSS requirements:**
- `.pm`: `position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); cursor: crosshair;`
- `.pm__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block;`
- `.pm__eyebrow`, `.pm__index`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted)`; eyebrow at `top: 2rem; left: 6vw`.
- `.pm__hud`: `position: absolute; top: 2rem; right: 6vw; display: flex; flex-direction: column; align-items: flex-end; gap: .9rem;`
- `.pm__name`: display serif, `font-weight: 300; font-size: clamp(1.75rem, 3vw, 2.75rem); letter-spacing: -0.04em; line-height: 1; min-width: 5ch; text-align: right;`
- `.pm__next`: outlined pill (`border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .6em 1.2em; font-size: .8rem`), hover inverts to `var(--text)` background; `[disabled] { opacity: .5; cursor: progress }`.
- `.pm__caption`: sans `clamp(.85rem, 1vw, .95rem)`, muted, `left/right: 6vw; bottom: 2rem; max-width: 44ch`.
- `.pm__fallback` (reduced motion / no WebGL): a centred `min(56vw, 56vh)` circle of concentric 1px lime rings (`repeating-radial-gradient(... 0 1px, transparent 1px 7px)`) over a soft highlight, masked to a soft disc.
- `@media (prefers-reduced-motion: reduce) { .pm__canvas, .pm__next { display: none } }`.

**Animation spec (exact values):**
Constants: `COUNT = 30000`, `MORPH_DURATION = 1.6`, `MORPH_INTERVAL = 3`, `SPREAD = 0.35`, `EASE = 'power2.inOut'`, `POINT_SIZE = 3`, `PARALLAX = 0.05`, accent colour `#d4ff3a`.
1. **Shapes** (each a `Float32Array(COUNT * 3)`, generated with a seeded PRNG so reloads are identical):
   - Sphere: Fibonacci sphere, radius 1.15 (`y = 1 − 2i/(N−1)`, `r = √(1 − y²)`, `θ = i · 2.399963`).
   - Torus knot: `new THREE.TorusKnotGeometry(0.72, 0.24, 420, 72, 2, 3)`; for each particle pick a random vertex and jitter ±0.0075 per axis; dispose the geometry.
   - Cube: random face (6), random `(u, v) ∈ [−0.88, 0.88]²`, third axis fixed at ±0.88 — a hollow surface.
   - Heart: `t ∈ [0, 2π)`, `k = √random` (fills the outline), `x = 16 sin³t`, `y = 13 cos t − 5 cos 2t − 2 cos 3t − cos 4t`; position `((x·k)/15, (y·k)/15 + 0.12, (2·random − 1) · √(1 − k²) · 0.42)`.
2. **Geometry**: `aFrom = BufferAttribute(shapes[0])`, `aTo = BufferAttribute(shapes[1])`, `aRand = random ∈ [0, 1)`; also `setAttribute('position', aFrom)` and `frustumCulled = false`. `points.rotation.x = 0.18`.
3. **Material**: `ShaderMaterial({ transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending })` (NormalBlending + opacity 0.75 on the light theme). Uniforms: `uProgress 0`, `uTime`, `uSize 3` (× 0.87 on mobile), `uDpr`, `uSpread 0.35`, `uColorA = #d4ff3a`, `uColorB = --text`, `uOpacity 0.55`.
   Vertex shader:
   ```glsl
   attribute vec3 aFrom; attribute vec3 aTo; attribute float aRand;
   uniform float uProgress, uTime, uSize, uDpr, uSpread;
   varying vec3 vPos; varying float vArc;
   void main() {
     float p = clamp((uProgress - aRand * uSpread) / (1.0 - uSpread), 0.0, 1.0);
     p = p * p * (3.0 - 2.0 * p);
     vec3 pos = mix(aFrom, aTo, p);
     float arc = sin(p * 3.14159265);
     float h1 = fract(aRand * 7.13), h2 = fract(aRand * 13.7), h3 = fract(aRand * 29.3);
     vec3 swirl = vec3(sin(h1 * 6.2831 + uTime * 0.6), cos(h2 * 6.2831 - uTime * 0.4), sin(h3 * 6.2831)) * (0.18 + 0.32 * h2);
     pos += swirl * arc;
     pos += normalize(pos + 1e-4) * sin(uTime * 0.9 + aRand * 6.2831) * 0.012;
     vec4 mv = modelViewMatrix * vec4(pos, 1.0);
     gl_PointSize = uSize * uDpr * (4.0 / -mv.z) * (0.7 + 0.6 * aRand) * (1.0 + arc * 0.8);
     gl_Position = projectionMatrix * mv;
     vPos = pos; vArc = arc;
   }
   ```
   Fragment shader:
   ```glsl
   uniform vec3 uColorA, uColorB; uniform float uOpacity;
   varying vec3 vPos; varying float vArc;
   void main() {
     vec2 c = gl_PointCoord - 0.5; float d = length(c);
     if (d > 0.5) discard;
     float a = smoothstep(0.5, 0.12, d);
     float t = smoothstep(-1.1, 1.1, vPos.y * 0.85 + vPos.x * 0.35);
     vec3 col = mix(uColorA, uColorB, t);
     col = mix(col, uColorB, vArc * 0.35);
     gl_FragColor = vec4(col, a * uOpacity);
   }
   ```
4. **Morph** (inside `gsap.context`): `gsap.to(uniforms.uProgress, { value: 1, duration: 1.6, ease: 'power2.inOut', onStart, onComplete })`.
   - `onStart`: label out `gsap.fromTo('.pm__name', { yPercent: 0, opacity: 1 }, { yPercent: -60, opacity: 0, duration: 0.35, ease: 'power2.in' })`, then set the new index/name and label in `{ yPercent: 60 → 0, opacity: 0 → 1, duration: 0.5, ease: 'power3.out' }`.
   - `onComplete`: `aFrom.array.set(aTo.array); aTo.array.set(shapes[(index + 1) % 4]); aFrom.needsUpdate = aTo.needsUpdate = true; uProgress.value = 0;` re-enable the button; `timer = gsap.delayedCall(3, morph)`.
   - First morph: `gsap.delayedCall(0.6, morph)`. `morph()` is a no-op while one is in flight.
5. **Per frame** (`dt = min(getDelta(), 0.05)`): `uTime = elapsed`; `points.rotation.y += dt · 0.12`; `camTarget = (pointer.x · 0.45, pointer.y · 0.3, 4.4)`; `camera.position.lerp(camTarget, 0.05)`; `camera.lookAt(0, 0, 0)`; render. Camera: `PerspectiveCamera(38, aspect, 0.1, 30)` at `z = 4.4` (5.6 when `aspect < 0.8`).

**Interaction:** Clicking "Next shape →" or anywhere on the canvas starts the next morph immediately (ignored while one is running; the button is `disabled` during a morph). `pointermove` over the section sets the parallax target in NDC; `pointerleave` recentres. Hover inverts the button over 0.25s.

**Responsive behavior:** Under 768px (`matchMedia('(max-width: 767px)')`): DPR cap 1.5, `uSize 2.6`, the camera backs off to `z = 5.6` on portrait aspect ratios, the HUD moves to the bottom as a row (`bottom: 4.5rem; left/right: 5vw; flex-direction: row; justify-content: space-between`) and the caption is hidden. Touch taps trigger the morph through the canvas click handler.

**Accessibility:** With `prefers-reduced-motion: reduce` the canvas and button are hidden and a static `.pm__fallback` disc of concentric lime rings is appended — no WebGL, no motion; the label still reads "01 Sphere". The same fallback is used if `WebGLRenderer` throws. The canvas has an `aria-label`; the button is a real `<button>` with a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).

**Performance rules:** All 30k particles are moved in the vertex shader; JS touches the buffers only once per morph (two `Float32Array.set` calls). `antialias: false` (point sprites don't need MSAA), `depthWrite/depthTest: false`. DPR capped at 2 (1.5 mobile, halved in preview). The rAF loop stops on `ml:pause` and when the tab is hidden, resumes on `ml:resume`; GSAP's global timeline pausing also freezes the morph and its `delayedCall`. On cleanup: cancel rAF, kill the timer, `ctx.revert()`, disconnect the theme observer, remove listeners, dispose geometry, material and renderer.

**Customization knobs:**
- `COUNT` (30000) — 15k is plenty on mid-range phones.
- `MORPH_DURATION` (1.6) / `MORPH_INTERVAL` (3) / ease (`power2.inOut`).
- `SPREAD` (0.35) — 0 makes all particles move together (a slide); 0.6 makes a long trailing swarm.
- Swirl amplitude (`0.18 + 0.32 · h2`) and `uSize` (3).
- `uColorA` (#d4ff3a) / `uColorB` (`--text`) — the position gradient; `uOpacity` (0.55) for glow density.
- Add shapes by pushing another `{ name, build }` that returns `Float32Array(COUNT * 3)`.

**Acceptance criteria:**
- [ ] 30,000 soft round points render additively with a `#d4ff3a` → white gradient by position; no square sprites.
- [ ] Morphs take 1.6s, particles start at different times (visible leading/trailing edge) and arc sideways mid-flight.
- [ ] Sequence is sphere → torus knot → cube → heart → sphere, every 3s, with the label changing mid-morph.
- [ ] Clicking Next/canvas morphs immediately and is ignored while a morph is in flight.
- [ ] Camera parallax lags the pointer; shape rotates slowly; at rest it breathes slightly.
- [ ] Reduced motion shows the static fallback; no console errors; everything is disposed on unmount.

Source

Full source code

<section class="pm">
  <canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
  <p class="pm__eyebrow">WebGL · 30 000 points</p>
  <div class="pm__hud">
    <p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
    <button class="pm__next" type="button">Next shape →</button>
  </div>
  <p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>