# AI 꾸러미 — 시선 따라가기 (머리 · 눈 돌리기 제한) — Look-at with constraints (head · eye tracking)
> 목표를 볼 때 눈이 먼저 빠르게 돌고 머리 · 목은 각도 제한 안에서 천천히 뒤따르며, 크게 돌 때 깜빡여 캐릭터가 살아 있어 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i461

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

## 주문서

### 만들어 줘: 시선 따라가기 (머리 · 눈 돌리기 제한) — Look-at with constraints (head · eye tracking)

#### 1. 목표
나비를 보는 점토 곰이 움직이는 목표를 바라보게 해 줘 — 눈이 먼저 · 머리가 뒤따라, 목은 각도 제한, 사이사이 깜빡임. 분위기는 호기심 많은 귀여운.

#### 2. 핵심 기술 용어
- **Look-at with constraints (head · eye tracking)** — 각도 제한이 있는 시선 따라가기
- **Eye-head coordination** — 눈이 먼저 · 머리가 뒤따라
- **Exponential damping (1 − e^(−dt/τ))** — 프레임 속도와 무관한 부드러운 따라가기
- **Procedural blinking** — 무작위 간격 깜빡임

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

#### 4. 조건
- 목 돌림은 각도 제한 안에서만, 넘친 만큼은 가슴이 조금 거든다
- 눈 → 머리 순서: 눈 시간 상수 0.045초, 머리 0.32초 (끄면 한 덩어리로 비교)
- 따라가기는 1 − exp(−dt/τ) 로 — 프레임 속도가 달라도 같은 빠르기
- 돌림을 가슴 · 목 · 머리에 나눠 준다
- 깜빡임은 무작위 간격 + 머리를 크게 돌 때

#### 5. 완성 기준 (이게 보이면 성공)
- 나비가 날면 눈동자가 먼저 휙 따라가고 머리가 늦게 따라 돈다
- 나비가 옆으로 많이 가면 목은 제한(부채꼴 표시)에서 멈추고 가슴이 조금 더 돈다
- 「눈 먼저 · 머리 뒤따라」를 끄면 머리와 눈이 한 덩어리로 돌아 어색해진다
- 사이사이 깜빡이고, 「목 돌림 제한」 슬라이더로 제한 각이 바뀐다

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

## 원리
- 머리 위치에서 목표까지 방향의 좌우각(yaw) · 위아래각(pitch)을 atan2 로 구한다.
- 목은 제한 각(견본 50°) 안으로 자르고, 넘친 만큼은 가슴이 조금(× 0.5, 최대 0.35) 더 돌아 준다.
- 머리 각은 느리게(τ 0.32초), 눈은 머리 기준으로 남은 각을 빠르게(τ 0.045초) 따라가며 눈도 ±0.6 · ±0.45 로 제한한다.
- 돌림은 가슴 · 목 · 머리에 나눠 준다 (목 0.35 · 머리 0.5) — 한 뼈만 돌리면 목이 부러진 듯하다.
- 깜빡임은 1.8 ~ 4.4초 무작위 간격, 0.07초 감고 0.1초 뜬다. 머리를 크게 돌기 시작할 때도 깜빡인다.

