# AI 꾸러미 — 2D 시차 층 · 무한 확대 — Parallax scrolling
> 먼 층은 느리게 · 가까운 층은 빠르게 옮기는 2D 카메라(시차)와, 확대 배율을 10 의 거듭제곱으로 돌려 수직선을 끝없이 확대하는 방법.  
> 견본: https://ai-techstudio.web.app/#t/i104

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

## 주문서

### 만들어 줘: 2D 시차 층 · 무한 확대 — Parallax scrolling

#### 1. 목표
수직선 무한 확대 (소수 자리)에 2D 카메라를 넣어 줘 — 배경 층은 시차로 다른 빠르기, 수직선은 0.1 → 0.01 → 0.001 로 끝없이 확대되게. 분위기는 밝은 낮 풍경 + 짙은 남색 수직선.

#### 2. 핵심 기술 용어
- **Parallax scrolling** — 층마다 다른 빠르기로 옮겨 깊이감
- **2D camera transform (pan / zoom)** — 그림 전체를 옮기고 키우는 2D 카메라
- **Infinite zoom (log-scale level of detail)** — 배율을 로그로 돌려 끝없이 확대 — 눈금 단계가 차례로 나타남

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

#### 4. 조건
- 층 빠르기는 멀수록 작게 — 가장 가까운 층이 기준(×1)
- 눈금은 화면에 보이는 것만 그리고, 3px 보다 작은 단계는 건너뛴다 (최대 400개)
- 단계마다 투명도 · 굵기를 칸 너비에 따라 서서히 — 갑자기 튀어나오지 않게
- 이름표 소수 자리 수 = 단계 n (toFixed(n)) — 0.1, 0.01, 0.001
- 확대 주기(견본 3.2초 × 3단계)를 마치면 처음과 같은 모습

#### 5. 완성 기준 (이게 보이면 성공)
- 위쪽에서 먼 산은 천천히, 언덕은 조금 빠르게, 나무는 가장 빠르게 지나간다
- 아래 수직선이 계속 확대되며 0.1 칸 사이에 0.01 칸이, 그 사이에 0.001 칸이 차례로 나타난다
- 한 바퀴가 끝나도 확대가 끊기지 않고 이어진다

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

## 원리
- 시차: 층마다 오프셋 = 시간(또는 카메라 x) × 빠르기. 견본은 먼 산 6 · 언덕 16 · 나무 40 (×0.15 · ×0.4 · ×1).
- 층은 주기 함수(사인)로 그려 오프셋이 아무리 커져도 이어진다. 나무는 간격 sp 마다 화면 안 것만 그린다.
- 무한 확대: 배율 z 를 0 → 3 으로 돌리고(10 의 z 제곱 배), 눈금 단계 n 마다 칸 너비 px = 길이 × 10^z × 10^(−n).
- 칸이 3px 보다 작은 단계는 그리지 않고, 커질수록 진하게 · 굵게 · 이름표까지 — 단계가 차례로 「피어난다」.
- 3단계를 다 돌면 처음과 같은 모습이 되므로(10배 자리 이동) 이음새 없이 반복된다.

## 핵심 코드 — 시차 층 + 끝없이 확대되는 수직선 눈금
(발췌: demos/demosMotionA.ts i104 의 draw 를 정리)
```ts
/** 시차 층 — speed 가 클수록 가깝다. f 는 주기 함수라 오프셋이 커져도 이어진다 */
function layer(speed: number, base: number, amp: number, per: number, col: string, f: (x: number) => number) {
  const off = tt * speed * u;
  g.fillStyle = col;
  g.beginPath(); g.moveTo(0, topH);
  for (let x = 0; x <= w + 4; x += 4) g.lineTo(x, topH * base - f((x + off) / (per * u)) * amp * u);
  g.lineTo(w, topH); g.fill();
}
layer(6, 0.72, 26, 60, '#9fb6e8', (x) => Math.abs(Math.sin(x)) * 0.8 + Math.sin(x * 2.3) * 0.15); // 먼 산
layer(16, 0.86, 12, 40, '#76c46e', (x) => Math.sin(x) * 0.6 + 0.5);                               // 언덕

/** 끝없이 확대 — z: 0 → 3 (10^z 배), 단계 n 의 칸 너비 px */
const z = (tt / 3.2) % 3;
const zl = Math.floor(z);
for (let n = zl; n <= zl + 3; n++) {
  const stepU = Math.pow(10, -n);
  const px = L * Math.pow(10, z) * stepU;
  if (px < 3) continue;                                    // 너무 촘촘한 단계는 건너뛴다
  const vis = Math.min(1, (px - 3) / 12);                  // 커질수록 진하게
  const big = Math.max(0, Math.min(1, Math.log10(px / 4) / 2));
  const th = (4 + 10 * big) * u * 0.6;
  const cnt = Math.min(400, Math.floor(L / px) + 1);
  g.strokeStyle = 'rgba(255,255,255,' + vis + ')';
  g.lineWidth = 1 + big;
  for (let i = 0; i <= cnt; i++) {
    if (i % 10 === 0 && n > zl) continue;                  // 윗단계 눈금과 겹치는 자리는 건너뛰기
    const x = x0 + i * px;
    if (x > w) break;
    g.beginPath(); g.moveTo(x, lineY - th); g.lineTo(x, lineY + th); g.stroke();
  }
  if (px > 22 * u && px < L * 1.05)                        // 칸이 넉넉한 단계만 이름표
    for (let i = 1; i <= Math.min(10, cnt); i++) g.fillText((i * stepU).toFixed(n), x0 + i * px, lineY + th + 3 * u);
}
```

