LAB_06 // PARTICLE_MORPH

PARTI
CLES

OGL // One Draw Call // 120K Points

A single buffer of particles forming words, a portrait, and whatever you type. There is no simulation: every particle carries two targets and a random seed, and the whole flight — stagger, easing, the sideways arc — is computed statelessly in the vertex shader. The CPU only swaps target buffers between shapes.

01

THE_SWARM

MOVE YOUR CURSOR THROUGH ITTAP THE SWARM

SWARM CONTROLS — everything is live, COUNT rebuilds the buffer

YOUR CONFIG [+]
<LabParticles
  :count="120000" :size="2.2" :scatter="0.4"
  :repulse-radius="0.32" :repulse-strength="0.16"
  :morph-seconds="2.6" :hold-seconds="3"
  accent="#FF3B00" :accent-share="0.07"
/>
THE WHOLE COMPONENT [+]
<template>
  <div ref="wrapRef" class="particles-wrap"></div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
// @ts-ignore — ogl has no bundled types
import { Renderer, Program, Mesh, Geometry } from "ogl"

// GPU particle morph: one draw call, all choreography in the vertex shader.
// Each particle carries two targets (A and B) and a random seed; the shader
// staggers, eases and arcs the flight between them, so there is no per-frame
// simulation state at all — the CPU only swaps target buffers between morphs.
const props = withDefaults(defineProps<{
  count?: number
  size?: number
  scatter?: number
  repulseRadius?: number
  repulseStrength?: number
  morphSeconds?: number
  holdSeconds?: number
  ink?: string
  accent?: string
  accentShare?: number
  photo?: string
  words?: string[]     // "@PHOTO" becomes the sampled portrait
}>(), {
  count: 120000,
  size: 2.2,
  scatter: 0.4,
  repulseRadius: 0.32,
  repulseStrength: 0.16,
  morphSeconds: 2.6,
  holdSeconds: 3.0,
  ink: "#f0ece3",
  accent: "#FF3B00",
  accentShare: 0.07,
  photo: "/image_me_512.jpg",
  words: () => ["THE LAB", "@PHOTO", "SAMUEL RATZEL", "SRATZEL.DEV"],
})

const wrapRef = ref<HTMLDivElement | null>(null)

const hexToRgb = (hex: string): [number, number, number] => {
  const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  return m
    ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255]
    : [1, 1, 1]
}

const vertex = `
attribute vec2 position;   // target A
attribute vec2 aB;         // target B
attribute vec3 aColA;      // photo colors, only meaningful while uPhotoA/B = 1
attribute vec3 aColB;
attribute vec4 aSeed;
uniform float uMorph;
uniform float uTime;
uniform float uAspect;
uniform vec2 uMouse;
uniform float uRepR;
uniform float uRepS;
uniform float uScatter;
uniform float uSize;
uniform float uDpr;
uniform vec3 uInk;
uniform vec3 uAccent;
uniform float uAccentShare;
uniform float uPhotoA;
uniform float uPhotoB;
varying vec3 vColor;
varying float vFly;

void main() {
  // per-particle stagger: seeds start late, everyone lands by uMorph = 1
  float t = clamp(uMorph * 1.35 - aSeed.x * 0.35, 0.0, 1.0);
  float e = t * t * (3.0 - 2.0 * t);
  vec2 p = mix(position, aB, e);

  // arc sideways during flight, strongest mid-transit
  vec2 dir = aB - position;
  float fly = sin(3.14159 * e);
  p += vec2(-dir.y, dir.x) * (aSeed.y - 0.5) * uScatter * fly;

  // idle breathing so the formed shape never freezes
  p += 0.008 * (0.5 + aSeed.y) * vec2(
    sin(uTime * 1.4 + aSeed.z * 43.0),
    cos(uTime * 1.1 + aSeed.w * 39.0));

  // cursor repulsion
  vec2 d = p - uMouse;
  float dist = length(d);
  p += (d / max(dist, 1e-4)) * uRepS * smoothstep(uRepR, 0.0, dist);

  // text shapes use the ink/accent scheme, the portrait its real pixel colors
  vec3 scheme = aSeed.w < uAccentShare ? uAccent : uInk;
  vColor = mix(mix(scheme, aColA, uPhotoA), mix(scheme, aColB, uPhotoB), e);
  vFly = fly;
  gl_Position = vec4(p.x / uAspect, p.y, 0.0, 1.0);
  gl_PointSize = uSize * uDpr * (0.7 + aSeed.z * 0.6 + fly * 1.6);
}`

const fragment = `precision highp float;
varying vec3 vColor;
varying float vFly;

void main() {
  vec2 c = gl_PointCoord - 0.5;
  float r = length(c);
  if (r > 0.5) discard;
  float a = smoothstep(0.5, 0.26, r);
  gl_FragColor = vec4(vColor, a * (0.8 + vFly * 0.2));
}`

