# AI 꾸러미 — 글리치 · 주사선 · CRT — Glitch effect (RGB split + slice displacement)
> 화면을 빨강 · 초록 · 파랑 세 장으로 나눠 어긋나게 더하고 가로 띠를 찢어 밀어, 글리치를 내고 주사선 · 둥근 화면으로 옛 CRT 느낌을 더한다.  
> 견본: https://ai-techstudio.web.app/#t/i112

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

## 주문서

### 만들어 줘: 글리치 · 주사선 · CRT — Glitch effect (RGB split + slice displacement)

#### 1. 목표
검문소 「모순 발견!」 순간에 글리치 · CRT 효과를 넣어 줘 — RGB 세 장을 어긋나게 더하고, 순간 가로 띠를 찢어 밀고, 주사선 · 둥근 화면까지. 분위기는 초록빛 옛 모니터.

#### 2. 핵심 기술 용어
- **Glitch effect (RGB split + slice displacement)** — 색 갈라짐 + 가로 띠 찢기
- **Chromatic aberration** — 색수차 — 빨강 · 파랑이 옆으로 어긋남
- **CRT scanlines** — 옛 TV 줄무늬 · 지나가는 밝은 띠

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

#### 4. 조건
- RGB 세 장은 크기(w · h · 픽셀 비율)가 바뀔 때만 다시 만든다 — 매 프레임 만들지 않기
- 찢기 · 큰 갈라짐은 터지는 순간만 (견본 2.6초에 0.42초), 평소엔 아주 작게 (1px)
- 무작위는 프레임 번호 씨앗(초당 20번)으로 — 띠가 매 프레임 미친 듯 바뀌지 않게
- CRT 가장자리 어둡게는 「둥근 옛 화면」 모양일 때만 — 게임 화면 전체 비네트로 쓰지 않는다
- 「글리치 세기」 · 「CRT 켬/끔」 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 평소엔 「모순 발견!」 화면에 줄무늬와 지나가는 밝은 띠만 은은하게 보인다
- 터지는 순간 가로 띠가 옆으로 찢어지고 빨강 · 파랑이 갈라진다
- CRT 를 끄면 둥근 화면 · 줄무늬가 사라지고 글리치만 남는다

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

## 원리
- 바탕 그림을 한 번 그려 두고, 그것을 빨강 · 초록 · 파랑으로 곱한(multiply) 세 장을 미리 만든다.
- 세 장을 lighter(더하기)로 다시 겹치면 원래 그림 — 빨강은 왼쪽 · 파랑은 오른쪽으로 밀면 색이 갈라진다.
- 터지는 순간(2.6초마다 0.42초)만 그림을 높이 4~30 의 가로 띠로 잘라, 45% 의 띠를 옆으로 민다 (drawImage 의 원본 영역을 띠로).
- CRT: 3px 마다 어두운 줄 · 위에서 아래로 지나가는 밝은 띠 · 둥근 네모 화면 · 유리 반사.

## 핵심 코드 — RGB 세 장 미리 만들기 · 어긋나게 더하기 · 띠 찢기
(발췌: demos/demosMotionB.ts i112 를 정리)
```ts
// 크기가 바뀔 때만: 바탕 그림 → 빨강 · 초록 · 파랑 세 장
const chan = ['#ff0000', '#00ff00', '#0000ff'].map((col) => {
  const cv = document.createElement('canvas');
  cv.width = base.width; cv.height = base.height;
  const cc = cv.getContext('2d')!;
  cc.drawImage(base, 0, 0);
  cc.globalCompositeOperation = 'multiply'; // 그 색만 남긴다
  cc.fillStyle = col; cc.fillRect(0, 0, cv.width, cv.height);
  return cv;
});

// 매 프레임 (d = 픽셀 비율)
const ph = (t % 2.6);
const burst = ph < 0.42 ? Math.sin((ph / 0.42) * Math.PI) * power : 0;
const R = rng(Math.floor(t * 20) + 1);          // 초당 20번 바뀌는 무작위
const offs = [[-(1 + burst * 7) * u, 0], [0, 0], [(1 + burst * 7) * u, burst * 2 * u]];
const bands: [number, number, number][] = [];
if (burst > 0.05) {
  for (let y = 0; y < h; ) { const bh = (4 + R() * 26) * u; bands.push([y, bh, R() < 0.45 ? (R() - 0.5) * 40 * u * burst : 0]); y += bh; }
} else bands.push([0, h, 0]);
g.globalCompositeOperation = 'lighter';         // 세 장을 더하면 원래 색
chan.forEach((cv, ci) => {
  const [ox, oy] = offs[ci];
  for (const [y, bh, sx] of bands)
    g.drawImage(cv, 0, y * d, cv.width, bh * d, ox + sx * (ci === 1 ? 0.6 : 1), y + oy, w, bh);
});
g.globalCompositeOperation = 'source-over';
// 주사선: 3px 마다 어두운 줄이 천천히 흐른다
g.fillStyle = 'rgba(0,0,0,.28)';
for (let y = (t * 18 * u) % (3 * u); y < h; y += 3 * u) g.fillRect(0, y, w, 1.2 * u);
```

