# AI 꾸러미 — 2D 잔상 꼬리 · 대시 잔상 — Afterimage (ghost trail)
> 지난 0.6초 위치를 기억해 두었다가 0.035초 간격 자리마다 색을 입힌 반투명 복제를 그려, 대시할 때 캐릭터 뒤로 잔상이 사라지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i298

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

## 주문서

### 만들어 줘: 2D 잔상 꼬리 · 대시 잔상 — Afterimage (ghost trail)

#### 1. 목표
대시하는 캐릭터가 빠르게 움직일 때 뒤로 잔상을 남겨 줘 — 지난 위치를 기억해 일정 시간 간격으로 반투명 복제를 그리고, 뒤로 갈수록 옅게. 색은 하늘 → 보라 → 분홍 네온.

#### 2. 핵심 기술 용어
- **Afterimage (ghost trail)** — 잔상 — 지난 자리에 반투명 복제
- **Position history buffer** — 지난 위치 기록
- **Time-based sampling (interpolation)** — 정해진 시간 전 자리를 기록 사이에서 보간
- **Additive tinted ghosts** — 색 입혀 더하기로 그린 잔상

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

#### 4. 조건
- 잔상 위치는 「프레임 몇 개 전」이 아니라 「몇 초 전」으로 (프레임 빠르기와 상관없게)
- 기록은 0.6초만 남기고 버리기
- 지금 위치와 거의 같은 잔상은 그리지 않기 (멈춰 있을 때 겹침 방지)
- 색 물들인 그림은 미리 구워 두기
- 잔상 켬/끔 · 개수 2~12 · 색 입히기 켬/끔

#### 5. 완성 기준 (이게 보이면 성공)
- 대시하는 순간 캐릭터 뒤로 색 잔상이 늘어섰다가 멈추면 사라진다
- 잔상 개수를 늘리면 꼬리가 길어진다
- 색 입히기를 끄면 원래 색 반투명 잔상이 된다
- 60fps · 30fps 어디서든 잔상 간격이 같다

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

## 원리
- 매 프레임 (시각, 위치, 늘어남)을 기록 배열에 넣고, 0.6초보다 오래된 것은 앞에서 버린다.
- 잔상 i 번째(1 ~ n) 는 「지금 − i·0.035초」 시각의 위치 — 기록 두 칸 사이를 보간해 구한다. 프레임 빠르기가 달라도 간격이 같다.
- 알파 = 0.6·(1 − i/n) + 0.08 — 뒤로 갈수록 옅게. 색은 하늘 → 파랑 → 보라 → 분홍 4장을 미리 물들여 두고 골라 쓴다.
- 색 잔상은 lighter(더하기)로 그려 빛처럼. 지금 위치와 거의 같은(2px 안) 잔상은 건너뛴다 — 서 있을 땐 안 보이게.

## 핵심 코드 — 지난 위치 기록 → 시간 간격으로 잔상 그리기
(발췌: demos/demos2dLook.ts mkI298() 를 정리)
```ts
interface Rec { t: number; x: number; sx: number; sy: number }
const hist: Rec[] = [];
// 하늘 → 파랑 → 보라 → 분홍, 0.75 만큼 물들인 그림 4장 (tinted 는 source-atop 칠하기)
const tints = ['#6ff6ff', '#8aa8ff', '#c08bff', '#ff8be0'].map((c) => tinted(bud, c, 0.75));

function at(tt: number): Rec | null { // 기록 두 칸 사이 보간
  for (let i = hist.length - 1; i > 0; i--) {
    const a = hist[i - 1]!, b = hist[i]!;
    if (a.t <= tt && b.t >= tt) {
      const k = (tt - a.t) / Math.max(1e-6, b.t - a.t);
      return { t: tt, x: a.x + (b.x - a.x) * k, sx: a.sx + (b.sx - a.sx) * k, sy: a.sy + (b.sy - a.sy) * k };
    }
  }
  return null;
}

function drawGhosts(g: CanvasRenderingContext2D, t: number, p: Rec, count = 6, D = 70, floorY = 128) {
  hist.push({ t, x: p.x, sx: p.sx, sy: p.sy });
  while (hist.length > 2 && hist[0]!.t < t - 0.6) hist.shift(); // 0.6초만
  for (let i = count; i >= 1; i--) { // 먼 것부터 그려 가까운 잔상이 위에
    const q = at(t - i * 0.035);
    if (!q || Math.abs(q.x - p.x) < 2) continue; // 서 있으면 건너뛰기
    const k = i / count;
    g.globalAlpha = 0.6 * (1 - k) + 0.08;
    g.globalCompositeOperation = 'lighter';
    const img = tints[Math.min(tints.length - 1, Math.floor(k * tints.length))]!;
    g.drawImage(img, q.x - (D * q.sx) / 2, floorY - D * q.sy * 0.9, D * q.sx, D * q.sy);
  }
  g.globalCompositeOperation = 'source-over';
  g.globalAlpha = 1;
  // 그다음 본체를 그린다
}
```