/* ── target sampling: draw on a canvas, pick lit pixels ── */
// world units: y in [-1, 1], x in [-aspect, aspect]

let aspect = 1.6

type Target = { pos: Float32Array; col: Float32Array | null }

function textTarget(word: string): Target {
  const H = 480
  const W = Math.round(H * aspect)
  const c = document.createElement("canvas")
  c.width = W; c.height = H
  const x = c.getContext("2d", { willReadFrequently: true })!
  let fs = H * 0.62
  x.fillStyle = "#fff"
  do { x.font = `400 ${Math.round(fs)}px 'Bebas Neue', sans-serif`; fs -= 8 }
  while (fs > 40 && x.measureText(word).width > W * 0.86)
  x.textAlign = "center"; x.textBaseline = "middle"
  x.fillText(word, W / 2, H * 0.54)

  const img = x.getImageData(0, 0, W, H).data
  const pos = new Float32Array(props.count * 2)
  let filled = 0, guard = 0
  while (filled < props.count && guard < props.count * 400) {
    guard++
    const px = (Math.random() * W) | 0
    const py = (Math.random() * H) | 0
    if (img[(py * W + px) * 4 + 3] < 128) continue
    pos[filled * 2] = (px + Math.random() - W / 2) / (H / 2)
    pos[filled * 2 + 1] = -((py + Math.random() - H / 2) / (H / 2))
    filled++
  }
  for (let i = filled; i < props.count; i++) {
    const j = ((Math.random() * Math.max(filled, 1)) | 0) * 2
    pos[i * 2] = pos[j]; pos[i * 2 + 1] = pos[j + 1]
  }
  return { pos, col: null }
}

function photoTarget(img: HTMLImageElement): Target {
  // original aspect ratio, background removed: the two top corners define the
  // backdrop color, and only pixels that clearly differ from it get particles —
  // so the silhouette is the person, not a rectangle of noise
  const H = 480
  const W = Math.round(H * aspect)
  const ih = H * 0.86
  const iw = ih * (img.naturalWidth / img.naturalHeight)
  const ix = (W - iw) / 2, iy = (H - ih) / 2
  const c = document.createElement("canvas")
  c.width = W; c.height = H
  const x = c.getContext("2d", { willReadFrequently: true })!
  x.drawImage(img, ix, iy, iw, ih)

  const data = x.getImageData(0, 0, W, H).data
  const at = (px: number, py: number) => (py * W + px) * 4
  // the backdrop has a vignette, so one reference color is not enough —
  // take samples along the border and match against the nearest one
  const refs: number[][] = []
  for (const [fx, fy] of [
    [0.02, 0.02], [0.5, 0.02], [0.98, 0.02],
    [0.02, 0.3], [0.98, 0.3], [0.02, 0.6], [0.98, 0.6],
    [0.02, 0.98], [0.98, 0.98],
  ]) {
    const i = at((ix + fx * iw) | 0, (iy + fy * ih) | 0)
    refs.push([data[i], data[i + 1], data[i + 2]])
  }
  const bgDist = (i: number) => {
    let min = 1e9
    for (const r of refs) {
      const dr = data[i] - r[0], dg = data[i + 1] - r[1], db = data[i + 2] - r[2]
      const d = dr * dr + dg * dg + db * db
      if (d < min) min = d
    }
    return Math.sqrt(min)
  }

  const pos = new Float32Array(props.count * 2)
  const col = new Float32Array(props.count * 3)
  let filled = 0, guard = 0
  while (filled < props.count && guard < props.count * 400) {
    guard++
    const px = (Math.random() * W) | 0
    const py = (Math.random() * H) | 0
    const i = at(px, py)
    if (data[i + 3] < 128) continue
    const dist = bgDist(i)
    // soft edge: clearly-background pixels never pass, borderline ones rarely
    const t = Math.min(Math.max((dist - 16) / 28, 0), 1)
    if (Math.random() > t * t * (3 - 2 * t)) continue
    pos[filled * 2] = (px + Math.random() - W / 2) / (H / 2)
    pos[filled * 2 + 1] = -((py + Math.random() - H / 2) / (H / 2))
    col[filled * 3] = data[i] / 255
    col[filled * 3 + 1] = data[i + 1] / 255
    col[filled * 3 + 2] = data[i + 2] / 255
    filled++
  }
  for (let i = filled; i < props.count; i++) {
    const j = (Math.random() * Math.max(filled, 1)) | 0
    pos[i * 2] = pos[j * 2]; pos[i * 2 + 1] = pos[j * 2 + 1]
    col[i * 3] = col[j * 3]; col[i * 3 + 1] = col[j * 3 + 1]; col[i * 3 + 2] = col[j * 3 + 2]
  }
  return { pos, col }
}

