# AI 꾸러미 — 렌즈 플레어 · 빛 새기 — Lens flare (ghosts along the flare axis)
> 밝은 빛에서 화면 가운데를 지나는 줄 위에 둥근 · 육각 고스트를 늘어놓고 가로 빛줄 · 가장자리 주황빛을 더해, 카메라 렌즈 플레어처럼 빛나게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i111

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

## 주문서

### 만들어 줘: 렌즈 플레어 · 빛 새기 — Lens flare (ghosts along the flare axis)

#### 1. 목표
별 획득 · 보상 순간에 렌즈 플레어를 넣어 줘 — 빛에서 화면 가운데를 지나는 줄 위에 고스트, 가로 빛줄, 십자 빛살, 가장자리 빛 새기를 더하기 섞기로. 분위기는 밤하늘 · 푸른 빛줄.

#### 2. 핵심 기술 용어
- **Lens flare (ghosts along the flare axis)** — 렌즈 플레어 — 빛과 화면 가운데를 잇는 줄 위의 고스트
- **Anamorphic streak** — 가로로 길게 늘어진 빛줄
- **Light leak** — 화면 가장자리로 새어 드는 주황빛
- **Additive blending (lighter)** — 빛 더하기 섞기

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

#### 4. 조건
- 고스트는 반드시 「빛 → 화면 가운데」 직선 위에 — 빛이 움직이면 고스트가 반대쪽으로 움직여야 진짜 같다
- 모든 플레어 요소는 lighter(더하기) 섞기
- 세기 조절 하나로 모든 요소의 투명도를 함께 (견본 0 ~ 2)
- 빛 새기는 빛이 가까운 가장자리에서만, 거리에 따라 옅게
- 「켬 / 끔」 비교 (견본은 8초 주기 중 62% 켬)

#### 5. 완성 기준 (이게 보이면 성공)
- 별이 화면을 돌면 고스트들이 늘 화면 가운데를 지나 반대쪽에 줄지어 따라 움직인다
- 별을 가로지르는 푸른 가로 빛줄과 도는 십자 빛살이 보인다
- 별이 화면 왼쪽 · 오른쪽 끝에 가까워지면 그쪽 가장자리가 주황빛으로 물든다
- 「플레어 세기」 를 0 으로 내리면 별만 남는다

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

## 원리
- 진짜 렌즈에선 밝은 빛이 렌즈 사이를 오가며 반사돼, 빛 → 화면 가운데 → 반대편 을 잇는 직선 위에 둥근 「고스트」 가 생긴다.
- 고스트 자리 = L + (C − L) × s. s 가 0.35 · 0.7 · 1.15 · 1.45 · 1.8 · 2.15 처럼 1 을 넘으면 가운데를 지나 반대편에 놓인다.
- 고스트는 가장자리가 진한 고리 모양 그러데이션, 큰 것(반지름 20 넘음)은 조리개 모양 육각형.
- 모두 lighter(더하기)로 그려 겹칠수록 밝다. 빛이 화면 가장자리에 가까우면 그쪽에서 주황빛이 새어 든다.

## 핵심 코드 — 빛 → 가운데 줄 위 고스트 · 가로 빛줄 · 빛 새기
(발췌: demos/demosMotionB.ts i111 을 정리)
```ts
const C = { x: w / 2, y: h / 2 };                 // 화면 가운데
g.save();
g.globalCompositeOperation = 'lighter';
// 가로 빛줄 (아나모픽)
let gr = g.createLinearGradient(L.x - w * 0.6, 0, L.x + w * 0.6, 0);
gr.addColorStop(0, 'rgba(120,180,255,0)');
gr.addColorStop(0.5, 'rgba(170,220,255,' + 0.75 * k + ')');
gr.addColorStop(1, 'rgba(120,180,255,0)');
g.fillStyle = gr;
g.fillRect(L.x - w * 0.6, L.y - 1.2 * u, w * 1.2, 2.4 * u);
// 고스트: [L→C 비율 s, 반지름, 색, 투명도] — s > 1 은 가운데 너머 반대편
const GH: [number, number, string, number][] = [
  [0.35, 7, '120,255,200', 0.35], [0.7, 14, '255,140,220', 0.22], [1.15, 5, '160,200,255', 0.5],
  [1.45, 24, '120,160,255', 0.14], [1.8, 10, '255,220,120', 0.3], [2.15, 34, '255,120,160', 0.1],
];
for (const [s, r, c, al] of GH) {
  const x = L.x + (C.x - L.x) * s, y = L.y + (C.y - L.y) * s, rad = r * u;
  gr = g.createRadialGradient(x, y, rad * 0.2, x, y, rad);
  gr.addColorStop(0, 'rgba(' + c + ',' + al * 0.4 * k + ')');
  gr.addColorStop(0.85, 'rgba(' + c + ',' + al * k + ')');  // 가장자리가 진한 고리
  gr.addColorStop(1, 'rgba(' + c + ',0)');
  g.fillStyle = gr;
  g.beginPath();
  if (r > 20) for (let i = 0; i < 6; i++) { const a = (i * Math.PI * 2) / 6 + 0.3; g.lineTo(x + Math.cos(a) * rad, y + Math.sin(a) * rad); } // 조리개 육각형
  else g.arc(x, y, rad, 0, Math.PI * 2);
  g.fill();
}
// 빛 새기: 빛이 가까운 가장자리에서 주황빛
const edge = L.x < w / 2 ? 0 : w;
const leak = (1 - Math.min(1, Math.abs(L.x - edge) / (w * 0.5))) * k;
gr = g.createLinearGradient(edge, 0, w / 2, 0);
gr.addColorStop(0, 'rgba(255,130,40,' + 0.55 * leak + ')');
gr.addColorStop(1, 'rgba(255,80,60,0)');
g.fillStyle = gr; g.fillRect(0, 0, w, h);
g.restore();
```

