# AI 꾸러미 — 오니언 스킨 잔상 · 2D 모션 블러
> 지나간 자리가 흐린 겹으로 남음 — 움직임을 눈으로 셈  
> 견본: https://ai-techstudio.web.app/#t/i124

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

## 주문서

### 만들어 줘: 오니언 스킨 잔상 · 2D 모션 블러

#### 1. 목표
빠른 공에 「오니언 스킨 잔상 · 2D 모션 블러」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 지나간 자리가 흐린 겹으로 남음 — 움직임을 눈으로 셈

#### 2. 핵심 기술 용어
- 오니언 스킨 잔상 · 2D 모션 블러 — 지나간 자리가 흐린 겹으로 남음 — 움직임을 눈으로 셈

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- devicePixelRatio 를 맞춰 또렷하게
- 매 프레임 다시 그릴 필요 없는 그림은 화면 밖 캔버스에 미리 구워 두기
- 한 프레임 16ms 안 (폰 포함)

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「지나간 자리가 흐린 겹으로 남음 — 움직임을 눈으로 셈」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 지나간 자리가 흐린 겹으로 남음 — 움직임을 눈으로 셈


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「지나간 자리가 흐린 겹으로 남음 — 움직임을 눈으로 셈」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i124` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i124
- 쓰면 좋을 때: 쓰면 좋을 곳 — 빠른 공 · 진자 주기 · 등속/가속 비교

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i124 견본 항목 — `src/demos/demosMotionB.ts:2148`
```ts
  i124: {
    kind: '2d',
    caption: '같은 진자 — 왼쪽 0.08초마다 남긴 잔상(가운데일수록 간격이 넓음 = 빠름) · 오른쪽 셔터 동안 겹쳐 그린 모션 블러',
    make() {
      let ghosts = 8;
      let shutter = 0.12;
      const T0 = 2.4;
      const th = (t: number): number => 0.75 * Math.cos((TAU * t) / T0);
      const bob = (g: G, px: number, py: number, L: number, a: number, u: number, col: string, alpha: number, line = true): void => {
        const x = px + Math.sin(a) * L;
        const y = py + Math.cos(a) * L;
        g.globalAlpha = alpha;
        if (line) {
          g.strokeStyle = '#cfd8ff';
          g.lineWidth = 1.5 * u;
          g.beginPath();
          g.moveTo(px, py);
          g.lineTo(x, y);
          g.stroke();
        }
        g.fillStyle = col;
        g.beginPath();
        g.arc(x, y, 10 * u, 0, TAU);
        g.fill();
        g.globalAlpha = 1;
      };
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          bg(g, w, h, '#1d2a6a', '#0b1030');
          const hw = w / 2;
          const L = Math.min(h * 0.62, hw * 0.62);
          for (const side of [0, 1]) {
            const px = side ? w * 0.75 : w * 0.25;
            const py = h * 0.2;
            // 받침 · 호
            g.fillStyle = '#8a95c9';
            rr(g, px - 22 * u, py - 5 * u, 44 * u, 5 * u, 2 * u);
            g.fill();
            g.strokeStyle = 'rgba(255,255,255,.12)';
            g.lineWidth = 1.2 * u;
            g.beginPath();
            g.arc(px, py, L, Math.PI / 2 - 0.8, Math.PI / 2 + 0.8);
            g.stroke();
            g.save();
            g.beginPath();
            g.rect(side ? hw : 0, 0, hw, h);
            g.clip();
            if (side === 0) {
              const dtg = 0.08;
              for (let k = ghosts; k >= 1; k--) bob(g, px, py, L, th(t - k * dtg), u, '#7cf0ff', 0.08 + 0.45 * (1 - k / (ghosts + 1)), false);
              for (let k = ghosts; k >= 1; k--) {
                const a = th(t - k * dtg);
                g.fillStyle = 'rgba(255,255,255,.7)';
                g.beginPath();
                g.arc(px + Math.sin(a) * L, py + Math.cos(a) * L, 1.6 * u, 0, TAU);
                g.fill();
              }
              bob(g, px, py, L, th(t), u, '#ffd23f', 1);
            } else {
              const S = 18;
              for (let k = 0; k < S; k++) bob(g, px, py, L, th(t - (k / (S - 1)) * shutter), u, '#ffd23f', k === 0 ? 0.6 : 0.1);
            }
            g.restore();
            g.fillStyle = '#fff';
            g.beginPath();
            g.arc(px, py, 2.5 * u, 0, TAU);
            g.fill();
          }
          splitLabels(g, w, h, u, '오니언 스킨', '모션 블러');
        },
        controls: [
          { type: 'range', label: '잔상 수', min: 0, max: 16, step: 1, value: 8, on: (v) => (ghosts = v) },
          { type: 'range', label: '셔터 길이 (초)', min: 0, max: 0.4, step: 0.01, value: 0.12, on: (v) => (shutter = v) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [2D 잔상 꼬리 · 대시 잔상](https://ai-techstudio.web.app/ai/t/i298.md) `i298`
