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.