## 핵심 코드 — 목 제한 + 눈 먼저 · 머리 뒤따라 + 깜빡임
(발췌: demos/demosRigA.ts i461 update() 를 정리)
```ts
const damp = (dt: number, tau: number) => 1 - Math.exp(-dt / tau);
const clamp = THREE.MathUtils.clamp;

// 머리에서 목표까지 각
const to = new THREE.Vector3().subVectors(target, headPos);
const yaw = Math.atan2(to.x, to.z);
const pitch = Math.atan2(to.y, Math.hypot(to.x, to.z));
const over = Math.max(0, Math.abs(yaw) - lim) * Math.sign(yaw); // 목 제한을 넘친 만큼
const hyW = clamp(yaw, -lim, lim), hpW = clamp(pitch, -lim * 0.5, lim * 0.5);
hy += (hyW - hy) * damp(dt, 0.32);                              // 머리는 천천히
hp += (hpW - hp) * damp(dt, 0.32);
const chestY = clamp(over * 0.5, -0.35, 0.35);
bone('chest').quaternion.copy(qY(chestY * 0.6 + hy * 0.12));
bone('neck').quaternion.copy(qY(hy * 0.35).multiply(qX(-hp * 0.35)));
bone('head').quaternion.copy(qY(hy * 0.5).multiply(qX(-hp * 0.6)).multiply(qZ(-hy * 0.06)));
character.updateMatrixWorld(true);

// 눈: 머리 기준으로 남은 각을 빠르게, 눈도 제한
const local = new THREE.Vector3().subVectors(target, eyeWorldPos).applyQuaternion(socket.getWorldQuaternion(q).invert());
ey += (clamp(Math.atan2(local.x, local.z), -0.6, 0.6) - ey) * damp(dt, 0.045);
ep += (clamp(Math.atan2(local.y, Math.hypot(local.x, local.z)), -0.45, 0.45) - ep) * damp(dt, 0.045);
eye.rotation.set(-ep, ey, 0, 'YXZ');

// 깜빡임: 1.8 ~ 4.4초마다, 0.07초 감고 0.1초 뜨기
nextBlink -= dt;
if (nextBlink <= 0 && blinkT < 0) blinkT = 0;
let close = 0;
if (blinkT >= 0) {
  blinkT += dt;
  close = blinkT < 0.07 ? blinkT / 0.07 : 1 - (blinkT - 0.07) / 0.1;
  if (blinkT > 0.17) { blinkT = -1; close = 0; nextBlink = 1.8 + Math.random() * 2.6; }
}
socket.scale.set(1, 1 - 0.9 * clamp(close, 0, 1), 1);
```

## 흔한 실수 · 확인 목록
- [ ] **lookAt 로 머리를 목표에 바로 돌리면 목이 꺾이고 등 뒤까지 돈다** — 각을 직접 계산해 제한 각 안으로 자르고, 넘친 만큼은 가슴이 조금 거든다.
- [ ] **머리와 눈이 같은 빠르기로 돌면 로봇 같다** — 눈이 먼저(τ 0.045초), 머리가 뒤따라(τ 0.32초). 사람도 그렇게 본다.
- [ ] **lerp(값, 목표, 0.1) 로 따라가면 프레임 속도에 따라 빠르기가 바뀐다** — 1 − exp(−dt/τ) 를 비율로 쓴다.

## 완성 기준 체크리스트
- [ ] 나비가 날면 눈동자가 먼저 휙 따라가고 머리가 늦게 따라 돈다
- [ ] 나비가 옆으로 많이 가면 목은 제한(부채꼴 표시)에서 멈추고 가슴이 조금 더 돈다
- [ ] 「눈 먼저 · 머리 뒤따라」를 끄면 머리와 눈이 한 덩어리로 돌아 어색해진다
- [ ] 사이사이 깜빡이고, 「목 돌림 제한」 슬라이더로 제한 각이 바뀐다

