# AI 꾸러미 — 2D 입자 글자 (글자 → 입자 → 다른 글자) — Particle text / text-to-particles
> 글자를 보이지 않는 캔버스에 그려 칠해진 픽셀 자리를 뽑고, 입자 수백 개가 스프링으로 그 자리에 모였다가 흩어져 다음 글자로 건너간다.  
> 견본: https://ai-techstudio.web.app/#t/i556

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 2D 입자 글자 (글자 → 입자 → 다른 글자) — Particle text / text-to-particles

#### 1. 목표
게임 시작 화면 제목 → 「START」에 쓸 2D 입자 글자를 만들어 줘 — 글자가 입자 수백 개로 이루어지고, 흩어졌다가 스프링으로 모여 다음 글자로 바뀐다. 분위기는 어두운 바탕에 무지개 빛 점 (더하기 섞기).

#### 2. 핵심 기술 용어
- **Particle text / text-to-particles** — 글자 모양을 점 구름으로 — 점이 글자를 이룸
- **getImageData pixel sampling** — 숨은 캔버스에 글자를 그리고 알파가 있는 픽셀 자리를 일정 간격으로 뽑기
- **Damped spring (stiffness · damping ratio)** — 목표로 당기는 힘 − 속도에 비례한 마찰 — 감쇠비 1 미만이면 살짝 출렁
- **Target reassignment (morph)** — 입자는 그대로 두고 목표 자리만 다음 글자 것으로 바꾸기

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표
- 숨은 캔버스에 fillText → getImageData 로 자리 뽑기. 입자는 Float32Array 여러 개(x · y · vx · vy · tx · ty)로, 그리기는 색 묶음별 fillRect. 입자가 수천 개를 넘으면 three 로.

#### 4. 조건
- 글자 자리는 화면 크기가 바뀔 때만 다시 뽑고 결과를 기억해 둘 것 (매 프레임 getImageData 금지)
- 입자 상태는 Float32Array 로 — 객체 배열 수천 개 만들지 않기
- dt 는 1/30 초로 막을 것 — 탭을 다녀오면 큰 dt 로 입자가 화면 밖으로 튄다
- 글자 크기는 화면 폭에 맞춰 줄일 것 (measureText 로 재서 폭 86% 안)
- 색 바꾸기는 몇 묶음으로 — 입자마다 fillStyle 을 바꾸면 느리다

#### 5. 완성 기준 (이게 보이면 성공)
- 글자가 점으로 또렷하게 읽히고, 2~3초마다 점들이 흩어졌다가 다음 글자로 모인다
- 「흩어졌다 모이기」를 끄면 점들이 곧장 다음 자리로 흐르고, 켜면 한 번 터진 뒤 모인다
- 스프링 세기를 낮추면 느긋하게 출렁이며 모이고, 높이면 빠르게 딱 붙는다
- 한글 · 영문 · 기호(★) 모두 점 수가 비슷해 한 글자만 성기거나 빽빽하지 않다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 자리 뽑기: 보이지 않는 캔버스에 글자를 꽉 차게 그리고 getImageData 로 알파 > 128 인 픽셀만 고른다. 간격 = √(칠해진 넓이 ÷ 입자 수) 로 두면 어느 글자든 점 수가 비슷하다.
- 짝짓기: 입자 i 의 목표 = 섞은 순서의 i 번째 자리. 점이 모자라면 돌려 쓰고 남으면 버린다. 섞어서 짝지으면 건너갈 때 소용돌이처럼 엇갈려 보기 좋다 (x 순서로 짝지으면 옆으로 미끄러짐).
- 움직임: 매 프레임 속도 += (k·(목표 − 자리) − c·속도)·dt, 자리 += 속도·dt. c = 2√k × 0.55 면 살짝 지나쳤다 돌아온다.
- 바뀌는 순간 입자마다 무작위 방향 속도를 더해 주면 「터졌다가 모이는」 느낌. 빠른 입자는 조금 크게 그려 속도감을 준다.

