# AI 꾸러미 — 카메라가 벽을 뚫지 않게 — Camera collision (sphere cast)
> 주인공에서 바라는 카메라 자리까지 작은 구를 쓸어 벽에 닿는 곳을 찾고, 그 앞까지 바로 당겼다가 벗어나면 천천히 되돌아가게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i481

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

## 주문서

### 만들어 줘: 카메라가 벽을 뚫지 않게 — Camera collision (sphere cast)

#### 1. 목표
쿼터뷰 · 3인칭 던전의 3인칭 카메라가 벽을 뚫지 않게 구 쓸기로 당겨 오고, 벗어나면 부드럽게 되돌아가게 해 줘. 분위기는 돌 던전 · 횃불.

#### 2. 핵심 기술 용어
- **Camera collision (sphere cast)** — 구를 쓸어 카메라가 벽 뒤로 가지 않게
- **Spring arm** — 주인공에 달린 늘어나고 줄어드는 카메라 팔
- **Asymmetric smoothing** — 당길 땐 빠르게(30) · 되돌릴 땐 천천히(2.2)

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 광선 하나가 아니라 구(반지름 0.25~0.3)로 쓸기 — 광선은 모서리 틈으로 빠져 벽이 화면에 걸린다
- 당길 때와 되돌릴 때 빠르기를 다르게 (당김은 즉시에 가깝게, 되돌림은 천천히)
- 최소 거리(0.55)를 둬 주인공 몸 속으로 들어가지 않게
- 켬/끔 나란히 비교 화면 + 카메라 거리 · 되돌아가는 빠르기 슬라이더

#### 5. 완성 기준 (이게 보이면 성공)
- 주인공이 벽 쪽으로 돌아서도 카메라가 벽 앞에 머물러 주인공이 늘 보인다 (끔 쪽은 벽 뒤로 가 가려진다)
- 벽에서 벗어나면 카메라가 덜컥 튀지 않고 부드럽게 원래 거리로 돌아간다
- 「되돌아가는 빠르기」를 바꾸면 돌아가는 빠르기만 바뀐다

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

## 원리
- 카메라는 주인공 등 뒤를 늦게 따라간다: 바라는 자리 = 주인공 − 바라보는 방향 × 거리(4.6), 높이 2.45.
- 주인공 가슴(1.35) → 바라는 자리를 22 조각으로 나눠 반지름 0.28 구가 벽과 겹치는지 차례로 본다.
- 처음 겹친 조각 앞뒤를 이분 탐색 6번으로 좁혀 「막히기 직전 거리」를 얻는다.
- 목표 거리 = max(0.55, 그 거리). 지금보다 짧아지면 빠르게(30), 길어지면 천천히(2.2) 따라간다 — 벽 뒤로는 절대 안 가고, 벗어날 땐 덜컥거리지 않는다.

## 핵심 코드 — 구 쓸기 + 이분 탐색 + 당김 · 되돌림 빠르기 다르게
(발췌: demos/demosGame3dA.ts CamDemo.sweep() · tick() 을 정리)
```ts
const damp = (a: number, b: number, k: number, dt: number): number => a + (b - a) * (1 - Math.exp(-k * dt));
const SR = 0.28;                          // 쓸어 보는 구 반지름

/** 주인공 → 바라는 카메라 자리를 구로 쓸어 처음 닿는 거리 */
function sweep(from: THREE.Vector3, to: THREE.Vector3): number {
  const len = from.distanceTo(to);
  const N = 22;
  let free = 0;
  for (let i = 1; i <= N; i++) {
    const k = i / N;
    if (col.sphereHit(lerp3(from, to, k), SR)) {
      let lo = free, hi = k;
      for (let j = 0; j < 6; j++) {        // 막힌 조각 앞뒤를 이분 탐색으로 좁힌다
        const m = (lo + hi) / 2;
        if (col.sphereHit(lerp3(from, to, m), SR)) hi = m; else lo = m;
      }
      return lo * len;
    }
    free = k;
  }
  return len;
}

// 매 장면
pivot.set(hero.x, 1.35, hero.z);
desired.set(hero.x - Math.sin(camYaw) * dist, 2.45, hero.z - Math.cos(camYaw) * dist);
const full = pivot.distanceTo(desired);
const tgt = Math.max(0.55, sweep(pivot, desired));
curD = tgt < curD ? damp(curD, tgt, 30, dt) : damp(curD, tgt, back, dt);   // 당김은 빠르게, 되돌림은 천천히(2.2)
curD = Math.min(curD, full);
camera.position.copy(pivot).lerp(desired, curD / full);
```