## 이 기술 정보
- id: `i461` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 뼈 몇 개 회전 계산뿐 — 캐릭터 수십 명도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i461
- 쓰면 좋을 때: 마스코트 · NPC 가 플레이어나 물건을 쳐다봐야 할 때 / 가만히 서 있는 캐릭터에 생기를 줄 때
- 쓰지 말 때: 목표가 등 뒤로 자주 가는 장면 — 제한 때문에 못 따라간다. 몸 전체를 돌리는 동작과 함께 쓴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i461 견본 항목 — `src/demos/demosRigA.ts:2014`
```ts
  i461: {
    kind: '3d',
    caption: '눈이 먼저 나비를 보고 머리가 뒤따라 돌아요 — 목은 각도 제한 안에서만, 사이사이 깜빡',
    make() {
      const st = makeStage([0.3, 2.45, 4.4], [0, 1.12, 0.2], 34, 1.9);
      let cc: Char | null = null;
      const loader = makeLoader(st.scene, [0, 1.1, 0]);
      const bt = blobTex();
      const sh = blob(bt, 1.2);
      st.scene.add(sh);
      // 나비
      const wc = document.createElement('canvas');
      wc.width = wc.height = 128;
      const wg = wc.getContext('2d')!;
      const wgr = wg.createRadialGradient(20, 64, 4, 30, 64, 110);
      wgr.addColorStop(0, '#fff4c2');
      wgr.addColorStop(0.45, '#ff9f43');
      wgr.addColorStop(0.85, '#e8505b');
      wgr.addColorStop(1, '#5a1e3a');
      wg.fillStyle = wgr;
      wg.beginPath();
      wg.moveTo(8, 64);
      wg.bezierCurveTo(30, -10, 130, -6, 118, 50);
      wg.bezierCurveTo(112, 70, 70, 66, 8, 64);
      wg.bezierCurveTo(60, 72, 108, 84, 96, 112);
      wg.bezierCurveTo(80, 132, 24, 110, 8, 64);
      wg.fill();
      wg.strokeStyle = 'rgba(60,20,40,0.85)';
      wg.lineWidth = 4;
      wg.stroke();
      wg.fillStyle = 'rgba(255,255,255,0.9)';
      [
        [92, 30, 6],
        [104, 44, 4],
        [80, 98, 5],
      ].forEach(([x, y, r]) => {
        wg.beginPath();
        wg.arc(x!, y!, r!, 0, Math.PI * 2);
        wg.fill();
      });
      const wtex = new THREE.CanvasTexture(wc);
      wtex.colorSpace = THREE.SRGBColorSpace;
      const gWing = new THREE.PlaneGeometry(0.28, 0.28);
      gWing.translate(0.14, 0, 0);
      gWing.rotateX(-Math.PI / 2);
      const mWing = new THREE.MeshStandardMaterial({ map: wtex, transparent: true, alphaTest: 0.4, side: THREE.DoubleSide, roughness: 0.6, emissive: 0x442211, emissiveIntensity: 0.4 });
      const fly = new THREE.Group();
      const wl = new THREE.Mesh(gWing, mWing);
      const wr = new THREE.Mesh(gWing, mWing);
      wr.scale.x = -1;
      const gBody = new THREE.CapsuleGeometry(0.014, 0.09, 4, 8);
      gBody.rotateX(Math.PI / 2);
      const mBody = new THREE.MeshStandardMaterial({ color: 0x2a1a22, roughness: 0.6 });
      const body = new THREE.Mesh(gBody, mBody);
      wl.castShadow = wr.castShadow = true;
      fly.add(wl, wr, body);
      st.scene.add(fly);
      const fsh = blob(bt, 0.3);
      st.scene.add(fsh);
      // 목 제한 부채꼴 (머리 높이)
      const mFan = new THREE.MeshBasicMaterial({ color: 0x8fd8ff, transparent: true, opacity: 0.2, side: THREE.DoubleSide, depthWrite: false });
      let gFan = new THREE.CircleGeometry(1.25, 48, 0, 1);
      const fan = new THREE.Mesh(gFan, mFan);
      fan.rotation.x = -Math.PI / 2;
      fan.position.set(0, 0.012, 0);
      fan.renderOrder = 3;
      st.scene.add(fan);
      const setFan = (lim: number): void => {
        gFan.dispose();
        gFan = new THREE.CircleGeometry(1.25, 48, -Math.PI / 2 - lim, lim * 2);
        fan.geometry = gFan;
      };
      // 시선 선
      const gCyl = new THREE.CylinderGeometry(1, 1, 1, 6);
      gCyl.translate(0, 0.5, 0);
      const mLine = new THREE.MeshBasicMaterial({ color: 0xffe58a, transparent: true, opacity: 0.45, depthTest: false });
      const line = new THREE.Mesh(gCyl, mLine);
      line.renderOrder = 25;
      st.scene.add(line);
      const ptr = new BigPointer();
      let lim = (50 * Math.PI) / 180;
      setFan(lim);
      let eyesFirst = true;
      let blinkOn = true;
      let showLine = true;
      const T = new THREE.Vector3(0.6, 1.7, 1);
      const want = new THREE.Vector3();
      const prevT = new THREE.Vector3().copy(T);
      let hy = 0;
      let hp = 0; // 머리
      let ey = 0;
      let ep = 0; // 눈
      let nextBlink = 1.5;
      let blinkT = -1;
      let lastYaw = 0;
      const eyeW = new THREE.Vector3();
      const tmpQ = new THREE.Quaternion();
      return {
        scene: st.scene,
        camera: st.cam,
        update(t, dt) {
          if (!cc) {
            if (!bearReady() || !createSlot()) {
              loader.set(PROG, t);
              return;
            }
            cc = makeChar('smooth');
            st.later([cc.group]);
            st.onShown.push(() => loader.done());
          }
          const c = cc;
          dt = Math.min(dt, 0.05);
          // 나비 길: 앞을 크게 돌다가 가끔 옆 · 뒤로 (목 제한 밖)
          if (ptr.live() && ptr.hit(st.cam, new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0.9), want)) {
            want.y = clamp(want.y, 0.3, 2.6);
          } else {
            const a = t * 0.55;
            const wide = 1 + 0.8 * sstep(0.75, 0.98, Math.sin(t * 0.37 + 2.2));
            want.set((0.95 * Math.sin(a) + 0.2 * Math.sin(a * 3.1)) * wide, 1.7 + 0.45 * Math.sin(a * 1.7 + 0.5) + 0.08 * Math.sin(t * 5), 1.3 + 0.4 * Math.cos(a * 0.8) - 0.55 * (wide - 1));
          }
          T.lerp(want, damp(dt, 0.25));
          fly.position.copy(T);
          const vel = _vd.subVectors(T, prevT);
          if (vel.lengthSq() > 1e-8) fly.rotation.y = Math.atan2(vel.x, vel.z);
          prevT.copy(T);
          const flap = Math.sin(t * 22) * 0.9 + 0.2;
          wl.rotation.z = flap;
          wr.rotation.z = -flap;
          fsh.position.set(T.x, 0.004, T.z);
          // 몸 기준 방향
          c.rest();
          c.b('hips').position.y = 0.8 + 0.006 * Math.sin(t * 2.2);
          c.group.updateMatrixWorld(true);
          const H = c.b('head').getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0, 0.32, 0.1));
          const to = new THREE.Vector3().subVectors(T, H);
          const yaw = Math.atan2(to.x, to.z);
          const pitch = Math.atan2(to.y, Math.hypot(to.x, to.z));
          // 목 제한 넘으면 가슴이 조금 더 돌아 줌
          const over = Math.max(0, Math.abs(yaw) - lim) * Math.sign(yaw);
          const hyW = clamp(yaw, -lim, lim);
          const hpW = clamp(pitch, -lim * 0.5, lim * 0.5);
          if (eyesFirst) {
            hy += (hyW - hy) * damp(dt, 0.32);
            hp += (hpW - hp) * damp(dt, 0.32);
          } else {
            hy += (hyW - hy) * damp(dt, 0.05);
            hp += (hpW - hp) * damp(dt, 0.05);
          }
          // 머리를 크게 돌리기 시작하면 깜빡 (사람도 그래요)
          if (blinkOn && Math.abs(hyW - lastYaw) > 0.9 && blinkT < 0) blinkT = 0;
          lastYaw = lastYaw + (hyW - lastYaw) * damp(dt, 0.6);
          const chestY = clamp(over * 0.5, -0.35, 0.35);
          c.b('chest').quaternion.copy(qY(chestY * 0.6 + hy * 0.12));
          c.b('neck').quaternion.copy(qY(hy * 0.35).multiply(qX(-hp * 0.35)));
          c.b('head').quaternion.copy(qY(hy * 0.5).multiply(qX(-hp * 0.6)).multiply(qZ(-hy * 0.06)));
          c.group.updateMatrixWorld(true);
          // 눈: 머리 기준 남은 각도 (빠르게), 눈 돌림도 제한
          c.eyes.forEach((e, i) => {
            const sock = c.sockets[i]!;
            sock.getWorldPosition(eyeW);
            const local = new THREE.Vector3().subVectors(T, eyeW).applyQuaternion(sock.getWorldQuaternion(tmpQ).invert());
            const yE = clamp(Math.atan2(local.x, local.z), -0.6, 0.6);
            const pE = clamp(Math.atan2(local.y, Math.hypot(local.x, local.z)), -0.45, 0.45);
            if (i === 0) {
              ey += ((eyesFirst ? yE : 0) - ey) * damp(dt, 0.045);
              ep += ((eyesFirst ? pE : 0) - ep) * damp(dt, 0.045);
            }
            e.rotation.set(-ep, ey + (i === 0 ? -0.03 : 0.03), 0, 'YXZ');
          });
          // 깜빡임
          nextBlink -= dt;
          if (blinkOn && nextBlink <= 0 && blinkT < 0) blinkT = 0;
          let close = 0;
          if (blinkT >= 0) {
            blinkT += dt;
            close = blinkT < 0.07 ? blinkT / 0.07 : 1 - (blinkT - 0.07) / 0.1;
            if (blinkT > 0.17) {
              blinkT = -1;
              close = 0;
              nextBlink = 1.8 + Math.random() * 2.6;
            }
          }
          c.sockets.forEach((s) => s.scale.set(1, 1 - 0.9 * clamp(close, 0, 1), 1));
          line.visible = showLine;
          if (showLine) {
            const mid = new THREE.Vector3();
            c.sockets[0]!.getWorldPosition(mid);
            c.sockets[1]!.getWorldPosition(eyeW);
            mid.add(eyeW).multiplyScalar(0.5);
            const d = _vd.subVectors(T, mid);
            line.position.copy(mid);
            line.quaternion.setFromUnitVectors(Y, d.clone().normalize());
            line.scale.set(0.005, d.length(), 0.005);
          }
          fan.rotation.z = chestY * 0.6;
        },
        render(r, w, h) {
          ptr.attach(w, h);
          stageRender(st, r);
        },
        controls: [
          {
            type: 'range',
            label: '목 돌림 제한 (°)',
            min: 15,
            max: 110,
            step: 5,
            value: 50,
            on: (v) => {
              lim = (v * Math.PI) / 180;
              setFan(lim);
            },
          },
          { type: 'toggle', label: '눈 먼저 · 머리 뒤따라 (끄면 한 덩어리로)', value: true, on: (v) => (eyesFirst = v) },
          { type: 'toggle', label: '깜빡임', value: true, on: (v) => (blinkOn = v) },
          { type: 'toggle', label: '시선 선 · 제한 부채꼴 보기', value: true, on: (v) => ((showLine = v), (fan.visible = v)) },
        ] as Control[],
        dispose() {
          ptr.dispose();
          cc?.dispose();
          loader.dispose();
          disposeMesh(sh);
          (fsh.geometry as THREE.BufferGeometry).dispose();
          (fsh.material as THREE.Material).dispose();
          bt.dispose();
          wtex.dispose();
          gWing.dispose();
          mWing.dispose();
          gBody.dispose();
          mBody.dispose();
          gFan.dispose();
          mFan.dispose();
          gCyl.dispose();
          mLine.dispose();
          st.own.forEach((o) => o.dispose());
        },
      } satisfies Scene3D;
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456`
- 다음에 해 볼 기술: [표정 모프 (눈 감기 · 웃음 · 놀람)](https://ai-techstudio.web.app/ai/t/i463.md) `i463` · [흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임)](https://ai-techstudio.web.app/ai/t/i462.md) `i462`