## 흔한 실수 · 확인 목록
- [ ] **고스트를 빛 둘레에 아무렇게나 흩으면 가짜 같다** — 반드시 빛 → 화면 가운데 직선 위, 가운데 너머까지 (s 1 넘김).
- [ ] **source-over 로 그리면 고스트가 탁한 동그라미가 된다** — 모두 lighter 로 더해 겹칠수록 밝게.
- [ ] **플레어를 늘 켜 두면 화면이 뿌옇다** — 보상 순간에만 켜고, 세기 조절 하나로 전체를 함께 줄인다.

## 완성 기준 체크리스트
- [ ] 별이 화면을 돌면 고스트들이 늘 화면 가운데를 지나 반대쪽에 줄지어 따라 움직인다
- [ ] 별을 가로지르는 푸른 가로 빛줄과 도는 십자 빛살이 보인다
- [ ] 별이 화면 왼쪽 · 오른쪽 끝에 가까워지면 그쪽 가장자리가 주황빛으로 물든다
- [ ] 「플레어 세기」 를 0 으로 내리면 별만 남는다

## 이 기술 정보
- id: `i111` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 그러데이션 10여 장을 더하기로 그린다. 켜 있는 동안만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i111
- 쓰면 좋을 때: 보상 · 별 획득처럼 「빛났다」 를 크게 보여 줄 때 / 해 · 조명이 화면 안에 보이는 장면
- 쓰지 말 때: 늘 켜 두는 배경 빛 — 금방 지겹고 화면을 가린다. 순간에만 / 어두운 판 위의 글씨 근처 — 고스트가 글씨를 덮는다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i111 견본 항목 — `src/demos/demosMotionB.ts:446`
```ts
  i111: {
    kind: '2d',
    caption: '별 빛에서 화면 가운데를 지나는 줄 위에 둥근 고스트 · 가로 빛줄 · 가장자리 주황 빛 새기 (켬 / 끔 번갈아)',
    make() {
      const R = rng(11);
      const STARS = Array.from({ length: 70 }, () => [R(), R(), R()] as const);
      let force: boolean | null = null;
      let strength = 1;
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          bg(g, w, h, '#0d1440', '#2a1450');
          for (const [x, y, s] of STARS) {
            g.fillStyle = `rgba(255,255,255,${0.3 + 0.5 * Math.abs(Math.sin(t * 1.5 + s * 20))})`;
            g.fillRect(x * w, y * h * 0.8, 1.4 * u * s + 0.4, 1.4 * u * s + 0.4);
          }
          // 언덕 · 홈런 공원 느낌
          g.fillStyle = '#132a3c';
          g.beginPath();
          g.moveTo(0, h);
          for (let x = 0; x <= w; x += 8) g.lineTo(x, h * 0.84 - Math.sin(x / w * 5 + 1) * 8 * u);
          g.lineTo(w, h);
          g.fill();
          const P = 8;
          const p = fract(t / P);
          const on = force ?? (p < 0.62);
          const a = TAU * p;
          const L = { x: w * (0.5 + 0.38 * Math.cos(a)), y: h * (0.42 + 0.26 * Math.sin(a * 2) * 0.6 - 0.12 * Math.sin(a)) };
          const C = { x: w / 2, y: h / 2 };
          // 별 (보상)
          g.save();
          g.translate(L.x, L.y);
          g.rotate(t * 0.8);
          star(g, 0, 0, 11 * u);
          g.fillStyle = '#ffe27a';
          g.fill();
          g.restore();
          if (on) {
            const k = strength;
            g.save();
            g.globalCompositeOperation = 'lighter';
            // 큰 빛무리
            let gr = g.createRadialGradient(L.x, L.y, 0, L.x, L.y, 60 * u);
            gr.addColorStop(0, `rgba(255,250,220,${0.9 * k})`);
            gr.addColorStop(0.25, `rgba(255,200,120,${0.35 * k})`);
            gr.addColorStop(1, 'rgba(255,120,60,0)');
            g.fillStyle = gr;
            g.fillRect(L.x - 60 * u, L.y - 60 * u, 120 * u, 120 * u);
            // 가로 빛줄 (아나모픽)
            gr = g.createLinearGradient(L.x - w * 0.6, 0, L.x + w * 0.6, 0);
            gr.addColorStop(0, 'rgba(120,180,255,0)');
            gr.addColorStop(0.5, `rgba(170,220,255,${0.75 * k})`);
            gr.addColorStop(1, 'rgba(120,180,255,0)');
            g.fillStyle = gr;
            g.fillRect(L.x - w * 0.6, L.y - 1.2 * u, w * 1.2, 2.4 * u);
            // 십자 빛살
            for (let i = 0; i < 4; i++) {
              g.save();
              g.translate(L.x, L.y);
              g.rotate((i * Math.PI) / 4 + t * 0.2);
              gr = g.createLinearGradient(-40 * u, 0, 40 * u, 0);
              gr.addColorStop(0, 'rgba(255,255,255,0)');
              gr.addColorStop(0.5, `rgba(255,255,240,${0.5 * k})`);
              gr.addColorStop(1, 'rgba(255,255,255,0)');
              g.fillStyle = gr;
              g.fillRect(-40 * u, -0.8 * u, 80 * u, 1.6 * u);
              g.restore();
            }
            // 고스트: L 에서 가운데를 지나 반대편으로
            const GH: [number, number, string, number][] = [
              [0.35, 7, '120,255,200', 0.35],
              [0.7, 14, '255,140,220', 0.22],
              [1.15, 5, '160,200,255', 0.5],
              [1.45, 24, '120,160,255', 0.14],
              [1.8, 10, '255,220,120', 0.3],
              [2.15, 34, '255,120,160', 0.1],
            ];
            for (const [s, r, c, al] of GH) {
              const x = L.x + (C.x - L.x) * s;
              const y = L.y + (C.y - L.y) * s;
              const rad = r * u;
              gr = g.createRadialGradient(x, y, rad * 0.2, x, y, rad);
              gr.addColorStop(0, `rgba(${c},${al * 0.4 * k})`);
              gr.addColorStop(0.85, `rgba(${c},${al * k})`);
              gr.addColorStop(1, `rgba(${c},0)`);
              g.fillStyle = gr;
              g.beginPath();
              if (r > 20) {
                for (let i = 0; i < 6; i++) {
                  const aa = (i * TAU) / 6 + 0.3;
                  g.lineTo(x + Math.cos(aa) * rad, y + Math.sin(aa) * rad);
                }
              } else g.arc(x, y, rad, 0, TAU);
              g.fill();
            }
            // 빛 새기: 별이 가까운 가장자리가 주황빛
            const edge = L.x < w / 2 ? 0 : w;
            const leak = (1 - Math.min(1, Math.abs(L.x - edge) / (w * 0.5))) * k;
            gr = g.createLinearGradient(edge, 0, w / 2, 0);
            gr.addColorStop(0, `rgba(255,130,40,${0.55 * leak})`);
            gr.addColorStop(0.5, `rgba(255,80,60,${0.15 * leak})`);
            gr.addColorStop(1, 'rgba(255,80,60,0)');
            g.fillStyle = gr;
            g.fillRect(0, 0, w, h);
            g.restore();
          }
          pill(g, on ? '플레어 켬' : '플레어 끔', 10 * u, 14 * u, 8.5 * u, on ? 'rgba(255,90,150,.9)' : 'rgba(10,14,40,.7)', '#fff', 'left');
        },
        controls: [
          { type: 'range', label: '플레어 세기', min: 0, max: 2, step: 0.05, value: 1, on: (v) => (strength = v) },
          { type: 'toggle', label: '늘 켜기', value: false, on: (v) => (force = v ? true : null) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [빛 번짐 (UnrealBloom)](https://ai-techstudio.web.app/ai/t/u20.md) `u20`
- 다음에 해 볼 기술: [글리치 · 주사선 · CRT](https://ai-techstudio.web.app/ai/t/i112.md) `i112` · [임팩트 프레임 (반전 번쩍)](https://ai-techstudio.web.app/ai/t/i200.md) `i200`
- 참고 문서: [three.js 예제 — webgl_lensflares](https://threejs.org/examples/#webgl_lensflares) · [Wikipedia — Lens flare](https://en.wikipedia.org/wiki/Lens_flare)
