# AI 꾸러미 — 발 땅에 붙이기 (울퉁불퉁한 지형) — Foot IK / foot placement on uneven terrain
> 발 아래로 광선을 쏴 땅 높이를 재고 다리 IK 와 골반 높이를 맞춰, 계단 · 언덕에서도 발이 뜨거나 묻히지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i465

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

## 주문서

### 만들어 줘: 발 땅에 붙이기 (울퉁불퉁한 지형) — Foot IK / foot placement on uneven terrain

#### 1. 목표
언덕 · 계단을 걷는 토끼가 울퉁불퉁한 땅을 걸을 때 발을 땅에 붙여 줘 — 발 아래 광선으로 높이를 재서 다리 IK · 골반 높이 보정, 경사엔 발바닥도 기울게. 분위기는 귀엽고 아기자기.

#### 2. 핵심 기술 용어
- **Foot IK / foot placement on uneven terrain** — 울퉁불퉁한 땅에 발 붙이기
- **Downward raycast (Raycaster)** — 발 아래로 광선 쏴 땅 높이 · 기울기 재기
- **Pelvis height adjustment** — 다리가 닿도록 골반 낮추기
- **Foot alignment to ground normal** — 발바닥을 경사에 맞춰 기울이기

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

#### 4. 조건
- 광선은 발 자리 위(높은 곳)에서 아래로 — 땅 메시 전체가 아니라 그 x 의 조각만 검사
- 골반은 낮은 발에도 다리가 닿도록 낮춘다 (안 그러면 다리가 다 펴지고 발이 뜬다)
- 골반 높이는 부드럽게 따라가되 큰 차이(0.4)는 바로 맞춘다
- 발바닥 기울기는 법선에서, 너무 가파른 면(법선 y < 0.5)은 무시
- IK 켬/끔 두 캐릭터를 나란히, 광선 · 틈을 선으로 보여 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽(IK 켬) 토끼는 잔물결 · 언덕 · 계단 오르내리기에서 발이 늘 땅에 닿는다
- 오른쪽(IK 끔)은 발이 뜨거나 땅에 묻히고, 빨간 막대가 뜬 틈을 보여 준다
- 발 아래 하늘색 광선과 맞은 점이 보인다
- 경사에서 발바닥이 땅 기울기에 맞춰 기운다

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

## 원리
- 걸음마다 디딜 자리(앞 · 뒤 발자국 x)를 정하고, 그 자리 위에서 아래로 광선을 쏴 땅 높이를 잰다.
- 디딤 구간엔 발을 그 높이에, 드는 구간엔 두 디딤 높이 중 높은 쪽 + 0.02 위로 넘어가게 곡선을 그린다.
- 골반은 「두 발 중 더 낮은 쪽에도 다리가 닿는 높이」까지만: min(골반, 발 높이 + √(다리 길이² − 앞뒤 거리²)). 부드럽게 따라가되 0.4 넘게 차이 나면 바로.
- 다리는 코사인 법칙 두 뼈 IK(평면)로, 발바닥은 맞은 면 법선으로 기울기(±0.45 rad)를 구해 맞춘다.
- IK 끈 토끼와 나란히 걸어 발이 뜬 틈을 빨간 막대로 보여 준다.