## 흔한 실수 · 확인 목록
- [ ] **광선 하나로만 검사하면 모서리 틈으로 빠져 벽 면이 화면에 걸린다** — 카메라 가까운 면(near)보다 조금 큰 구로 쓸어 본다.
- [ ] **당김 · 되돌림을 같은 빠르기로 하면 벽 뒤가 잠깐 보이거나 덜컥거린다** — 짧아질 땐 거의 즉시(30), 길어질 땐 천천히(2.2).
- [ ] **바라는 자리까지 너무 성기게 나누면 얇은 기둥을 건너뛴다** — 구 반지름보다 촘촘하게 조각을 나누고, 막힌 곳은 이분 탐색으로 다듬는다.

## 완성 기준 체크리스트
- [ ] 주인공이 벽 쪽으로 돌아서도 카메라가 벽 앞에 머물러 주인공이 늘 보인다 (끔 쪽은 벽 뒤로 가 가려진다)
- [ ] 벽에서 벗어나면 카메라가 덜컥 튀지 않고 부드럽게 원래 거리로 돌아간다
- [ ] 「되돌아가는 빠르기」를 바꾸면 돌아가는 빠르기만 바뀐다

## 이 기술 정보
- id: `i481` · 분류: 게임 시스템 · AI › 3D 게임 기본기 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 한 장면에 구 겹침 검사 최대 22 + 6 번 (근처 삼각형만). 1ms 미만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i481
- 쓰면 좋을 때: 벽 · 기둥이 많은 실내에서 3인칭으로 따라갈 때 / 카메라를 플레이어가 돌릴 수 있을 때
- 쓰지 말 때: 위에서 내려다보는 고정 쿼터뷰 — 카메라를 당기기보다 가리는 벽을 비우는 쪽(i488)이 자연스럽다 / 벽이 거의 없는 바깥 들판 — 필요 없다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### CamDemo — `src/demos/demosGame3dA.ts:1774`
```ts
class CamDemo extends Stage {
  private col: Col | null = null;
  private rig!: Rig;
  private pos = new THREE.Vector3(-6.1, 0, -4.1);
  private heading = Math.PI / 2;
  private camYaw = Math.PI / 2;
  private wp = 1;
  private pause = 0;
  private moving = 0;
  private dist = 4.6;
  private back = 2.2;
  private compare = true;
  private enabled = true;
  private curD = 4.6;
  private hitD = 4.6;
  private readonly sr = 0.28;
  private camOff = new THREE.PerspectiveCamera(55, 1, 0.1, 80);
  private camOn = new THREE.PerspectiveCamera(55, 1, 0.1, 80);
  private pivot = new THREE.Vector3();
  private desired = new THREE.Vector3();
  private offHidden = false;
  private mapAt = -1;
  private time = 0;
  private sp = v3();
  constructor() {
    super();
    this.camera.position.set(0, 16, 12);
    this.camera.lookAt(0, 0, 0);
    this.buildArena(
      (b) => {
        slab(b, -7.6, -5.6, 7.6, 5.6);
        wall(b, -7.6, -5.6, 7.6, -5, 2.8);
        wall(b, -7.6, 5, 7.6, 5.6, 2.8);
        wall(b, -7.6, -5, -7, 5, 2.8);
        wall(b, 7, -5, 7.6, 5, 2.8);
        wall(b, -1.5, 0.55, 1.5, 1.0, 2.4);
        pillar(b, -3, -1.5, 0.9, 2.7);
        pillar(b, 2.5, 1.9, 0.9, 2.7);
        pillar(b, 3.6, -3.4, 0.9, 2.7);
        pillar(b, -3.6, 2.6, 0.8, 2.7);
        b.box(4.6, 0, 3.7, 5.5, 0.8, 4.6, WOOD, { topColor: 0xa36d40 });
        b.box(-6.6, 0, -1.2, -5.8, 0.7, -0.4, WOOD, { topColor: 0xa36d40 });
        return () => b.tiles(-7, -5, 7, 5, STONE, () => false, 4);
      },
      (b) => {
        this.col = new Col(b.tris);
        this.footRects = b.foot.filter((f) => f.h > 0.5);
      },
    );
    this.phases.push(() => {
      this.addTorch(-3.5, 1.9, -4.88);
      this.addTorch(3.5, 1.9, -4.88);
      this.addTorch(-6.88, 1.9, 1.5);
      this.rig = makeKnight('knight', this.ink);
      this.root.add(this.rig.root);
      this.rig.root.position.copy(this.pos);
    });
    this.controls = [
      { type: 'toggle', label: '켬 · 끔 나란히 비교', value: true, on: (v) => (this.compare = v) },
      { type: 'toggle', label: '카메라 벽 충돌 (한 화면일 때)', value: true, on: (v) => (this.enabled = v) },
      { type: 'range', label: '카메라 거리', min: 2.5, max: 7, step: 0.1, value: 4.6, on: (v) => (this.dist = v) },
      { type: 'range', label: '되돌아가는 빠르기', min: 0.5, max: 8, step: 0.1, value: 2.2, on: (v) => (this.back = v) },
    ];
  }
  /** 주인공 → 바라는 카메라 자리를 구로 쓸어 처음 닿는 거리 */
  private sweep(from: THREE.Vector3, to: THREE.Vector3): number {
    const col = this.col!;
    const len = from.distanceTo(to);
    const N = 22;
    let free = 0;
    for (let i = 1; i <= N; i++) {
      const k = i / N;
      this.sp[0] = lerp(from.x, to.x, k);
      this.sp[1] = lerp(from.y, to.y, k);
      this.sp[2] = lerp(from.z, to.z, k);
      if (col.sphereHit(this.sp, this.sr)) {
        let lo = free;
        let hi = k;
        for (let j = 0; j < 6; j++) {
          const m = (lo + hi) / 2;
          this.sp[0] = lerp(from.x, to.x, m);
          this.sp[1] = lerp(from.y, to.y, m);
          this.sp[2] = lerp(from.z, to.z, m);
          if (col.sphereHit(this.sp, this.sr)) hi = m;
          else lo = m;
        }
        return lo * len;
      }
      free = k;
    }
    return len;
  }
  protected tick(t: number, dt: number): void {
    if (!this.col) return;
    this.time = t;
    // 주인공: 벽 가까이에서 출발해 방을 가로지르는 길 (돌아서면 카메라가 벽 쪽으로 감)
    const [tx, tz] = ROUTE_B[this.wp]!;
    const dx = tx - this.pos.x;
    const dz = tz - this.pos.z;
    const d = Math.hypot(dx, dz);
    if (this.pause > 0) {
      this.pause -= dt;
      this.moving = damp(this.moving, 0, 10, dt);
    } else if (d < 0.2) {
      this.wp = (this.wp + 1) % ROUTE_B.length;
      this.pause = 0.5;
    } else {
      const want = Math.atan2(dx, dz);
      this.heading += angWrap(want - this.heading) * (1 - Math.exp(-7 * dt));
      this.moving = damp(this.moving, 1, 6, dt);
      const sp = 2.5 * this.moving * clamp(1 - Math.abs(angWrap(want - this.heading)) / 1.6, 0.15, 1);
      this.pos.x += Math.sin(this.heading) * sp * dt;
      this.pos.z += Math.cos(this.heading) * sp * dt;
    }
    this.rig.root.position.copy(this.pos);
    this.rig.yaw.rotation.y = this.heading;
    animRig(this.rig, dt, 2.5 * this.moving, false, t);
    // 카메라: 등 뒤를 늦게 따라감
    this.camYaw += angWrap(this.heading - this.camYaw) * (1 - Math.exp(-1.8 * dt));
    this.pivot.set(this.pos.x, 1.35, this.pos.z);
    this.desired.set(this.pos.x - Math.sin(this.camYaw) * this.dist, 2.45, this.pos.z - Math.cos(this.camYaw) * this.dist);
    // 바라는 자리까지 구 쓸기
    this.hitD = this.sweep(this.pivot, this.desired);
    const full = this.pivot.distanceTo(this.desired);
    this.offHidden = this.hitD < full - 0.05;
    const tgt = Math.max(0.55, this.hitD);
    this.curD = tgt < this.curD ? damp(this.curD, tgt, 30, dt) : damp(this.curD, tgt, this.back, dt);
    this.curD = Math.min(this.curD, full);
    const look = new THREE.Vector3(this.pos.x + Math.sin(this.camYaw) * 2.2, 0.9, this.pos.z + Math.cos(this.camYaw) * 2.2);
    this.camOff.position.copy(this.desired);
    this.camOff.lookAt(look);
    this.camOn.position.copy(this.pivot).lerp(this.desired, this.curD / full);
    this.camOn.lookAt(look);
  }
  protected draw(r: R, w: number, h: number): void {
    r.autoClear = false;
    r.setScissorTest(true);
    r.setClearColor(this.bg, 1);
    const views: [THREE.PerspectiveCamera, number, number][] = this.compare
      ? [
          [this.camOff, 0, Math.floor(w / 2)],
          [this.camOn, Math.floor(w / 2), w - Math.floor(w / 2)],
        ]
      : [[this.enabled ? this.camOn : this.camOff, 0, w]];
    const au = r.shadowMap.autoUpdate;
    let first = true;
    for (const [cam, x, vw] of views) {
      r.shadowMap.autoUpdate = first;
      first = false;
      r.setViewport(x, 0, vw, h);
      r.setScissor(x, 0, vw, h);
      if (Math.abs(cam.aspect - vw / h) > 1e-3) {
        cam.aspect = vw / h;
        cam.updateProjectionMatrix();
      }
      r.clear();
      r.render(this.scene, cam);
    }
    r.shadowMap.autoUpdate = au;
    r.setScissorTest(false);
    r.setViewport(0, 0, w, h);
  }
  protected overlay(w: number, h: number): void {
    const u = this.hud.u;
    const hp = this.hud;
    const half = this.compare ? Math.floor(w / 2) : w;
    const lab = (id: string, x: number, on: boolean): void => {
      const p = hp.get(id);
      const pw = Math.min(half - 10 * u, 170 * u);
      const ph = 34 * u;
      p.place(x + 5 * u, 5 * u, pw, ph);
      const hidden = !on && this.offHidden;
      const pulled = on && this.curD < this.pivot.distanceTo(this.desired) - 0.1;
      p.paint(pw, ph, `${on}|${hidden}|${pulled}`, (g) => {
        pill(g, on ? '충돌 켬' : '충돌 끔', 0, 0, 10 * u, on ? 'rgba(40,120,70,0.92)' : 'rgba(150,50,40,0.92)', '#fff');
        const sub = on ? (pulled ? '구로 쓸어 벽 앞까지 당겨 옴' : '부딪힌 것 없음 — 제자리') : hidden ? '벽 뒤로 감 — 주인공이 가려짐!' : '그냥 정해진 거리';
        pill(g, sub, 0, 17 * u, 8.2 * u, 'rgba(18,11,8,0.8)', on ? '#c8f5cf' : hidden ? '#ffb3a6' : '#e8dccb');
      });
    };
    if (this.compare) {
      lab('l0', 0, false);
      lab('l1', half, true);
      const dv = hp.get('div');
      dv.place(half - 1.5 * u, 0, 3 * u, h);
      dv.paint(4, 4, 'div', (g) => {
        g.fillStyle = '#120c08';
        g.fillRect(0, 0, 4, 4);
      });
    } else lab('l0', 0, this.enabled);
    // 작은 지도 (위에서 본 방)
    const mh = Math.min(h * 0.4, (this.compare ? half : w) * 0.42 / 1.36);
    const mw = mh * 1.36;
    const mp = hp.get('map');
    mp.place(w - mw - 6 * u, h - mh - 6 * u, mw, mh);
    const bucket = Math.floor(this.time * 15);
    if (bucket !== this.mapAt) {
      this.mapAt = bucket;
      mp.paint(mw, mh, 'm' + bucket, (g, W, Hh) => this.drawMap(g, W, Hh));
    }
  }
  private drawMap(g: G, W: number, Hh: number): void {
    const s = W / 15.6;
    const X = (x: number): number => (x + 7.8) * s;
    const Z = (z: number): number => (z + 5.7) * s;
    rr(g, 0, 0, W, Hh, 6 * (W / 120));
    g.fillStyle = 'rgba(16,10,7,0.86)';
    g.fill();
    g.fillStyle = '#8a735c';
    g.fillRect(X(-7.6), Z(-5.6), 15.2 * s, 11.2 * s);
    g.fillStyle = '#3d2f25';
    g.fillRect(X(-7), Z(-5), 14 * s, 10 * s);
    g.fillStyle = '#b89c7c';
    for (const r of this.footRects) g.fillRect(X(r.x0), Z(r.z0), (r.x1 - r.x0) * s, (r.z1 - r.z0) * s);
    const px = X(this.pos.x);
    const pz = Z(this.pos.z);
    // 쓸기 선: 닿기 전 초록, 그 뒤 빨강
    const full = this.pivot.distanceTo(this.desired);
    const k = clamp(this.hitD / full, 0, 1);
    const dx = X(this.desired.x);
    const dz = Z(this.desired.z);
    const hx = lerp(px, dx, k);
    const hz = lerp(pz, dz, k);
    g.lineWidth = 2 * (W / 120);
    g.strokeStyle = '#6ee07a';
    g.beginPath();
    g.moveTo(px, pz);
    g.lineTo(hx, hz);
    g.stroke();
    if (k < 0.999) {
      g.setLineDash([3 * (W / 120), 3 * (W / 120)]);
      g.strokeStyle = '#ff6a5a';
      g.beginPath();
      g.moveTo(hx, hz);
      g.lineTo(dx, dz);
      g.stroke();
      g.setLineDash([]);
      g.strokeStyle = 'rgba(255,210,90,0.9)';
      g.beginPath();
      g.arc(hx, hz, this.sr * s, 0, TAU);
      g.stroke();
    }
    // 끈 카메라 (빨강 ×), 켠 카메라 (초록 점)
    const cr = 3.2 * (W / 120);
    g.strokeStyle = '#ff6a5a';
    g.lineWidth = 2 * (W / 120);
    g.beginPath();
    g.moveTo(dx - cr, dz - cr);
    g.lineTo(dx + cr, dz + cr);
    g.moveTo(dx + cr, dz - cr);
    g.lineTo(dx - cr, dz + cr);
    g.stroke();
    const ox = X(this.camOn.position.x);
    const oz = Z(this.camOn.position.z);
    g.fillStyle = '#6ee07a';
    g.beginPath();
    g.arc(ox, oz, cr * 0.9, 0, TAU);
    g.fill();
    // 주인공 + 바라보는 쪽
    g.fillStyle = '#5aa2ff';
    g.beginPath();
    g.arc(px, pz, 3.6 * (W / 120), 0, TAU);
    g.fill();
    g.strokeStyle = '#fff';
    g.lineWidth = 1.2 * (W / 120);
    g.stroke();
    g.beginPath();
    g.moveTo(px, pz);
    g.lineTo(px + Math.sin(this.heading) * 7 * (W / 120), pz + Math.cos(this.heading) * 7 * (W / 120));
    g.stroke();
    g.font = `700 ${8 * (W / 120)}px ${FONT}`;
    g.fillStyle = '#f3dcc0';
    g.textBaseline = 'top';
    g.fillText('위에서 본 지도', 4 * (W / 120), 3 * (W / 120));
  }
  private footRects: Rect[] = [];
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [캐릭터 조작기 (경사 · 계단 · 점프 · 벽 미끄러지기)](https://ai-techstudio.web.app/ai/t/i480.md) `i480`
- 다음에 해 볼 기술: [가리는 벽 투명 처리](https://ai-techstudio.web.app/ai/t/i488.md) `i488`
