# AI 꾸러미 — 애니메이션 섞기 (가만히 ↔ 걷기 ↔ 뛰기) — Animation blending (blend tree by speed)
> 코드로 만든 가만히 · 걷기 · 뛰기 클립 셋을 AnimationMixer 로 함께 틀고 속도에 따라 가중치를 섞되, 걷기 ↔ 뛰기 발 박자를 맞춘다.  
> 견본: https://ai-techstudio.web.app/#t/i464

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

## 주문서

### 만들어 줘: 애니메이션 섞기 (가만히 ↔ 걷기 ↔ 뛰기) — Animation blending (blend tree by speed)

#### 1. 목표
점토 토끼 (흐르는 꽃밭)의 가만히 · 걷기 · 뛰기 클립을 속도에 따라 부드럽게 섞어 줘 — AnimationMixer 가중치로, 걷기 ↔ 뛰기 발 박자는 맞춰서. 분위기는 통통 튀는 귀여운.

#### 2. 핵심 기술 용어
- **Animation blending (blend tree by speed)** — 속도에 따라 클립 가중치 섞기
- **AnimationMixer · AnimationAction.setEffectiveWeight** — three.js 클립 재생기 · 가중치
- **Phase sync (foot cycle matching)** — 다른 길이 클립의 발 박자 맞추기
- **QuaternionKeyframeTrack** — 뼈 회전 키프레임 트랙 — 코드로 클립 만들기

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

#### 4. 조건
- 세 클립을 모두 재생해 두고 setEffectiveWeight 로만 섞는다 (켰다 껐다 하지 않기)
- 가중치 합은 1 (가만히 · 걷기 · 뛰기)
- 길이가 다른 클립은 timeScale 과 재생 시각으로 발 박자를 맞춘다
- 「박자 맞추기」 켬/끔으로 차이를 보여 준다
- 가중치 막대를 화면에 보여 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 속도가 0 → 1.1 → 2.5 → 0 으로 바뀌는 동안 가만히 → 걷기 → 뛰기 → 가만히가 끊김 없이 섞인다
- 「박자 맞추기」를 끄면 걷기 ↔ 뛰기 사이에서 다리가 엉키고, 켜면 매끈하다
- 「속도」 슬라이더로 아무 속도든 중간 걸음이 나온다
- 화면 막대 셋이 지금 가중치를 보여 준다

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

## 원리
- 클립은 코드로: 한 주기를 24칸으로 나눠 뼈마다 오일러 → 쿼터니언 키를 쌓아 QuaternionKeyframeTrack, 골반 높이는 VectorKeyframeTrack.
- 세 클립을 모두 play() 해 두고, 가중치만 바꾼다: 가만히 = 1 − sstep(속도/0.9), 뛰기 몫 kr = sstep((속도 − 1.4)/0.9).
- 걷기(0.8초)와 뛰기(0.5초)는 길이가 달라 그냥 섞으면 발이 엉킨다. 지금 섞인 한 걸음 길이 D = lerp(0.8, 0.5, kr) 로 둘의 timeScale 을 맞춘다.
- 그리고 뛰기 재생 시각을 걷기 진행 비율에 묶는다: run.time = (walk.time / 0.8 % 1) × 0.5 — 두 클립이 같은 발을 같은 순간에 딛는다.
- 땅 무늬 · 꽃이 속도만큼 흘러 제자리 걷기가 앞으로 가는 것처럼 보인다.