function scatterTarget(): Target {
  const pos = new Float32Array(props.count * 2)
  for (let i = 0; i < props.count; i++) {
    pos[i * 2] = (Math.random() * 2 - 1) * aspect * 1.1
    pos[i * 2 + 1] = (Math.random() * 2 - 1) * 1.1
  }
  return { pos, col: null }
}

/* ── scene ── */
let renderer: any
let uniforms: any
let attrs: any
let raf = 0
let cleanup: (() => void) | undefined
let io: IntersectionObserver | undefined
let running = false
let reduced = false

let photoImg: HTMLImageElement | null = null
let targetIdx = -1
let pendingWord: string | null = null
let phase: "morph" | "hold" = "morph"
let tPhase = 0
let last = 0

function nextTargetData(): Target {
  if (pendingWord !== null) {
    const w = pendingWord
    pendingWord = null
    return textTarget(w)
  }
  const list = props.words
  for (let step = 1; step <= list.length; step++) {
    const i = (targetIdx + step) % list.length
    if (list[i] === "@PHOTO") {
      if (photoImg) { targetIdx = i; return photoTarget(photoImg) }
      continue // photo not loaded yet, skip it this round
    }
    targetIdx = i
    return textTarget(list[i])
  }
  return scatterTarget()
}

function advance() {
  // B becomes the new A, the next shape lands in B
  attrs.position.data.set(attrs.aB.data)
  attrs.position.needsUpdate = true
  attrs.aColA.data.set(attrs.aColB.data)
  attrs.aColA.needsUpdate = true
  uniforms.uPhotoA.value = uniforms.uPhotoB.value
  const t = nextTargetData()
  attrs.aB.data.set(t.pos)
  attrs.aB.needsUpdate = true
  if (t.col) {
    attrs.aColB.data.set(t.col)
    attrs.aColB.needsUpdate = true
  }
  uniforms.uPhotoB.value = t.col ? 1 : 0
  uniforms.uMorph.value = 0
  phase = "morph"
  tPhase = 0
}