## 핵심 코드 — 발 아래 광선 → 디딤 높이 · 골반 보정 → 평면 두 뼈 IK
(발췌: demos/demosRigB.ts cast() · walk() · legIK() 를 정리)
```ts
const rc = new THREE.Raycaster(), down = new THREE.Vector3(0, -1, 0), org = new THREE.Vector3(), nrm = new THREE.Vector3();
const cast = (x: number): number => {
  rc.set(org.set(x, 5, 0), down);
  const hit = rc.intersectObject(chunkAt(x), false)[0];   // 그 x 의 땅 조각만
  if (!hit) return terrH(x);
  nrm.copy(hit.face!.normal);
  return hit.point.y;
};

// 발 하나: p = 걸음 진행(0~1), Xn · Xn1 = 지금 · 다음 발자국 자리
const gP = cast(Xn), gN = cast(Xn1);
let fx: number, fy: number;
if (p < 0.5) { fx = Xn; fy = gP; }                          // 디딤
else {
  const q = (p - 0.5) / 0.5, hi = Math.max(gP, gN) + 0.02; // 들어서 넘어가기
  fx = lerp(Xn, Xn1, sstep(q));
  fy = (q < 0.5 ? lerp(gP, hi, sstep(q * 2)) : lerp(hi, gN, sstep((q - 0.5) * 2))) + 0.09 * Math.sin(Math.PI * q);
}
cast(fx);
const slope = nrm.y > 0.5 ? clamp(Math.atan2(-nrm.x, nrm.y), -0.45, 0.45) : 0;
const ty = fy + 0.1; // 발목 높이
// 골반: 낮은 발에도 닿게
hips = Math.min(hips, ty + Math.sqrt(Math.max(0, (0.37 * 0.98) ** 2 - (fx - bodyX) ** 2)) + 0.08);

// 평면 두 뼈 IK (코사인 법칙)
function legIK(hipY: number, dz: number, ty: number, slope: number, L1: number, L2: number) {
  const vy = ty - hipY, D = Math.hypot(vy, dz);
  const Dc = clamp(D, 0.05, (L1 + L2) * 0.999);
  const phi = Math.atan2(dz, -vy);
  const al = Math.acos(clamp((L1 * L1 + Dc * Dc - L2 * L2) / (2 * L1 * Dc), -1, 1));
  const ga = Math.acos(clamp((L1 * L1 + L2 * L2 - Dc * Dc) / (2 * L1 * L2), -1, 1));
  const th = -(phi + al), sh = Math.PI - ga;
  thigh.rotation.set(th, 0, 0); shin.rotation.set(sh, 0, 0);
  foot.rotation.set(-slope - th - sh, 0, 0);                // 발바닥 = 땅 기울기
  return D - Dc;                                            // 못 닿은 길이
}
```

## 흔한 실수 · 확인 목록
- [ ] **골반을 그대로 두면 낮은 쪽 발이 닿지 못하고 공중에 뜬다** — 두 발 중 낮은 쪽에도 다리가 닿는 높이로 골반을 낮춘다.
- [ ] **광선을 땅 전체에 쏘면 지형이 커질수록 느려진다** — 땅을 1칸 조각으로 나눠 그 x 의 조각만 검사한다.
- [ ] **계단 모서리 · 벽 같은 가파른 면 법선으로 발을 기울이면 발이 세워진다** — 법선 y 가 0.5 보다 작으면 기울기를 0 으로, 그 밖엔 ±0.45 rad 로 자른다.
- [ ] **드는 발이 계단 모서리를 뚫고 지나간다** — 드는 구간 최고점을 두 디딤 높이 중 높은 쪽 + 여유로 잡는다.

## 완성 기준 체크리스트
- [ ] 왼쪽(IK 켬) 토끼는 잔물결 · 언덕 · 계단 오르내리기에서 발이 늘 땅에 닿는다
- [ ] 오른쪽(IK 끔)은 발이 뜨거나 땅에 묻히고, 빨간 막대가 뜬 틈을 보여 준다
- [ ] 발 아래 하늘색 광선과 맞은 점이 보인다
- [ ] 경사에서 발바닥이 땅 기울기에 맞춰 기운다