## 핵심 코드 — 글자 → 점 자리 뽑기 + 스프링
(발췌: demos/demosMotionE.ts 의 sampleWord() · particleText() 를 정리)
```ts
function sampleWord(word: string, w: number, h: number, n: number): number[] {
  const c = document.createElement('canvas');
  c.width = w; c.height = h;
  const g = c.getContext('2d', { willReadFrequently: true })!;
  let size = h * 0.6;
  g.font = '900 ' + size + 'px sans-serif';
  const tw = g.measureText(word).width;
  if (tw > w * 0.86) size *= (w * 0.86) / tw;           // 폭에 맞춰 줄이기
  g.font = '900 ' + size + 'px sans-serif';
  g.textAlign = 'center'; g.textBaseline = 'middle';
  g.fillText(word, w / 2, h / 2);
  const d = g.getImageData(0, 0, w, h).data;
  const on = (x: number, y: number) => d[((y | 0) * w + (x | 0)) * 4 + 3] > 128;
  let area = 0;
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (on(x, y)) area++;
  const step = Math.max(1, Math.sqrt(area / n));         // 어느 글자든 점 수 ≈ n
  const pts: number[] = [];
  for (let y = step / 2; y < h; y += step)
    for (let x = step / 2; x < w; x += step) if (on(x, y)) pts.push(x, y);
  return pts;
}

// 매 프레임 — k: 스프링 세기, c: 마찰 (감쇠비 0.55 → 살짝 출렁)
const c = 2 * Math.sqrt(k) * 0.55;
for (let i = 0; i < N; i++) {
  vx[i] += (k * (tx[i] - px[i]) - c * vx[i]) * dt;
  vy[i] += (k * (ty[i] - py[i]) - c * vy[i]) * dt;
  px[i] += vx[i] * dt;
  py[i] += vy[i] * dt;
}
```

## 흔한 실수 · 확인 목록
- [ ] **글자마다 점 수가 크게 달라 어떤 글자는 성기다** — 간격을 고정하면 「★」는 점이 적고 「스튜디오」는 넘친다. 칠해진 넓이를 먼저 세어 간격 = √(넓이 ÷ n) 으로 글자마다 정한다.
- [ ] **바뀔 때 점들이 한쪽으로 줄지어 미끄러져 심심하다** — 자리 순서(왼쪽 → 오른쪽)대로 짝지으면 모두 같은 방향으로 움직인다. 섞은 순서로 짝지으면 엇갈려 건너간다.
- [ ] **탭을 다녀오면 입자가 화면 밖으로 튀어 나간다** — dt 가 몇 초가 되면 스프링이 폭발한다. dt 를 1/30 초로 막는다.
- [ ] **글꼴이 늦게 내려오면 첫 글자만 기본 글꼴로 뽑힌다** — document.fonts.ready 뒤에 자리를 뽑거나, 글꼴이 바뀌면 다시 뽑는다.

## 완성 기준 체크리스트
- [ ] 글자가 점으로 또렷하게 읽히고, 2~3초마다 점들이 흩어졌다가 다음 글자로 모인다
- [ ] 「흩어졌다 모이기」를 끄면 점들이 곧장 다음 자리로 흐르고, 켜면 한 번 터진 뒤 모인다
- [ ] 스프링 세기를 낮추면 느긋하게 출렁이며 모이고, 높이면 빠르게 딱 붙는다
- [ ] 한글 · 영문 · 기호(★) 모두 점 수가 비슷해 한 글자만 성기거나 빽빽하지 않다