function build() {
  const el = wrapRef.value
  if (!el || renderer) return

  renderer = new Renderer({ dpr: Math.min(window.devicePixelRatio, 2), alpha: true })
  const gl = renderer.gl
  gl.canvas.style.width = "100%"
  gl.canvas.style.height = "100%"
  el.appendChild(gl.canvas)

  aspect = Math.max(el.clientWidth / el.clientHeight, 0.5)

  const N = props.count
  const seeds = new Float32Array(N * 4)
  for (let i = 0; i < seeds.length; i++) seeds[i] = Math.random()

  targetIdx = -1
  const first = nextTargetData()
  const geometry = new Geometry(gl, {
    position: { size: 2, data: scatterTarget().pos },
    aB: { size: 2, data: first.pos },
    aColA: { size: 3, data: new Float32Array(N * 3) },
    aColB: { size: 3, data: first.col ?? new Float32Array(N * 3) },
    aSeed: { size: 4, data: seeds },
  })
  attrs = geometry.attributes

  uniforms = {
    uMorph: { value: reduced ? 1 : 0 },
    uPhotoA: { value: 0 },
    uPhotoB: { value: first.col ? 1 : 0 },
    uTime: { value: 0 },
    uAspect: { value: aspect },
    uMouse: { value: [999, 999] },
    uRepR: { value: props.repulseRadius },
    uRepS: { value: props.repulseStrength },
    uScatter: { value: props.scatter },
    uSize: { value: props.size },
    uDpr: { value: renderer.dpr },
    uInk: { value: hexToRgb(props.ink) },
    uAccent: { value: hexToRgb(props.accent) },
    uAccentShare: { value: props.accentShare },
  }

  const program = new Program(gl, { vertex, fragment, uniforms, transparent: true, depthTest: false })
  const mesh = new Mesh(gl, { mode: gl.POINTS, geometry, program })

  const updateSize = () => {
    renderer.setSize(el.clientWidth, el.clientHeight)
    uniforms.uDpr.value = renderer.dpr
  }
  updateSize()

  // resample the current shapes when the stage aspect really changes
  let resizeTimer = 0
  const onResize = () => {
    updateSize()
    window.clearTimeout(resizeTimer)
    resizeTimer = window.setTimeout(() => {
      const a = Math.max(el.clientWidth / el.clientHeight, 0.5)
      if (Math.abs(a - aspect) < 0.05) return
      aspect = a
      uniforms.uAspect.value = a
      targetIdx-- // resample the shape we are on
      const t = nextTargetData()
      attrs.aB.data.set(t.pos)
      attrs.aB.needsUpdate = true
      if (t.col) { attrs.aColB.data.set(t.col); attrs.aColB.needsUpdate = true }
      uniforms.uPhotoB.value = t.col ? 1 : 0
    }, 250)
  }
  window.addEventListener("resize", onResize)

  // no hover on touch: a tap fires a repulsion pulse that decays on its own,
  // so the swarm blasts open under the finger and heals — no drag needed
  const coarse = window.matchMedia("(pointer: coarse)").matches
  let pulse = 0
  const toWorld = (e: PointerEvent) => {
    const r = gl.canvas.getBoundingClientRect()
    uniforms.uMouse.value = [
      ((e.clientX - r.left) / r.width * 2 - 1) * aspect,
      -((e.clientY - r.top) / r.height * 2 - 1),
    ]
    if (coarse) pulse = 1
  }
  const onLeave = () => { if (!coarse) uniforms.uMouse.value = [999, 999] }
  el.addEventListener("pointermove", toWorld)
  el.addEventListener("pointerdown", toWorld)
  el.addEventListener("pointerleave", onLeave)
  el.addEventListener("pointerup", onLeave)
  el.addEventListener("pointercancel", onLeave)

  phase = "morph"
  tPhase = 0
  last = performance.now()

  const loop = (now: number) => {
    const dt = Math.min((now - last) / 1000, 0.05)
    last = now
    uniforms.uTime.value = now * 0.001
    if (coarse) {
      // tap pulse: stronger than hover repulsion, gone in about a second
      uniforms.uRepS.value = props.repulseStrength * pulse * 3
      pulse = Math.max(0, pulse - dt / 1.1)
    }
    tPhase += dt
    if (phase === "morph") {
      uniforms.uMorph.value = Math.min(tPhase / props.morphSeconds, 1)
      if (tPhase >= props.morphSeconds) { phase = "hold"; tPhase = 0 }
    } else if (tPhase >= props.holdSeconds || pendingWord !== null) {
      advance()
    }
    renderer.render({ scene: mesh })
    raf = requestAnimationFrame(loop)
  }

  if (reduced) {
    // no choreography: render the first word, formed and still
    uniforms.uMorph.value = 1
    renderer.render({ scene: mesh })
  } else {
    raf = requestAnimationFrame(loop)
    running = true
  }

  cleanup = () => {
    cancelAnimationFrame(raf)
    running = false
    window.clearTimeout(resizeTimer)
    window.removeEventListener("resize", onResize)
    el.removeEventListener("pointermove", toWorld)
    el.removeEventListener("pointerdown", toWorld)
    el.removeEventListener("pointerleave", onLeave)
    el.removeEventListener("pointerup", onLeave)
    el.removeEventListener("pointercancel", onLeave)
    gl.getExtension("WEBGL_lose_context")?.loseContext()
    gl.canvas.remove()
    renderer = undefined
    uniforms = undefined
    attrs = undefined
  }
}

function rebuild() {
  cleanup?.()
  cleanup = undefined
  build()
}

/* uniforms update live, only a count change rebuilds the buffers */
watch(() => props.count, rebuild)
watch(props, () => {
  if (!uniforms) return
  uniforms.uRepR.value = props.repulseRadius
  uniforms.uRepS.value = props.repulseStrength
  uniforms.uScatter.value = props.scatter
  uniforms.uSize.value = props.size
  uniforms.uInk.value = hexToRgb(props.ink)
  uniforms.uAccent.value = hexToRgb(props.accent)
  uniforms.uAccentShare.value = props.accentShare
})

onMounted(async () => {
  reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches

  // the words are drawn in the site font — have it ready before sampling
  try { await document.fonts.load("400 200px 'Bebas Neue'") } catch { /* fallback is fine */ }

  const img = new Image()
  img.onload = () => { photoImg = img }
  img.src = props.photo

  io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) { if (running) { cleanup?.(); cleanup = undefined } return }
    if (!renderer) build()
  }, { threshold: 0.1 })
  if (wrapRef.value) io.observe(wrapRef.value)
})

onBeforeUnmount(() => {
  io?.disconnect()
  cleanup?.()
  cleanup = undefined
})

/* jump to the next shape / morph to any text.
   Mid-flight both just queue up — the loop advances as soon as the morph lands. */
function next() {
  if (attrs && phase === "hold") advance()
}
function morphTo(word: string) {
  const w = word.trim().toUpperCase()
  if (!w || !attrs) return
  pendingWord = w
  if (phase === "hold") advance()
}
defineExpose({ next, morphTo })
</script>

<style scoped>
.particles-wrap {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
  cursor: crosshair;
  /* vertical scroll passes through, horizontal swipes stir the swarm */
  touch-action: pan-y;
}
</style>

The shapes are sampled from a hidden 2D canvas: text set in this site's Bebas Neue, the portrait dark-pixel-weighted like a halftone print. Rejection sampling picks the particle homes, the shader does the rest.