텍스트 파티클

Text particles

2D 캔버스에 글자를 그린 뒤 픽셀을 샘플링해서, 그 모양대로 입자들이 모였다 흩어지는 걸 반복하는 기법.

다른 이름: Canvas text samplingImageData to pointsText dissolve particles
···
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05060c);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 5.2);
camera.lookAt(0, 0, 0);

function sampleText(text) {
  // ctx.letterSpacing 지원 여부에 기대지 않고, 글자마다 직접 x를 옮겨 그려서
  // 확실한 틈을 만든다 — 글자 사이가 붙으면 파티클이 뭉쳐 한 덩어리로만 보인다.
  const H = 110;
  const GAP = 18;
  const probe = document.createElement('canvas').getContext('2d');
  probe.font = 'bold 46px sans-serif';
  let totalW = 0;
  const widths = [];
  for (const ch of text) {
    const w = probe.measureText(ch).width;
    widths.push(w);
    totalW += w + GAP;
  }
  totalW -= GAP;
  const W = Math.ceil(totalW) + 40;
  const c = document.createElement('canvas');
  c.width = W; c.height = H;
  const ctx = c.getContext('2d');
  // 배경을 fillRect로 채우면 불투명 검정도 alpha=255가 돼서 "글자만" 걸러낼 수 없다.
  // 캔버스를 투명한 채로 두고 흰 글자만 그려야 alpha가 진짜 글자 모양이 된다.
  ctx.fillStyle = '#fff';
  ctx.font = 'bold 46px sans-serif';
  ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
  let cx = (W - totalW) / 2;
  for (let i = 0; i < text.length; i++) {
    ctx.fillText(text[i], cx, H / 2 + 4);
    cx += widths[i] + GAP;
  }
  const data = ctx.getImageData(0, 0, W, H).data;
  const pts = [];
  const STEP = 2;
  for (let y = 0; y < H; y += STEP) {
    for (let x = 0; x < W; x += STEP) {
      const idx = (y * W + x) * 4;
      if (data[idx + 3] > 128) pts.push([(x - W / 2) * 0.02, -(y - H / 2) * 0.02]);
    }
  }
  return pts;
}

const targets = sampleText('WORD');
const COUNT = Math.max(1, targets.length);
const pos = new Float32Array(COUNT * 3);
const target = new Float32Array(COUNT * 3);
const scatter = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
  const pt = targets[i] || [0, 0];
  target[i * 3] = pt[0];
  target[i * 3 + 1] = pt[1];
  target[i * 3 + 2] = 0;
  const r = 2.4 + Math.random() * 1.6;
  const th = Math.random() * Math.PI * 2;
  const ph = Math.acos(2 * Math.random() - 1);
  scatter[i * 3] = r * Math.sin(ph) * Math.cos(th);
  scatter[i * 3 + 1] = r * Math.sin(ph) * Math.sin(th);
  scatter[i * 3 + 2] = r * Math.cos(ph);
  pos[i * 3] = scatter[i * 3]; pos[i * 3 + 1] = scatter[i * 3 + 1]; pos[i * 3 + 2] = scatter[i * 3 + 2];
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({ size: 0.05, color: 0x6fe8ff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false });
const points = new THREE.Points(geo, mat);
scene.add(points);

function resize() { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); }
addEventListener('resize', resize); resize();
renderer.setAnimationLoop((t) => {
  const time = t * 0.001;
  // 사인파를 그대로 쓰면 주기의 절반을 "어중간하게 섞인" 상태로 보내 글자가 흐릿해 보인다.
  // 지수를 0.3으로 낮춰 1(글자 완성) 쪽에 오래 머물고 0(흩어짐) 쪽만 짧게 스치게 한다.
  const raw = (Math.sin(time * 0.5) + 1) / 2;
  const phase = Math.pow(raw, 0.3);
  const arr = geo.attributes.position.array;
  for (let i = 0; i < COUNT; i++) {
    const ix = i * 3;
    arr[ix] = THREE.MathUtils.lerp(scatter[ix], target[ix], phase);
    arr[ix + 1] = THREE.MathUtils.lerp(scatter[ix + 1], target[ix + 1], phase);
    arr[ix + 2] = THREE.MathUtils.lerp(scatter[ix + 2], target[ix + 2], phase);
  }
  geo.attributes.position.needsUpdate = true;
  points.rotation.y = Math.sin(time * 0.15) * 0.25;
  renderer.render(scene, camera);
});

3D 폰트 파일 없이 글자 모양의 파티클을 만드는 가장 쉬운 방법은 2D 캔버스를 "모양 스캐너"로 쓰는 것입니다. 오프스크린 &lt;canvas&gt;에 ctx.fillText()로 글자를 그린 뒤 ctx.getImageData()로 픽셀 배열을 읽어, 알파(불투명도)가 높은 픽셀의 x, y 좌표만 골라냅니다 — 그 좌표들이 곧 글자 모양을 이루는 점들의 목록이 됩니다.

각 파티클은 두 좌표를 갖습니다: scatter(구 표면에 무작위로 흩어진 시작 위치)와 target(텍스트 픽셀에서 뽑은 도착 위치). 매 프레임 phase(0~1로 사인파를 타고 오가는 값)만큼 두 좌표를 THREE.MathUtils.lerp로 섞어 현재 위치를 계산합니다 — phase가 1에 가까우면 텍스트로 뭉치고, 0에 가까우면 흩어집니다. 이건 GPU 셰이더가 아니라 CPU에서 매 프레임 좌표를 보간해 BufferAttribute를 갱신하는 방식이라, 파티클 수가 수천 개 이하일 때 적합합니다(더 많다면 gpgpu처럼 텍스처 기반으로 옮기는 게 낫습니다).

캔버스 해상도와 샘플링 간격(STEP)이 파티클 밀도를 정합니다 — 간격을 촘촘히 하면 글자가 선명해지지만 파티클 수가 늘어 느려지고, 성기게 하면 빠르지만 글자가 듬성듬성해 보입니다.

언제 쓰나

로고/워드마크 인트로, 로딩 화면의 브랜드 텍스트, "흩어졌다 모이는" 타이틀 연출에.