## 흔한 실수 · 확인 목록
- [ ] **「프레임 몇 개 전」으로 잔상을 뽑으면 빠른 화면에선 짧고 느린 화면에선 길다** — 「몇 초 전」 시각으로 뽑고 기록 사이를 보간한다.
- [ ] **멈춰 있어도 잔상이 겹쳐 그려져 캐릭터가 번져 보인다** — 지금 위치와 2px 안이면 건너뛴다.
- [ ] **잔상 색 그림을 매 프레임 만들면 느리다** — 물들인 그림 4장을 미리 굽는다.
- [ ] **잔상을 본체 뒤가 아니라 위에 그리면 본체가 가려진다** — 잔상을 먼저(먼 것부터) 그리고 본체를 마지막에.

## 완성 기준 체크리스트
- [ ] 대시하는 순간 캐릭터 뒤로 색 잔상이 늘어섰다가 멈추면 사라진다
- [ ] 잔상 개수를 늘리면 꼬리가 길어진다
- [ ] 색 입히기를 끄면 원래 색 반투명 잔상이 된다
- [ ] 60fps · 30fps 어디서든 잔상 간격이 같다

## 이 기술 정보
- id: `i298` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 잔상 n개(기본 6) = drawImage n번. 색 물들인 그림은 4장만 미리 굽는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i298
- 쓰면 좋을 때: 대시 · 순간이동처럼 아주 빠른 움직임을 눈에 보이게 할 때 / 속도감 있는 손맛 연출
- 쓰지 말 때: 늘 천천히 움직이는 캐릭터 — 잔상이 겹쳐 지저분하다 / 아주 많은 물체에 동시에 — drawImage 가 n배. 대신 주인공 · 빠른 것만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI298 — `src/demos/demos2dLook.ts:964`
```ts
function mkI298() {
  const S = 128;
  const bud = buddy(S, 'pink');
  const tints = ['#6ff6ff', '#8aa8ff', '#c08bff', '#ff8be0'].map((c) => tinted(bud, c, 0.75));
  let on = true;
  let count = 6;
  let color = true;
  const hist: { t: number; x: number; y: number; sx: number; sy: number }[] = [];
  const controls: Control[] = [
    { type: 'toggle', label: '잔상', value: on, on: (v) => (on = v) },
    { type: 'range', label: '잔상 개수', min: 2, max: 12, step: 1, value: count, on: (v) => (count = v) },
    { type: 'toggle', label: '색 입히기', value: color, on: (v) => (color = v) },
  ];
  const posAt = (t: number): { x: number; sx: number; sy: number; moving: number; dir: number } => {
    const c = t % 2.4;
    const A = 50;
    const B = 230;
    const dash = (k: number): number => 1 - Math.pow(2, -10 * k);
    if (c < 0.9) return { x: A, sx: 1, sy: 1, moving: 0, dir: 1 };
    if (c < 1.2) {
      const k = (c - 0.9) / 0.3;
      return { x: lerp(A, B, dash(k)), sx: 1.25 - k * 0.2, sy: 0.85 + k * 0.1, moving: 1 - k, dir: 1 };
    }
    if (c < 2.1) return { x: B, sx: 1, sy: 1, moving: 0, dir: -1 };
    const k = (c - 2.1) / 0.3;
    return { x: lerp(B, A, dash(k)), sx: 1.25 - k * 0.2, sy: 0.85 + k * 0.1, moving: 1 - k, dir: -1 };
  };
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const f = frame(g, w, h);
      vbg(g, f, '#1a1f3d', '#0b0e1f');
      // 바닥 줄
      g.fillStyle = '#262c55';
      g.fillRect(f.L, 128, f.R - f.L, f.B - 128);
      g.fillStyle = 'rgba(255,255,255,.07)';
      for (let x = -20; x < 300; x += 24) g.fillRect(x, 132, 12, 2);
      const p = posAt(t);
      hist.push({ t, x: p.x, y: 0, sx: p.sx, sy: p.sy });
      while (hist.length > 2 && hist[0]!.t < t - 0.6) hist.shift();
      while (hist.length > 2 && hist[0]!.t > t) hist.shift();
      const D = 70;
      // 속도선
      if (p.moving > 0.05) {
        g.strokeStyle = `rgba(200,230,255,${p.moving * 0.6})`;
        g.lineWidth = 1.2;
        for (let i = 0; i < 6; i++) {
          const y = 82 + i * 8 + Math.sin(i * 3.1) * 3;
          const len = 30 + (i % 3) * 14;
          g.beginPath();
          g.moveTo(p.x - p.dir * (30 + i * 3), y);
          g.lineTo(p.x - p.dir * (30 + len), y);
          g.stroke();
        }
      }
      const at = (tt: number): { x: number; sx: number; sy: number } | null => {
        for (let i = hist.length - 1; i > 0; i--) {
          const a = hist[i - 1]!;
          const b = hist[i]!;
          if (a.t <= tt && b.t >= tt) {
            const k = (tt - a.t) / Math.max(1e-6, b.t - a.t);
            return { x: lerp(a.x, b.x, k), sx: lerp(a.sx, b.sx, k), sy: lerp(a.sy, b.sy, k) };
          }
        }
        return null;
      };
      if (on) {
        for (let i = count; i >= 1; i--) {
          const q = at(t - i * 0.035);
          if (!q || Math.abs(q.x - p.x) < 2) continue;
          const k = i / count;
          g.globalAlpha = 0.6 * (1 - k) + 0.08;
          const img = color ? tints[Math.min(tints.length - 1, Math.floor(k * tints.length))]! : bud;
          const dw = D * q.sx;
          const dh = D * q.sy;
          if (color) g.globalCompositeOperation = 'lighter';
          g.drawImage(img, q.x - dw / 2, 128 - dh * 0.9, dw, dh);
          g.globalCompositeOperation = 'source-over';
        }
        g.globalAlpha = 1;
      }
      g.fillStyle = 'rgba(0,0,0,.35)';
      g.beginPath();
      g.ellipse(p.x, 129, 24 * p.sx, 4, 0, 0, TAU);
      g.fill();
      const dw = D * p.sx;
      const dh = D * p.sy;
      g.save();
      g.translate(p.x, 0);
      if (p.dir < 0) g.scale(-1, 1);
      g.drawImage(bud, -dw / 2, 128 - dh * 0.9, dw, dh);
      g.restore();
      // 멈춘 순간 먼지
      const c = t % 1.2;
      if (c > 0.1 && c < 0.6 && Math.floor(t / 1.2) % 1 === 0) {
        const k = (c - 0.1) / 0.5;
        g.fillStyle = `rgba(220,225,255,${0.5 * (1 - k)})`;
        for (let i = 0; i < 4; i++) {
          g.beginPath();
          g.arc(p.x + p.dir * (14 + k * 16) + i * 3 * p.dir, 126 - i * 3 - k * 5, 3 + k * 4, 0, TAU);
          g.fill();
        }
      }
      pill(g, on ? `잔상 ${count}개 · 지난 위치를 반투명으로` : '잔상 끔', 140, 14, 8.5, 'rgba(111,246,255,.15)', '#dffcff');
      g.restore();
    },
  };
}
```

### i298 견본 항목 — `src/demos/demos2dLook.ts:3781`
```ts
  i298: { kind: '2d', caption: '대시할 때 지난 위치들을 기억해 두었다가 색을 입힌 반투명 복제로 그림 — 뒤로 갈수록 옅게', make: mkI298 }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [오니언 스킨 잔상 · 2D 모션 블러](https://ai-techstudio.web.app/ai/t/i124.md) `i124`
- 먼저 알면 좋은 기술: [그림자 · 반사 (2D 바닥)](https://ai-techstudio.web.app/ai/t/i297.md) `i297`
- 다음에 해 볼 기술: [캔버스 입자 엔진 (2D)](https://ai-techstudio.web.app/ai/t/i302.md) `i302`