## 핵심 코드 — 코드로 클립 만들기 + 속도로 섞기 + 박자 맞춤
(발췌: demos/demosRigB.ts makeClip() · i464 update() 를 정리)
```ts
function makeClip(name: string, period: number, pose: (a: number) => { r: Record<string, [number, number, number]>; hy: number }, bones: string[], n = 24) {
  const times: number[] = [], hy: number[] = [];
  const q: Record<string, number[]> = Object.fromEntries(bones.map((b) => [b, []]));
  const e = new THREE.Euler(), qq = new THREE.Quaternion();
  for (let i = 0; i <= n; i++) {
    times.push((i / n) * period);
    const p = pose(((i % n) / n) * Math.PI * 2); // 마지막 키 = 첫 키 → 이음매 없음
    for (const b of bones) { const r = p.r[b] ?? [0, 0, 0]; qq.setFromEuler(e.set(r[0], r[1], r[2])); q[b]!.push(qq.x, qq.y, qq.z, qq.w); }
    hy.push(0, p.hy, 0);
  }
  const tracks: THREE.KeyframeTrack[] = bones.map((b) => new THREE.QuaternionKeyframeTrack(b + '.quaternion', times, q[b]!));
  tracks.push(new THREE.VectorKeyframeTrack('hips.position', times, hy));
  return new THREE.AnimationClip(name, period, tracks);
}

const mixer = new THREE.AnimationMixer(character);
const [aIdle, aWalk, aRun] = [makeClip('idle', 2.4, IDLE, B), makeClip('walk', 0.8, WALK, B), makeClip('run', 0.5, RUN, B)]
  .map((c) => { const a = mixer.clipAction(c); a.play(); a.setEffectiveWeight(0); return a; });

// 매 프레임
const wi = 1 - sstep(speed / 0.9);           // 가만히
const kr = sstep((speed - 1.4) / 0.9);       // 걷기 중 뛰기 몫
aIdle.setEffectiveWeight(wi);
aWalk.setEffectiveWeight((1 - wi) * (1 - kr));
aRun.setEffectiveWeight((1 - wi) * kr);
const D = lerp(0.8, 0.5, kr);                // 지금 섞인 한 걸음 길이
const m = clamp(Math.max(speed, 0.6) / lerp(1.1, 2.4, kr), 0.5, 1.6);
aWalk.timeScale = (0.8 / D) * m;
aRun.timeScale = (0.5 / D) * m;
mixer.update(dt);
aRun.time = ((aWalk.time / 0.8) % 1) * 0.5;  // 같은 발을 같은 순간에
```

## 흔한 실수 · 확인 목록
- [ ] **길이가 다른 걷기 · 뛰기를 그냥 섞으면 다리가 엉킨다** — 섞인 걸음 길이로 timeScale 을 맞추고, 뛰기 재생 시각을 걷기 진행 비율에 묶는다.
- [ ] **클립을 stop/play 로 바꾸면 툭 끊긴다** — 모두 play 해 두고 setEffectiveWeight 로만 섞는다.
- [ ] **트랙 이름이 뼈 이름과 다르면 아무것도 안 움직인다** — 트랙 이름은 「뼈이름.quaternion」 — 뼈 name 과 정확히 같아야 한다.
- [ ] **마지막 키가 첫 키와 다르면 반복할 때 튄다** — 견본은 i = n 일 때 (i % n) 으로 첫 자세를 다시 넣어 이음매를 없앤다.

## 완성 기준 체크리스트
- [ ] 속도가 0 → 1.1 → 2.5 → 0 으로 바뀌는 동안 가만히 → 걷기 → 뛰기 → 가만히가 끊김 없이 섞인다
- [ ] 「박자 맞추기」를 끄면 걷기 ↔ 뛰기 사이에서 다리가 엉키고, 켜면 매끈하다
- [ ] 「속도」 슬라이더로 아무 속도든 중간 걸음이 나온다
- [ ] 화면 막대 셋이 지금 가중치를 보여 준다

