# AI 꾸러미 — 점 속으로 빨려 들기 · 렌즈 포털
> 장면이 한 점으로 모이거나 원 창으로 넘어감  
> 견본: https://ai-techstudio.web.app/#t/i126

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

## 주문서

### 만들어 줘: 점 속으로 빨려 들기 · 렌즈 포털

#### 1. 목표
무한에 「점 속으로 빨려 들기 · 렌즈 포털」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 장면이 한 점으로 모이거나 원 창으로 넘어감

#### 2. 핵심 기술 용어
- 점 속으로 빨려 들기 · 렌즈 포털 — 장면이 한 점으로 모이거나 원 창으로 넘어감

#### 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: `i126` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i126
- 쓰면 좋을 때: 쓰면 좋을 곳 — 무한 · 수직선 확대 · 다음 세계

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i126 견본 항목 — `src/demos/demosMotionB.ts:2400`
```ts
  i126: {
    kind: '2d',
    caption: '수직선의 1/3 자리로 끝없이 확대 — 원 창(포털)이 커지며 다음 세계로: 0.3 → 0.33 → 0.333 …',
    make() {
      const THEMES = [
        ['#1d2b6b', '#2f49b0'],
        ['#4a1f6b', '#8a3fb0'],
        ['#0f4d5c', '#1f8aa0'],
        ['#5c2a1f', '#b0563f'],
      ];
      let speed = 1;
      let ph = 0;
      return {
        draw(g, w, h, _t, dt) {
          const u = scaleOf(w, h);
          ph += (dt * speed) / 3.2;
          const lv = Math.floor(ph);
          const p = ph - lv;
          const level = lv % 5;
          const S = Math.pow(10, p);
          const x0 = w * 0.1;
          const L = w * 0.8;
          const Xs = x0 + L / 3;
          const Ly = h * 0.55;
          const theme = (k: number): string[] => THEMES[((k % 4) + 4) % 4]!;
          const paintBg = (k: number): void => {
            const [a, b] = theme(k);
            const gr = g.createRadialGradient(Xs, Ly, 0, Xs, Ly, Math.hypot(w, h));
            gr.addColorStop(0, b!);
            gr.addColorStop(1, a!);
            g.fillStyle = gr;
            g.fillRect(0, 0, w, h);
          };
          paintBg(lv);
          // 포털: 다음 세계가 원 창으로 커짐
          const D = Math.hypot(w, h);
          const r0 = 4 * u;
          const pr = r0 * Math.pow(D / r0, p);
          g.save();
          g.beginPath();
          g.arc(Xs, Ly, pr, 0, TAU);
          g.clip();
          paintBg(lv + 1);
          g.restore();
          g.strokeStyle = 'rgba(255,255,255,.75)';
          g.lineWidth = 2.5 * u;
          g.beginPath();
          g.arc(Xs, Ly, pr, 0, TAU);
          g.stroke();
          g.strokeStyle = 'rgba(255,230,140,.35)';
          g.lineWidth = 7 * u;
          g.stroke();
          // 수직선
          const X = (uu: number): number => Xs + (uu - 1 / 3) * L * S;
          g.strokeStyle = '#fff';
          g.lineWidth = 2.5 * u;
          g.beginPath();
          g.moveTo(0, Ly);
          g.lineTo(w, Ly);
          g.stroke();
          const labelA = lv % 5 === 4 && p > 0.85 ? 1 - (p - 0.85) / 0.15 : level === 0 && p < 0.15 && lv > 0 ? p / 0.15 : 1;
          const step = Math.pow(10, -level);
          const a0 = Math.floor(Math.pow(10, level) / 3) / Math.pow(10, level);
          const label = (v: number, dig: number): string => (dig === 0 ? String(Math.round(v)) : v.toFixed(dig));
          const tick = (uu: number, len: number, alpha: number, lab: string | null, size: number): void => {
            const x = X(uu);
            if (x < -40 || x > w + 40) return;
            g.globalAlpha = alpha;
            g.strokeStyle = '#fff';
            g.lineWidth = 2 * u;
            g.beginPath();
            g.moveTo(x, Ly - len);
            g.lineTo(x, Ly + len);
            g.stroke();
            if (lab) txt(g, lab, x, Ly + len + size * 0.9, size, '#fff', 'center', F, 800);
            g.globalAlpha = 1;
          };
          const busy = w < 500;
          for (let j = 0; j <= 10; j++) {
            const v = a0 + (j / 10) * step;
            const showLab = !busy || j % 5 === 0 || j === 3 || j === 4;
            const dig = level === 0 && (j === 0 || j === 10) ? 0 : level + 1;
            tick(j / 10, (j % 5 === 0 ? 9 : 6) * u, 1, showLab ? label(v, dig) : null, 8 * u * Math.min(1.3, 0.9 + p * 0.3));
          }
          const na = clamp01((S - 2) / 5);
          for (let j = 1; j < 10; j++) {
            const v = a0 + (0.3 + j / 100) * step;
            const showLab = !busy || j === 3 || j === 5;
            tick(0.3 + j / 100, 4 * u + na * 2 * u, na, showLab && na > 0.3 ? label(v, level + 2) : null, 7 * u);
          }
          g.globalAlpha = labelA;
          // 목표 점 1/3
          const pulse = 1 + 0.3 * Math.sin(ph * 9);
          g.fillStyle = '#ffd23f';
          g.beginPath();
          g.arc(Xs, Ly, 4 * u * pulse, 0, TAU);
          g.fill();
          g.globalAlpha = 1;
          const digits = '0.' + '3'.repeat(level + 1);
          pill(g, `1/3 = ${digits}…`, w / 2, 16 * u, 10 * u, 'rgba(10,14,40,.7)', '#ffe27a');
          pill(g, `×${Math.round(Math.pow(10, level) * S).toLocaleString()} 확대`, 10 * u, h - 13 * u, 7.5 * u, 'rgba(255,255,255,.18)', '#fff', 'left');
        },
        controls: [{ type: 'range', label: '빨려 드는 빠르기', min: 0.2, max: 3, step: 0.1, value: 1, on: (v) => (speed = v) }] as Control[],
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [마스크 와이프 · 원 조리개](https://ai-techstudio.web.app/ai/t/i99.md) `i99`