## 흔한 실수 · 확인 목록
- [ ] **RGB 세 장을 매 프레임 만들면 폰에서 버벅인다** — 크기 · 픽셀 비율 열쇠가 바뀔 때만 만든다 (견본 key = w x h x d).
- [ ] **띠를 매 프레임 새로 무작위로 자르면 너무 어지럽다** — 프레임 번호(초당 20번) 씨앗 무작위로 몇 프레임씩 유지한다.
- [ ] **CRT 가장자리 어둡게를 게임 화면 전체에 쓰면 흐릿해 보인다** — 사이트 결정으로 가장자리 흐림 · 비네트는 금지. 옛 TV 화면 모양을 일부러 보여 줄 때만.

## 완성 기준 체크리스트
- [ ] 평소엔 「모순 발견!」 화면에 줄무늬와 지나가는 밝은 띠만 은은하게 보인다
- [ ] 터지는 순간 가로 띠가 옆으로 찢어지고 빨강 · 파랑이 갈라진다
- [ ] CRT 를 끄면 둥근 화면 · 줄무늬가 사라지고 글리치만 남는다

## 이 기술 정보
- id: `i112` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 세 장을 미리 만드는 데 화면 크기 캔버스 4장. 크기가 바뀔 때만 다시 만든다. 매 프레임은 drawImage 를 띠 수 × 3 번.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i112
- 쓰면 좋을 때: 오류 · 모순 · 경고처럼 「뭔가 잘못됐다」 를 순간 보여 줄 때 / 옛 게임기 · 레트로 분위기 화면
- 쓰지 말 때: 읽어야 하는 글이 계속 흔들리는 화면 — 터지는 순간만 / 빛에 민감한 아이 — 깜박임은 짧고 드물게 (초당 3번 넘게 번쩍이지 않기)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i112 견본 항목 — `src/demos/demosMotionB.ts:563`
```ts
  i112: {
    kind: '2d',
    caption: '「모순 발견!」 화면이 순간 찢기고 빨강 · 초록 · 파랑이 어긋남 + 옛 TV 줄무늬 · 둥근 화면',
    make() {
      let chan: HTMLCanvasElement[] = [];
      let key = '';
      let crt = true;
      let power = 1;
      const paint = (c: CanvasRenderingContext2D, w: number, h: number, u: number): void => {
        bg(c, w, h, '#0e2a3a', '#071420');
        c.strokeStyle = 'rgba(80,255,200,.12)';
        c.lineWidth = 1;
        for (let x = 0; x < w; x += 14 * u) {
          c.beginPath();
          c.moveTo(x, 0);
          c.lineTo(x, h);
          c.stroke();
        }
        for (let y = 0; y < h; y += 14 * u) {
          c.beginPath();
          c.moveTo(0, y);
          c.lineTo(w, y);
          c.stroke();
        }
        c.save();
        c.translate(w / 2, h * 0.42);
        c.rotate(-0.06);
        rr(c, -86 * u, -24 * u, 172 * u, 48 * u, 10 * u);
        c.lineWidth = 4 * u;
        c.strokeStyle = '#ff3b5c';
        c.stroke();
        txt(c, '모순 발견!', 0, 2 * u, 30 * u, '#ff3b5c', 'center', TF, 400);
        c.restore();
        txt(c, '가격표 30% ≠ 영수증 20%', w / 2, h * 0.7, 11 * u, '#bff8ff', 'center', F, 800);
        txt(c, '▶ 따져 묻기', w / 2, h * 0.83, 10 * u, '#ffe27a', 'center', F, 800);
      };
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          const d = g.getTransform().a;
          const k2 = `${w}x${h}x${d}`;
          if (k2 !== key) {
            key = k2;
            const base = document.createElement('canvas');
            base.width = Math.round(w * d);
            base.height = Math.round(h * d);
            const bc = base.getContext('2d')!;
            bc.scale(d, d);
            paint(bc, w, h, u);
            chan = ['#ff0000', '#00ff00', '#0000ff'].map((col) => {
              const cv = document.createElement('canvas');
              cv.width = base.width;
              cv.height = base.height;
              const cc = cv.getContext('2d')!;
              cc.drawImage(base, 0, 0);
              cc.globalCompositeOperation = 'multiply';
              cc.fillStyle = col;
              cc.fillRect(0, 0, cv.width, cv.height);
              return cv;
            });
          }
          const P = 2.6;
          const ph = fract(t / P) * P;
          const burst = ph < 0.42 ? Math.sin((ph / 0.42) * Math.PI) * power : 0;
          const frame = Math.floor(t * 20);
          const R = rng(frame + 1);
          g.fillStyle = '#000';
          g.fillRect(0, 0, w, h);
          // 화면 안쪽 (둥근 CRT)
          const inset = crt ? 8 * u : 0;
          g.save();
          if (crt) {
            rr(g, inset, inset, w - inset * 2, h - inset * 2, 18 * u);
            g.clip();
          }
          g.globalCompositeOperation = 'lighter';
          const offs = [
            [-(1 + burst * 7) * u, 0],
            [0, 0],
            [(1 + burst * 7) * u, burst * 2 * u],
          ];
          // 띠 나누기: 터질 때만 가로 띠가 어긋남
          const bands: [number, number, number][] = [];
          if (burst > 0.05) {
            let y = 0;
            while (y < h) {
              const bh = (4 + R() * 26) * u;
              bands.push([y, bh, R() < 0.45 ? (R() - 0.5) * 40 * u * burst : 0]);
              y += bh;
            }
          } else bands.push([0, h, 0]);
          chan.forEach((cv, ci) => {
            const [ox, oy] = offs[ci]!;
            for (const [y, bh, sx] of bands)
              g.drawImage(cv, 0, y * d, cv.width, bh * d, ox! + sx * (ci === 1 ? 0.6 : 1), y + oy!, w, bh);
          });
          g.globalCompositeOperation = 'source-over';
          if (burst > 0.3)
            for (let i = 0; i < 6; i++) {
              g.fillStyle = ['#ff3b5c', '#3bffd8', '#ffffff'][i % 3]! + '99';
              g.fillRect(R() * w, R() * h, (10 + R() * 50) * u, (1 + R() * 4) * u);
            }
          if (crt) {
            // 주사선
            g.fillStyle = 'rgba(0,0,0,.28)';
            for (let y = (t * 18 * u) % (3 * u); y < h; y += 3 * u) g.fillRect(0, y, w, 1.2 * u);
            // 지나가는 밝은 띠
            const sy = fract(t / 3.2) * (h + 60 * u) - 30 * u;
            const gr = g.createLinearGradient(0, sy - 30 * u, 0, sy + 30 * u);
            gr.addColorStop(0, 'rgba(180,255,230,0)');
            gr.addColorStop(0.5, 'rgba(180,255,230,.08)');
            gr.addColorStop(1, 'rgba(180,255,230,0)');
            g.fillStyle = gr;
            g.fillRect(0, sy - 30 * u, w, 60 * u);
            // 가장자리 어둡게
            const vg = g.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.3, w / 2, h / 2, Math.max(w, h) * 0.7);
            vg.addColorStop(0, 'rgba(0,0,0,0)');
            vg.addColorStop(1, 'rgba(0,0,0,.65)');
            g.fillStyle = vg;
            g.fillRect(0, 0, w, h);
            // 유리 반사
            g.fillStyle = 'rgba(255,255,255,.06)';
            g.beginPath();
            g.ellipse(w * 0.3, h * 0.2, w * 0.35, h * 0.12, -0.25, 0, TAU);
            g.fill();
          }
          g.restore();
          if (crt) {
            rr(g, inset, inset, w - inset * 2, h - inset * 2, 18 * u);
            g.lineWidth = 3 * u;
            g.strokeStyle = '#2a2f3a';
            g.stroke();
          }
        },
        controls: [
          { type: 'range', label: '글리치 세기', min: 0, max: 2, step: 0.05, value: 1, on: (v) => (power = v) },
          { type: 'toggle', label: 'CRT (줄무늬 · 둥근 화면)', value: true, on: (v) => (crt = v) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [암호 풀리는 글자 (scramble)](https://ai-techstudio.web.app/ai/t/i93.md) `i93` · [색 보정 LUT · 색수차 · 필름 결](https://ai-techstudio.web.app/ai/t/i07.md) `i07`
- 다음에 해 볼 기술: [임팩트 프레임 (반전 번쩍)](https://ai-techstudio.web.app/ai/t/i200.md) `i200` · [맞는 순간 색수차 맥박](https://ai-techstudio.web.app/ai/t/i203.md) `i203`
- 참고 문서: [three.js 예제 — webgl_postprocessing_glitch](https://threejs.org/examples/#webgl_postprocessing_glitch)