## 이 기술 정보
- id: `i464` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 클립 3개를 동시에 계산해도 뼈 수십 개 수준 — 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i464
- 쓰면 좋을 때: 플레이어처럼 속도가 계속 바뀌는 캐릭터 / 손으로 만든(또는 GLB) 클립을 끊김 없이 이을 때
- 쓰지 말 때: 클립이 하나뿐 — crossFadeTo 로 바꾸기만 하면 된다(i467) / 속도 · 지형에 정확히 맞춰야 할 때 — 절차 걷기(i458) 와 발 IK(i465)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i464 — `src/demos/demosRigB.ts:1259`
```ts
const i464: DemoMap[string] = {
  kind: '3d',
  caption: '코드로 만든 클립 3개(가만히 · 걷기 · 뛰기)를 속도에 따라 AnimationMixer 가중치로 섞기 — 걷기 ↔ 뛰기 발 박자 맞춤',
  make() {
    const st = makeStage([1.2, 1.45, 5.9], [0.2, 1.0, 0], 32, ['#30406e', '#151c34']);
    // 움직이는 땅 (러닝머신처럼 무늬가 흐른다)
    const gc = document.createElement('canvas');
    gc.width = gc.height = 256;
    const gg = gc.getContext('2d')!;
    gg.fillStyle = '#86c99a';
    gg.fillRect(0, 0, 256, 256);
    gg.fillStyle = '#79bd8e';
    gg.fillRect(0, 0, 128, 256);
    for (let i = 0; i < 90; i++) {
      gg.fillStyle = i % 3 ? 'rgba(255,255,255,0.18)' : 'rgba(40,110,70,0.25)';
      gg.beginPath();
      gg.arc((i * 97) % 256, (i * 57) % 256, 2 + (i % 3), 0, TAU);
      gg.fill();
    }
    const gt = new THREE.CanvasTexture(gc);
    gt.colorSpace = THREE.SRGBColorSpace;
    gt.wrapS = gt.wrapT = THREE.RepeatWrapping;
    gt.repeat.set(12, 8);
    gt.anisotropy = 4;
    const ground = new THREE.Mesh(new THREE.PlaneGeometry(24, 16), new THREE.MeshStandardMaterial({ map: gt, roughness: 0.9 }));
    ground.rotation.x = -Math.PI / 2;
    ground.receiveShadow = true;
    st.scene.add(ground);
    // 흐르는 꽃 · 돌
    const flowers = new THREE.Group();
    const stemG = new THREE.CylinderGeometry(0.012, 0.012, 0.16, 6).translate(0, 0.08, 0);
    const headG = new THREE.SphereGeometry(0.05, 12, 8);
    const stoneG = new THREE.SphereGeometry(0.1, 12, 8).scale(1, 0.55, 0.8);
    const stemM = new THREE.MeshStandardMaterial({ color: '#4e9a5e' });
    const petalMs = ['#ffd36e', '#ff9fb8', '#ffffff', '#b9a6ff'].map((c) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.6 }));
    const stoneM = new THREE.MeshStandardMaterial({ color: '#c9c2b8', roughness: 0.85 });
    for (let i = 0; i < 18; i++) {
      const f = new THREE.Group();
      if (i % 4 === 3) f.add(new THREE.Mesh(stoneG, stoneM));
      else {
        const hm = new THREE.Mesh(headG, petalMs[i % 4]!);
        hm.position.y = 0.17;
        f.add(new THREE.Mesh(stemG, stemM), hm);
      }
      f.position.set(-5 + (i * 10) / 18, 0, (i % 2 ? -1 : 1) * (0.6 + ((i * 37) % 10) / 12));
      f.traverse((o) => (o.castShadow = true));
      flowers.add(f);
    }
    st.scene.add(flowers);
    const bn = makeBunny();
    bn.group.rotation.y = Math.PI / 2 - 0.3;
    st.scene.add(bn.group);
    const mixer = new THREE.AnimationMixer(bn.group);
    const clips = [makeClip('가만히', 2.4, IDLE), makeClip('걷기', 0.8, WALK), makeClip('뛰기', 0.5, RUN)];
    const acts = clips.map((c) => {
      const a = mixer.clipAction(c);
      a.play();
      a.setEffectiveWeight(0);
      return a;
    });
    const [aIdle, aWalk, aRun] = acts as [THREE.AnimationAction, THREE.AnimationAction, THREE.AnimationAction];
    let autoSpeed = true, speedM = 1, sync = true, speed = 0;
    const W = [1, 0, 0];
    st.hud.add(112, 64, 'tl', (g, lw, lh) => {
      plate(g, lw, lh);
      txt(g, '속도 ' + speed.toFixed(2) + (sync ? '  · 박자 맞춤' : '  · 박자 따로'), 8, 11, 8.5, '#fff', 'left', 800);
      const C = ['#9fb4ff', '#8ee3c2', '#ffb36b'];
      clips.forEach((c, i) => bar(g, c.name, W[i]!, 8, 26 + i * 13, lw - 14, C[i]!, 30));
    }, 0.06);
    return {
      scene: st.scene,
      camera: st.camera,
      update(t, dt) {
        const d = Math.min(dt, 0.05);
        if (autoSpeed) {
          const u = t % 10;
          speed = u < 1.2 ? 0 : u < 2.4 ? 1.1 * sstep((u - 1.2) / 1.2) : u < 4.2 ? 1.1 : u < 5.4 ? lerp(1.1, 2.5, sstep((u - 4.2) / 1.2)) : u < 7.4 ? 2.5 : u < 8.8 ? lerp(2.5, 0, sstep((u - 7.4) / 1.4)) : 0;
        } else speed = speedM;
        const wi = 1 - sstep(speed / 0.9);
        const kr = sstep((speed - 1.4) / 0.9);
        W[0] = wi;
        W[1] = (1 - wi) * (1 - kr);
        W[2] = (1 - wi) * kr;
        acts.forEach((a, i) => a.setEffectiveWeight(W[i]!));
        // 박자: 걷기(0.8초) · 뛰기(0.5초)를 같은 한 걸음 길이로
        const D = lerp(0.8, 0.5, kr);
        const m = clamp(Math.max(speed, 0.6) / lerp(1.1, 2.4, kr), 0.5, 1.6);
        if (sync) {
          aWalk.timeScale = (0.8 / D) * m;
          aRun.timeScale = (0.5 / D) * m;
        } else {
          aWalk.timeScale = m;
          aRun.timeScale = m * 1.13;
        }
        mixer.update(d);
        if (sync) aRun.time = ((aWalk.time / 0.8) % 1) * 0.5;
        void aIdle;
        bn.expr[0] = blinkAt(t);
        bn.expr[1] = 0.6 * kr * (1 - wi);
        bn.expr[4] = 0.4 * kr * (1 - wi);
        bn.applyFace();
        // 땅이 흐른다
        gt.offset.x += (speed * d) / 2;
        for (const f of flowers.children) {
          f.position.x -= speed * d;
          if (f.position.x < -5) f.position.x += 10;
        }
      },
      render: st.render,
      controls: [
        { type: 'toggle', label: '자동 속도', value: true, on: (v) => (autoSpeed = v) },
        { type: 'range', label: '속도', min: 0, max: 3, step: 0.05, value: 1, on: (v) => ((speedM = v), (autoSpeed = false)) },
        { type: 'toggle', label: '걷기 ↔ 뛰기 박자 맞추기', value: true, on: (v) => (sync = v) },
      ] as Control[],
      dispose() {
        mixer.stopAllAction();
        mixer.uncacheRoot(bn.group);
        bn.dispose();
        st.disposeAll();
        stemG.dispose();
        headG.dispose();
        stoneG.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456` · [절차 걷기 · 뛰기 (애니메이션 파일 없이)](https://ai-techstudio.web.app/ai/t/i458.md) `i458`
- 다음에 해 볼 기술: [발 땅에 붙이기 (울퉁불퉁한 지형)](https://ai-techstudio.web.app/ai/t/i465.md) `i465` · [무료 모델 가져와 쓰기 (CC0 GLB · 애니메이션)](https://ai-techstudio.web.app/ai/t/i467.md) `i467`
- 참고 문서: [three.js 예제 — webgl_animation_skinning_blending](https://threejs.org/examples/#webgl_animation_skinning_blending) · [three.js 문서 — AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer)