## 이 기술 정보
- id: `i465` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 어려움 · 폰 부담 가벼움 (폰 OK) — 프레임마다 광선 몇 개 + 다리 IK 둘. 땅 메시를 조각(1칸씩)으로 나눠 광선이 작은 조각만 검사하게 해 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i465
- 쓰면 좋을 때: 계단 · 언덕 · 바위 위를 걷는 캐릭터 / 땅이 평평하지 않은 섬 · 산 무대
- 쓰지 말 때: 완전히 평평한 판 위만 걷는 게임 — 계산할 필요가 없다 / 아주 빠르게 달리는 캐릭터 — 발이 짧게 닿아 효과가 거의 안 보인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i465 — `src/demos/demosRigB.ts:1464`
```ts
const i465: DemoMap[string] = {
  kind: '3d',
  caption: '발 아래로 광선(하늘색)을 쏴 땅 높이 → 다리 IK · 골반 높이 보정 — 오른쪽(IK 끔)은 발이 뜨거나 묻힌다',
  make() {
    const st = makeStage([0.6, 1.9, 6.2], [0, 0.75, 0], 32, ['#35477a', '#161d36']);
    const tg = terrainGeo();
    const terr = new THREE.Mesh(tg.geo, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.88 }));
    terr.receiveShadow = true;
    st.scene.add(terr);
    // 뒤쪽 덤불
    const bushG = new THREE.SphereGeometry(0.32, 16, 12);
    const bush = new THREE.InstancedMesh(bushG, new THREE.MeshStandardMaterial({ color: '#ffffff', roughness: 0.8 }), 46);
    const m4 = new THREE.Matrix4();
    for (let i = 0; i < 46; i++) {
      const x = -12 + i * 0.78;
      const s = 0.7 + ((i * 37) % 10) / 18;
      m4.makeScale(s, s * 0.85, s).setPosition(x, terrH(x) + 0.12, -1.45 - ((i * 13) % 5) / 20);
      bush.setMatrixAt(i, m4);
      bush.setColorAt(i, new THREE.Color(i % 3 ? '#86c996' : '#74b887'));
    }
    bush.castShadow = true;
    st.scene.add(bush);
    const A = makeBunny(), B = makeBunny();
    st.scene.add(A.group, B.group);
    A.group.rotation.y = B.group.rotation.y = Math.PI / 2;
    const SA = new Springs(A), SB = new Springs(B);
    // 광선 · 틈 표시
    const rodG = new THREE.CylinderGeometry(1, 1, 1, 8).translate(0, 0.5, 0);
    const rayM = new THREE.MeshBasicMaterial({ color: '#7fe3ff', transparent: true, opacity: 0.85, toneMapped: false });
    const gapM = new THREE.MeshBasicMaterial({ color: '#ff5a6e', toneMapped: false });
    const dotG = new THREE.SphereGeometry(0.035, 12, 8);
    const rays = [0, 1].map(() => new THREE.Mesh(rodG, rayM));
    const dots = [0, 1].map(() => new THREE.Mesh(dotG, rayM));
    const gaps = [0, 1].map(() => new THREE.Mesh(rodG, gapM));
    st.scene.add(...rays, ...dots, ...gaps);
    const rc = new THREE.Raycaster();
    const down = new THREE.Vector3(0, -1, 0), org = new THREE.Vector3();
    const nrm = new THREE.Vector3();
    const cast = (x: number): number => {
      rc.set(org.set(x, 5, 0), down);
      const ch = tg.chunks[clamp(Math.floor(x + TP), 0, 3 * TP)]!;
      const hit = rc.intersectObject(ch, false)[0];
      if (!hit) return terrH(x);
      nrm.copy(hit.face!.normal);
      return hit.point.y;
    };
    let ikOn = true, pelvis = true, speed = 0.75, bx = 0.5;
    const hy = [0.55, 0.55];
    const lA = st.hud.add(52, 18, 'free', (g, w, h) => {
      plate(g, w, h, ikOn ? 'rgba(30,130,110,0.9)' : 'rgba(110,80,140,0.9)');
      txt(g, ikOn ? 'IK 켬' : 'IK 끔', w / 2, h / 2 + 0.5, 9.5, '#fff', 'center', 800);
    }, 0.2);
    const lB = st.hud.add(52, 18, 'free', (g, w, h) => {
      plate(g, w, h, 'rgba(190,60,80,0.9)');
      txt(g, 'IK 끔', w / 2, h / 2 + 0.5, 9.5, '#fff', 'center', 800);
    }, 1);
    st.hud.add(150, 18, 'bl', (g, w, h) => {
      plate(g, w, h);
      txt(g, '하늘색 = 발 아래 광선 · 빨강 = 뜬 틈', w / 2, h / 2 + 0.5, 8, '#e6ecff', 'center', 700);
    }, 1);
    const walk = (bn: Bunny, sp: Springs, bodyX: number, k: number, ik: boolean, d: number, t: number): void => {
      const S = 0.62;
      const ph = bodyX / S;
      const gC = cast(bodyX);
      let hips = gC + 0.53 + 0.02 * Math.cos(ph * 2 * TAU);
      const feet: { s: string; dz: number; ty: number; slope: number; fx: number }[] = [];
      for (const [s, o] of [['L', 0], ['R', 0.5]] as [string, number][]) {
        const n = Math.floor(ph + o), p = ph + o - n;
        const Xn = (n + 0.25 - o) * S, Xn1 = Xn + S;
        const gP = ik ? cast(Xn) : gC, gN = ik ? cast(Xn1) : gC;
        let fx: number, fy: number;
        if (p < 0.5) {
          fx = Xn;
          fy = gP;
        } else {
          const q = (p - 0.5) / 0.5;
          fx = lerp(Xn, Xn1, sstep(q));
          const hi = Math.max(gP, gN) + 0.02;
          fy = (q < 0.5 ? lerp(gP, hi, sstep(q * 2)) : lerp(hi, gN, sstep((q - 0.5) * 2))) + 0.09 * Math.sin(Math.PI * q);
        }
        let slope = 0;
        if (ik) {
          cast(fx);
          slope = nrm.y > 0.5 ? clamp(Math.atan2(-nrm.x, nrm.y), -0.45, 0.45) : 0;
          if (p >= 0.5) slope *= Math.abs(Math.cos(Math.PI * ((p - 0.5) / 0.5)));
        }
        const ty = fy + 0.1;
        feet.push({ s, dz: fx - bodyX, ty, slope, fx });
        if (ik && pelvis) hips = Math.min(hips, ty + Math.sqrt(Math.max(0, (0.37 * 0.98) ** 2 - (fx - bodyX) ** 2)) + 0.08);
      }
      hy[k] = Math.abs(hy[k]! - hips) > 0.4 ? hips : lerp(hy[k]!, hips, 1 - Math.exp(-d * 16));
      const g = bn.group;
      g.position.set(bodyX, 0, 0);
      bn.b.hips!.position.y = hy[k]!;
      bn.b.spine!.rotation.x = 0.07;
      bn.b.armL!.rotation.set(0.5 * Math.sin(ph * TAU), 0, 0.1);
      bn.b.armR!.rotation.set(-0.5 * Math.sin(ph * TAU), 0, -0.1);
      for (const n of ['earL1', 'earL2', 'earR1', 'earR2', 'hair', 'tail']) bn.b[n]!.quaternion.identity();
      feet.forEach((f, i) => {
        legIK(bn, f.s, hy[k]! - 0.08, f.dz, f.ty, f.slope);
        const ground = cast(f.fx);
        if (k === 0) {
          rays[i]!.visible = dots[i]!.visible = ik;
          rays[i]!.position.set(f.fx, ground, 0.15);
          rays[i]!.scale.set(0.006, f.ty + 0.55 - ground, 0.006);
          dots[i]!.position.set(f.fx, ground + 0.01, 0.15);
        } else {
          const gap = f.ty - 0.1 - ground;
          gaps[i]!.visible = gap > 0.03;
          gaps[i]!.position.set(f.fx, ground, 0.2);
          gaps[i]!.scale.set(0.012, Math.max(0.001, gap), 0.012);
        }
      });
      bn.expr[0] = blinkAt(t, k * 1.3);
      bn.applyFace();
      g.updateMatrixWorld(true);
      sp.step(d, 0.03, 0.05, true);
    };
    return {
      scene: st.scene,
      camera: st.camera,
      update(t, dt) {
        const d = Math.min(dt, 0.05);
        bx += speed * d;
        if (bx >= TP) {
          bx -= TP;
          SA.shift(-TP);
          SB.shift(-TP);
        }
        walk(A, SA, bx, 0, ikOn, d, t);
        walk(B, SB, bx + 2.0, 1, false, d, t);
        const cx = bx + 1.0;
        st.camera.position.set(cx + 0.5, 2.1, 7.4);
        st.camera.lookAt(cx, 1.05, 0);
        st.sun.position.set(cx + 2.5, 5, 3.5);
        st.sun.target.position.set(cx, 0, 0);
        st.sun.target.updateMatrixWorld();
        st.camera.updateMatrixWorld();
        pinTo(lA, new THREE.Vector3(bx, hy[0]! + 1.66, 0), st.camera);
        pinTo(lB, new THREE.Vector3(bx + 2.0, hy[1]! + 1.66, 0), st.camera);
      },
      render: st.render,
      controls: [
        { type: 'toggle', label: 'IK (왼쪽 토끼)', value: true, on: (v) => (ikOn = v) },
        { type: 'toggle', label: '골반 높이 보정', value: true, on: (v) => (pelvis = v) },
        { type: 'range', label: '걷는 속도', min: 0.2, max: 1.4, step: 0.05, value: speed, on: (v) => (speed = v) },
      ] as Control[],
      dispose() {
        A.dispose();
        B.dispose();
        bush.dispose();
        for (const c of tg.chunks) c.geometry.dispose();
        st.disposeAll();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [두 뼈 IK (팔 · 다리가 목표를 잡기)](https://ai-techstudio.web.app/ai/t/i459.md) `i459` · [절차 걷기 · 뛰기 (애니메이션 파일 없이)](https://ai-techstudio.web.app/ai/t/i458.md) `i458`
- 다음에 해 볼 기술: [래그돌 흉내 (넘어지는 몸)](https://ai-techstudio.web.app/ai/t/i466.md) `i466`
