# AI 꾸러미 — 스프라이트 녹기 · 나타나기 (잡음 디졸브 2D)
> 잡음 문턱으로 타들어 가듯 사라지고 테두리 불빛  
> 견본: https://ai-techstudio.web.app/#t/i295

이 문서를 코드 도우미(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: `i295` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i295
- 쓰면 좋을 때: 쓰면 좋을 곳 — 사라지는 블록 · 정답 칸 지우기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI295 — `src/demos/demos2dLook.ts:508`
```ts
function mkI295() {
  const S = 96;
  let edge = 0.08;
  let nscale = 0.075;
  let embers = true;
  const srcs = [mkCanvas(S, S), mkCanvas(S, S)];
  drawCrate(c2(srcs[0]!), S);
  c2(srcs[1]!).drawImage(buddy(S, 'sky'), 0, 0);
  const pix = srcs.map((c) => c.getContext('2d', { willReadFrequently: true })!.getImageData(0, 0, S, S).data);
  const noise = [new Float32Array(S * S), new Float32Array(S * S)];
  const buildNoise = (): void => {
    for (let k = 0; k < 2; k++) {
      const n = noise[k]!;
      let mn = 9;
      let mx = -9;
      for (let y = 0; y < S; y++)
        for (let x = 0; x < S; x++) {
          const v = fbm(x * nscale, y * nscale, 11 + k * 5) * 0.8 + (1 - y / S) * 0.2;
          n[y * S + x] = v;
          if (v < mn) mn = v;
          if (v > mx) mx = v;
        }
      for (let i = 0; i < S * S; i++) n[i] = (n[i]! - mn) / (mx - mn);
    }
  };
  buildNoise();
  const outs = [mkCanvas(S, S), mkCanvas(S, S)];
  const glows = [mkCanvas(S, S), mkCanvas(S, S)];
  const ims = outs.map((c) => c2(c).createImageData(S, S));
  const gims = glows.map((c) => c2(c).createImageData(S, S));
  const FIRE: V3[] = [
    [255, 255, 220],
    [255, 205, 70],
    [255, 110, 30],
    [190, 35, 20],
  ];
  const MAGIC: V3[] = [
    [240, 255, 255],
    [120, 230, 255],
    [90, 120, 255],
    [150, 60, 220],
  ];
  const ramp = (r: V3[], k: number): V3 => {
    const x = clamp01(k) * (r.length - 1);
    const i = Math.min(r.length - 2, Math.floor(x));
    return mix3(r[i]!, r[i + 1]!, x - i);
  };
  interface Em {
    x: number;
    y: number;
    vx: number;
    vy: number;
    life: number;
    k: number;
  }
  const ems: Em[] = [];
  const thAt = (t: number): number => {
    const c = t % 4.2;
    if (c < 0.5) return 0;
    if (c < 1.9) return smooth((c - 0.5) / 1.4) * 1.12;
    if (c < 2.4) return 1.2;
    if (c < 3.8) return (1 - smooth((c - 2.4) / 1.4)) * 1.12;
    return 0;
  };
  const controls: Control[] = [
    { type: 'range', label: '불빛 테두리 폭', min: 0.01, max: 0.2, step: 0.01, value: edge, on: (v) => (edge = v) },
    {
      type: 'range',
      label: '잡음 크기',
      min: 0.03,
      max: 0.2,
      step: 0.005,
      value: nscale,
      on: (v) => {
        nscale = v;
        buildNoise();
      },
    },
    { type: 'toggle', label: '불티 입자', value: embers, on: (v) => (embers = v) },
  ];
  return {
    controls,
    draw(g: G, w: number, h: number, t: number, dt: number) {
      reset(g);
      const f = frame(g, w, h);
      vbg(g, f, '#1d1530', '#0c0a18');
      g.fillStyle = '#231b3d';
      g.fillRect(f.L, 140, f.R - f.L, f.B - 140);
      for (let k = 0; k < 2; k++) {
        const th = thAt(t + k * 0.35);
        const fire = k === 0;
        const rmp = fire ? FIRE : MAGIC;
        const src = pix[k]!;
        const n = noise[k]!;
        const d = ims[k]!.data;
        const gd = gims[k]!.data;
        const cx = 80 + k * 120;
        const D = 104;
        const ox = cx - D / 2;
        const oy = 140 - D * 0.92;
        let cnt = 0;
        for (let i = 0; i < S * S; i++) {
          const a = src[i * 4 + 3]!;
          const j = i * 4;
          gd[j + 3] = 0;
          if (a === 0) {
            d[j + 3] = 0;
            continue;
          }
          const v = n[i]! - th + edge * (th > 0 ? 1 : 0) * 0;
          if (th > 0 && n[i]! < th) {
            d[j + 3] = 0;
            continue;
          }
          const kk = th > 0 ? (n[i]! - th) / edge : 9;
          if (kk < 1) {
            const c = ramp(rmp, kk);
            d[j] = c[0];
            d[j + 1] = c[1];
            d[j + 2] = c[2];
            d[j + 3] = a;
            gd[j] = c[0];
            gd[j + 1] = c[1];
            gd[j + 2] = c[2];
            gd[j + 3] = Math.round(a * (1 - kk * 0.6));
            if (embers && (i * 7919 + Math.floor(t * 60)) % 173 === 0 && ems.length < 90) {
              ems.push({ x: ox + ((i % S) / S) * D, y: oy + (Math.floor(i / S) / S) * D, vx: (Math.random() - 0.5) * 14, vy: -18 - Math.random() * 26, life: 0, k });
            }
            cnt++;
          } else {
            const dark = kk < 2 && th > 0 ? 0.55 + 0.45 * (kk - 1) : 1;
            d[j] = src[j]! * dark;
            d[j + 1] = src[j + 1]! * dark;
            d[j + 2] = src[j + 2]! * dark;
            d[j + 3] = a;
          }
          void v;
        }
        c2(outs[k]!).putImageData(ims[k]!, 0, 0);
        c2(glows[k]!).putImageData(gims[k]!, 0, 0);
        // 그림자
        g.fillStyle = 'rgba(0,0,0,.35)';
        const vis = clamp01(1 - th);
        g.beginPath();
        g.ellipse(cx, 141, 34 * (0.3 + 0.7 * vis), 6, 0, 0, TAU);
        g.fill();
        g.drawImage(outs[k]!, ox, oy, D, D);
        if (cnt) {
          g.globalCompositeOperation = 'lighter';
          g.drawImage(glows[k]!, ox, oy, D, D);
          g.globalAlpha = 0.5;
          g.drawImage(glows[k]!, ox - D * 0.04, oy - D * 0.04, D * 1.08, D * 1.08);
          g.globalAlpha = 1;
          g.globalCompositeOperation = 'source-over';
          spot(g, cx, oy + D * 0.5, 70, fire ? [255, 140, 40] : [90, 160, 255], 0.14);
        }
        // 문턱 막대
        const bx = cx - 40;
        rr(g, bx, 156, 80, 6, 3);
        g.fillStyle = 'rgba(255,255,255,.12)';
        g.fill();
        rr(g, bx, 156, 80 * clamp01(th), 6, 3);
        g.fillStyle = fire ? '#ff9b3d' : '#6fb6ff';
        g.fill();
        txt(g, (fire ? '불로 녹기' : '마법 나타나기') + ' · 문턱 ' + clamp01(th).toFixed(2), cx, 168, 7.5, '#d9d2f2');
      }
      g.globalCompositeOperation = 'lighter';
      for (let i = ems.length - 1; i >= 0; i--) {
        const e = ems[i]!;
        e.life += dt;
        e.x += e.vx * dt;
        e.y += e.vy * dt;
        e.vy -= 10 * dt;
        if (e.life > 1) {
          ems[i] = ems[ems.length - 1]!;
          ems.pop();
          continue;
        }
        const c: V3 = e.k === 0 ? [255, 170, 60] : [120, 200, 255];
        g.fillStyle = rgba(c, (1 - e.life) * 0.9);
        g.beginPath();
        g.arc(e.x, e.y, 1.4 * (1 - e.life) + 0.4, 0, TAU);
        g.fill();
      }
      g.globalCompositeOperation = 'source-over';
      pill(g, '잡음 값 < 문턱 → 지움 · 문턱 바로 위 = 불빛 테', 140, 14, 8, 'rgba(255,255,255,.1)', '#efe8ff');
      g.restore();
    },
  };
}
```

### i295 견본 항목 — `src/demos/demos2dLook.ts:3778`
```ts
  i295: { kind: '2d', caption: '화소마다 잡음 값 < 문턱이면 지우고, 문턱 바로 위 띠만 불빛(왼쪽) · 마법빛(오른쪽)으로 — 불티가 날림', make: mkI295 }
```

## 관련 기술
- 이 효과를 만든 기술: [디졸브 (잡음으로 사라짐)](https://ai-techstudio.web.app/ai/t/i12.md) `i12`