## 이 기술 정보
- id: `i556` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 입자 1,000개 스프링은 캔버스 2D 로 1ms 안. 자리 뽑기(getImageData)는 글자 · 화면 크기가 바뀔 때만 — 매 프레임 하면 안 된다. 5,000개를 넘으면 three Points 나 셰이더로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i556
- 쓰면 좋을 때: 게임 제목 · 결과 · 카운트다운처럼 글자가 주인공인 순간 / 숫자가 바뀌는 순간을 크게 보여 줄 때 (점수 · 레벨)
- 쓰지 말 때: 읽어야 하는 긴 문장 — 입자 글자는 4~6자까지 / 작은 화면 구석 — 점 간격보다 글자가 작으면 알아볼 수 없다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### particleText — `src/demos/demosMotionE.ts:42`
```ts
function particleText(): ReturnType<Demo2D['make']> {
  const o = { s: 1, k: 70, burst: true, n: 900, skip: 0 };
  let N = 0;
  let px = new Float32Array(0);
  let py = new Float32Array(0);
  let vx = new Float32Array(0);
  let vy = new Float32Array(0);
  let tx = new Float32Array(0);
  let ty = new Float32Array(0);
  let key = '';
  let words: number[][] = [];
  let cur = -1;
  let clock = 0;
  const HOLD = 2.6;

  const retarget = (wi: number, w: number, h: number, kick: boolean): void => {
    const pts = words[wi]!;
    const cnt = pts.length / 2;
    // 섞은 순서로 짝짓기 — 입자가 글자 사이를 소용돌이치며 건너간다
    const order = Array.from({ length: cnt }, (_, i) => i).sort((a, b) => hsh(a * 3.17 + wi * 11.3) - hsh(b * 3.17 + wi * 11.3));
    for (let i = 0; i < N; i++) {
      const j = order[i % cnt]!;
      tx[i] = pts[j * 2]!;
      ty[i] = pts[j * 2 + 1]!;
      if (kick) {
        const a = hsh(i * 1.7 + wi) * Math.PI * 2;
        const sp = (0.4 + hsh(i * 2.3 + wi * 5) * 0.8) * Math.min(w, h) * 3.2;
        vx[i]! += Math.cos(a) * sp;
        vy[i]! += Math.sin(a) * sp;
      }
    }
  };
  const init = (w: number, h: number): void => {
    N = o.n;
    words = WORDS556.map((s) => sampleWord(s, w, h, N));
    px = new Float32Array(N);
    py = new Float32Array(N);
    vx = new Float32Array(N);
    vy = new Float32Array(N);
    tx = new Float32Array(N);
    ty = new Float32Array(N);
    for (let i = 0; i < N; i++) {
      px[i] = hsh(i * 9.1) * w;
      py[i] = hsh(i * 4.7) * h;
    }
    key = `${w}x${h}x${N}`;
    cur = -1;
  };

  return {
    draw(g: G, w: number, h: number, _t: number, dt: number) {
      if (`${w}x${h}x${o.n}` !== key) init(w, h);
      const d = Math.min(dt, 1 / 30) * o.s;
      clock += d;
      const wi = (Math.floor(clock / HOLD) + o.skip) % WORDS556.length;
      if (wi !== cur) {
        retarget(wi, w, h, o.burst && cur >= 0);
        cur = wi;
      }
      // 스프링: a = k(목표 − 자리) − c·속도, c = 2√k · 0.55 (살짝 출렁)
      const k = o.k;
      const c = 2 * Math.sqrt(k) * 0.55;
      for (let i = 0; i < N; i++) {
        const wob = Math.sin(clock * 2 + i) * 0.5;
        vx[i]! += (k * (tx[i]! + wob - px[i]!) - c * vx[i]!) * d;
        vy[i]! += (k * (ty[i]! + wob - py[i]!) - c * vy[i]!) * d;
        px[i]! += vx[i]! * d;
        py[i]! += vy[i]! * d;
      }
      g.fillStyle = '#0b0f1a';
      g.fillRect(0, 0, w, h);
      g.globalCompositeOperation = 'lighter';
      const r = Math.max(1, Math.min(w, h) / 120);
      const hue0 = (clock * 25) % 360;
      for (let grp = 0; grp < 6; grp++) {
        g.fillStyle = `hsl(${(hue0 + grp * 22).toFixed(0)},90%,62%)`;
        for (let i = grp; i < N; i += 6) {
          const sp = Math.min(1, Math.hypot(vx[i]!, vy[i]!) / (w * 0.6));
          const rr = r * (1 + sp * 0.8);
          g.fillRect(px[i]! - rr, py[i]! - rr, rr * 2, rr * 2);
        }
      }
      g.globalCompositeOperation = 'source-over';
    },
    controls: [
      speedCtl(o),
      { type: 'range', label: '스프링 세기', min: 15, max: 220, step: 5, value: o.k, on: (v) => (o.k = v) },
      { type: 'range', label: '입자 수', min: 300, max: 2000, step: 100, value: o.n, on: (v) => (o.n = v) },
      { type: 'toggle', label: '흩어졌다 모이기', value: o.burst, on: (v) => (o.burst = v) },
      { type: 'button', label: '다음 글자', on: () => (o.skip += 1) },
    ],
  };
}
```

### i556 견본 항목 — `src/demos/demosMotionE.ts:801`
```ts
  i556: { kind: '2d', caption: '글자를 숨은 캔버스에 그려 픽셀 자리를 뽑고, 입자가 스프링으로 그 자리에 모였다 다음 글자로 흩어져 건너감', make: particleText }
```

## 관련 기술
- 먼저 알면 좋은 기술: [글자 차례 등장 (키네틱 타이포)](https://ai-techstudio.web.app/ai/t/i90.md) `i90` · [캔버스 입자 엔진 (2D)](https://ai-techstudio.web.app/ai/t/i302.md) `i302`
- 다음에 해 볼 기술: [GPU 입자 (수만 개)](https://ai-techstudio.web.app/ai/t/i22.md) `i22` · [매개 모핑 (이음새 없는 변신)](https://ai-techstudio.web.app/ai/t/i97.md) `i97`
- 참고 문서: [MDN — CanvasRenderingContext2D.getImageData()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData) · [Wikipedia — Damping (감쇠 진동)](https://en.wikipedia.org/wiki/Damping)