## 흔한 실수 · 확인 목록
- [ ] **배율을 그대로 키우면 숫자가 금방 넘치고 눈금이 수백만 개가 된다** — 배율을 0 ~ 3 으로 돌리고 단계 n 을 함께 옮긴다. 눈금은 화면 안 · 최대 400개만.
- [ ] **새 단계 눈금이 갑자기 나타나면 깜박인다** — 칸 너비 px 로 투명도 · 굵기를 서서히 올린다 ((px − 3) / 12).
- [ ] **층을 이미지 한 장으로 옮기면 끝에서 이음새가 보인다** — 주기 함수로 그리거나, 같은 그림 두 장을 이어 붙여 mod 로 옮긴다.

## 완성 기준 체크리스트
- [ ] 위쪽에서 먼 산은 천천히, 언덕은 조금 빠르게, 나무는 가장 빠르게 지나간다
- [ ] 아래 수직선이 계속 확대되며 0.1 칸 사이에 0.01 칸이, 그 사이에 0.001 칸이 차례로 나타난다
- [ ] 한 바퀴가 끝나도 확대가 끊기지 않고 이어진다

## 이 기술 정보
- id: `i104` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 층마다 경로 하나 · 눈금 선 최대 400개. 화면 밖 칸은 건너뛴다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i104
- 쓰면 좋을 때: 옆으로 움직이는 장면에 깊이를 줄 때 / 수직선 · 지도처럼 「더 자세히 보면 또 칸이 있다」 를 보여 줄 때
- 쓰지 말 때: 3D 장면 — 원근 카메라가 저절로 시차를 만든다 / 확대해도 새 정보가 없는 그림 — 대신 켄 번스처럼 천천히 밀기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i104 — `src/demos/demosMotionA.ts:1627`
```ts
const i104 = {
  kind: '2d' as const,
  caption: '위: 먼 산 · 언덕 · 나무가 서로 다른 빠르기(시차) · 아래: 수직선을 끝없이 확대 0.1 → 0.01 → 0.001',
  make() {
    const o = { s: 1 };
    let tt = 0;
    return {
      controls: [speedCtl(o)],
      draw(g: G, w: number, h: number, _t: number, dt: number) {
        tt += Math.max(0, dt) * o.s;
        const topH = h * 0.56;
        // 위: 시차 (화면 전체 폭)
        const u = Math.min(w / VW, h / VH);
        const sky = g.createLinearGradient(0, 0, 0, topH);
        sky.addColorStop(0, '#7fd0ff');
        sky.addColorStop(1, '#ffe7c4');
        g.fillStyle = sky;
        g.fillRect(0, 0, w, topH);
        g.fillStyle = '#fff1a8';
        g.beginPath();
        g.arc(w * 0.78, topH * 0.3, 12 * u, 0, TAU);
        g.fill();
        for (let i = 0; i < 3; i++) cloud(g, mod(i * 130 * u - tt * 4 * u, w + 80 * u) - 40 * u, topH * (0.18 + i * 0.1), 0.6 * u, 0.85);
        const layer = (speed: number, base: number, amp: number, per: number, col: string, f: (x: number) => number): void => {
          const off = tt * speed * u;
          g.fillStyle = col;
          g.beginPath();
          g.moveTo(0, topH);
          for (let x = 0; x <= w + 4; x += 4) g.lineTo(x, topH * base - f((x + off) / (per * u)) * amp * u);
          g.lineTo(w, topH);
          g.fill();
        };
        layer(6, 0.72, 26, 60, '#9fb6e8', (x) => Math.abs(Math.sin(x)) * 0.8 + Math.sin(x * 2.3) * 0.15);
        layer(16, 0.86, 12, 40, '#76c46e', (x) => Math.sin(x) * 0.6 + 0.5);
        // 가까운 나무 (가장 빠름)
        const off = tt * 40 * u;
        const sp = 46 * u;
        g.fillStyle = '#4c9e45';
        g.fillRect(0, topH * 0.92, w, topH * 0.08 + 1);
        for (let k = Math.floor(off / sp) - 1; k * sp - off < w + sp; k++) {
          const x = k * sp - off + hsh(k) * 12 * u;
          const s = (0.8 + hsh(k + 3) * 0.4) * u;
          g.fillStyle = '#7a4f2a';
          g.fillRect(x - 2 * s, topH * 0.92 - 10 * s, 4 * s, 10 * s);
          g.fillStyle = '#2f8a3c';
          g.beginPath();
          g.arc(x, topH * 0.92 - 16 * s, 9 * s, 0, TAU);
          g.fill();
        }
        g.save();
        g.font = `700 ${7.5 * u}px ${F}`;
        g.textAlign = 'left';
        g.textBaseline = 'top';
        g.fillStyle = 'rgba(20,40,80,.65)';
        g.fillText('먼 산 ×0.15 · 언덕 ×0.4 · 나무 ×1', 6 * u, 5 * u);
        g.restore();
        // 아래: 수직선 무한 확대
        const by = topH;
        const bh = h - topH;
        const gr = g.createLinearGradient(0, by, 0, h);
        gr.addColorStop(0, '#20275e');
        gr.addColorStop(1, '#0c1030');
        g.fillStyle = gr;
        g.fillRect(0, by, w, bh);
        const TT = 3.2;
        const z = (tt / TT) % 3; // 0..3 십진 단계
        const x0 = 14 * u;
        const lineY = by + bh * 0.5;
        const L = w - 28 * u;
        const base = L; // z=0 일 때 1 이 L 픽셀
        g.strokeStyle = '#fff';
        g.lineWidth = 2;
        g.beginPath();
        g.moveTo(x0, lineY);
        g.lineTo(w - 6 * u, lineY);
        g.stroke();
        const zl = Math.floor(z);
        for (let n = zl; n <= zl + 3; n++) {
          const stepU = Math.pow(10, -n);
          const px = base * Math.pow(10, z) * stepU;
          if (px < 3) continue;
          const vis = clamp01((px - 3) / 12);
          const big = clamp01(Math.log10(px / 4) / 2);
          const th = (4 + 10 * big) * u * 0.6;
          const cnt = Math.min(400, Math.floor(L / px) + 1);
          g.strokeStyle = `rgba(255,255,255,${vis})`;
          g.lineWidth = 1 + big;
          for (let i = 0; i <= cnt; i++) {
            if (i % 10 === 0 && n > zl) continue;
            const x = x0 + i * px;
            if (x > w) break;
            g.beginPath();
            g.moveTo(x, lineY - th);
            g.lineTo(x, lineY + th);
            g.stroke();
          }
          // 이름표: 칸이 넉넉한 단계만
          if (px > 22 * u && px < L * 1.05) {
            g.font = `800 ${Math.min(10, 5.5 + px / (16 * u)) * u}px ${F}`;
            g.textAlign = 'center';
            g.textBaseline = 'top';
            for (let i = 1; i <= Math.min(10, cnt); i++) {
              const x = x0 + i * px;
              if (x > w - 8 * u) break;
              const a = clamp01((px - 22 * u) / (20 * u));
              g.fillStyle = `rgba(255,${i === 1 ? 209 : 255},${i === 1 ? 102 : 255},${a})`;
              g.fillText((i * stepU).toFixed(n), x, lineY + th + 3 * u);
            }
          }
        }
        g.font = `900 ${10 * u}px ${F}`;
        g.textAlign = 'center';
        g.textBaseline = 'top';
        g.fillStyle = '#ffd166';
        g.fillText('0', x0, lineY + 10 * u);
        const cur = Math.pow(10, -(zl + 1)).toFixed(zl + 1);
        g.save();
        g.font = `800 ${9 * u}px ${F}`;
        g.textAlign = 'right';
        g.textBaseline = 'top';
        g.fillStyle = '#9fe6ff';
        g.fillText(`🔍 ×10 확대 중 → ${cur}`, w - 8 * u, by + 5 * u);
        g.restore();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [마스크 와이프 · 원 조리개](https://ai-techstudio.web.app/ai/t/i99.md) `i99`
- 다음에 해 볼 기술: [휩 팬 · 줌 통과 · 매치 컷](https://ai-techstudio.web.app/ai/t/i106.md) `i106` · [비대칭 카메라 (off-axis 투영)](https://ai-techstudio.web.app/ai/t/u40.md) `u40`
- 참고 문서: [Wikipedia — Parallax scrolling](https://en.wikipedia.org/wiki/Parallax_scrolling)
