# AI 꾸러미 — 무료 모델 가져와 쓰기 (CC0 GLB · 애니메이션) — GLTFLoader (glTF 2.0 / GLB)
> Quaternius 같은 CC0 GLB 모델을 GLTFLoader 로 불러와 클립을 바꿔 틀고, 재질 색을 바꾸고, 머리 · 꼬리 뼈에 모자 · 깃발을 붙인다.  
> 견본: https://ai-techstudio.web.app/#t/i467

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

## 주문서

### 만들어 줘: 무료 모델 가져와 쓰기 (CC0 GLB · 애니메이션) — GLTFLoader (glTF 2.0 / GLB)

#### 1. 목표
날아다니는 용 (모자 · 깃발)을(를) 무료 CC0 GLB 모델로 가져와 줘 — 클립 바꿔 틀기 · 색 바꾸기 · 뼈에 소품 붙이기까지. 분위기는 파티 분위기.

#### 2. 핵심 기술 용어
- **GLTFLoader (glTF 2.0 / GLB)** — three.js 3D 모델 파일 불러오기
- **AnimationMixer.clipAction · crossFadeTo** — 클립 재생 · 부드럽게 바꾸기
- **SkeletonUtils.clone** — 뼈 있는 모델 복제 (보통 clone 은 뼈가 꼬인다)
- **CC0 assets (Quaternius · Kenney)** — 출처 표시 없이 써도 되는 무료 모델

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

#### 4. 조건
- 뼈 있는 모델 복제는 SkeletonUtils.clone (Object3D.clone 은 뼈 연결이 원본을 가리킨다)
- 재질은 복제해서 고친다 · 버릴 땐 복제한 재질만 dispose (모양은 원본과 나눠 쓰므로 버리지 않기)
- 클립 바꾸기는 crossFadeTo 로 0.3초 남짓 — 끊기지 않게, 한 번만 할 동작(Death)은 LoopOnce + clampWhenFinished
- 소품은 뼈의 자식으로, 세계 → 뼈 좌표 변환을 거쳐서
- 라이선스 · 출처(작가 · CC0)를 파일 옆에 남기고 화면 구석에도 표시

#### 5. 완성 기준 (이게 보이면 성공)
- 용 모델이 받침 위에 맞는 크기로 앉아 날갯짓한다
- 2.8초마다 클립(날며 쉬기 · 응! · 빠르게 날기 · 박치기 …)이 부드럽게 바뀐다
- 색이 바뀌고, 머리 뼈의 파티 모자 · 꼬리 뼈의 깃발이 동작을 따라 움직인다
- 「뼈 보기」로 뼈대(SkeletonHelper)와 뼈 이름 목록을 볼 수 있다

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

## 원리
- GLTFLoader.loadAsync 로 GLB 를 한 번만 불러 캐시하고, 쓸 때마다 SkeletonUtils.clone 으로 복제한다.
- 재질은 복제해서 쓴다 (원본을 고치면 다른 복제본까지 바뀐다). 이름(Main · Secondary)으로 몸 색 재질만 골라 색을 돌린다.
- Box3 로 크기를 재서 높이 1.9 로 맞추고 바닥에 앉힌다.
- 클립 이름(Flying_Idle · Yes · Death 등)으로 clipAction 을 만들고, 바꿀 땐 reset → play → 이전.crossFadeTo(다음, 0.35초).
- 소품은 뼈(getObjectByName("Head"))의 자식으로: 원하는 세계 위치 · 방향 행렬에 뼈 세계 행렬의 역을 곱해 뼈 기준으로 넣는다.

## 핵심 코드 — GLB 불러오기 · 복제 · 클립 바꾸기 · 뼈에 소품
(발췌: demos/demosRigB.ts loadDragon() · i467 · attachTo() 를 정리)
```ts
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils.js';

const gltf = await new GLTFLoader().loadAsync('/models/dragon.glb'); // 한 번만 불러 캐시
const model = SkeletonUtils.clone(gltf.scene) as THREE.Object3D;   // 뼈까지 제대로 복제
const tintMats: { m: THREE.MeshStandardMaterial; c: THREE.Color }[] = [];
model.traverse((o) => {
  const m = o as THREE.Mesh;
  if (!m.isMesh) return;
  m.castShadow = true;
  m.frustumCulled = false;
  const mat = (m.material as THREE.MeshStandardMaterial).clone(); // 원본 재질은 건드리지 않기
  if (/Main|Secondary/.test(mat.name)) tintMats.push({ m: mat, c: mat.color.clone() });
  m.material = mat;
});
// 크기 맞추기: 높이 1.9, 바닥에 앉히기
const box = new THREE.Box3().setFromObject(model, true);
const size = box.getSize(new THREE.Vector3());
const s = 1.9 / size.y;
model.scale.multiplyScalar(s);
model.position.y = -box.min.y * s;
scene.add(model);

// 클립 바꾸기
const mixer = new THREE.AnimationMixer(model);
const act = (name: string) => mixer.clipAction(gltf.animations.find((c) => c.name.split('|').pop() === name)!);
let cur = act('Flying_Idle');
cur.play();
function play(name: string) {
  const next = act(name);
  next.reset();
  next.setLoop(name === 'Death' ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
  next.clampWhenFinished = true;
  next.play();
  cur.crossFadeTo(next, 0.35, false);
  cur = next;
}
// 색 바꾸기: for (const { m, c } of tintMats) m.color.copy(c).offsetHSL(hue, 0.15, 0.06);

// 뼈에 소품 — 세계 위치 · 방향을 뼈 기준으로 바꿔 자식으로
function attachTo(bone: THREE.Object3D, prop: THREE.Object3D, worldPos: THREE.Vector3, worldQ = new THREE.Quaternion()) {
  const M = new THREE.Matrix4().compose(worldPos, worldQ, new THREE.Vector3(1, 1, 1));
  M.premultiply(new THREE.Matrix4().copy(bone.matrixWorld).invert());
  M.decompose(prop.position, prop.quaternion, prop.scale);
  bone.add(prop);
}
model.updateMatrixWorld(true);
const head = model.getObjectByName('Head')!;
attachTo(head, partyHat, head.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0, 0.3, 0)));
// 매 프레임: mixer.update(dt);
```

## 흔한 실수 · 확인 목록
- [ ] **model.clone() 으로 복제하면 복제본이 원본 뼈를 따라 움직인다** — 뼈 있는 모델은 SkeletonUtils.clone 으로 복제한다.
- [ ] **재질 색을 바로 고치면 같은 모델 전부의 색이 바뀐다** — 재질을 clone 해서 고치고, 정리할 땐 복제한 재질만 dispose 한다.
- [ ] **소품을 뼈에 그냥 add 하면 크기 · 위치가 엉뚱하다** — 뼈에는 크기(모델 축소)까지 들어 있다. 원하는 세계 행렬에 뼈 세계 행렬의 역을 곱해 넣는다.
- [ ] **모델 라이선스를 확인하지 않고 쓰면 나중에 문제가 된다** — 이 사이트의 용(가짜 동전)은 Quaternius 「Dragon Evolved」 CC0 — 출처를 dragon.LICENSE.txt 로 남겼다.

## 완성 기준 체크리스트
- [ ] 용 모델이 받침 위에 맞는 크기로 앉아 날갯짓한다
- [ ] 2.8초마다 클립(날며 쉬기 · 응! · 빠르게 날기 · 박치기 …)이 부드럽게 바뀐다
- [ ] 색이 바뀌고, 머리 뼈의 파티 모자 · 꼬리 뼈의 깃발이 동작을 따라 움직인다
- [ ] 「뼈 보기」로 뼈대(SkeletonHelper)와 뼈 이름 목록을 볼 수 있다

## 이 기술 정보
- id: `i467` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 쉬움 · 폰 부담 보통 (폰 주의) — 모델 파일 크기(수백 KB ~ 수 MB)와 첫 불러오기 · 셰이더 컴파일이 비싸다. 한 번 불러 캐시하고 복제해 쓴다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i467
- 쓰면 좋을 때: 용 · 동물처럼 코드로 만들기 어려운 캐릭터가 필요할 때 / 뼈 · 애니메이션이 이미 있는 모델을 빨리 쓰고 싶을 때
- 쓰지 말 때: 라이선스가 CC0 · 사용 허락이 아닌 모델 — 출처 · 라이선스를 꼭 확인하고 기록한다 / 게임 그림체와 너무 다른 모델 — 따로 노는 느낌이면 코드 모델이 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i467 — `src/demos/demosRigB.ts:2001`
```ts
const i467: DemoMap[string] = {
  kind: '3d',
  caption: 'CC0 용 모델(Quaternius)을 GLTFLoader 로 — 클립 바꾸기 · 색 바꾸기 · 뼈 이름 · 머리 뼈에 모자, 꼬리 뼈에 깃발',
  make() {
    const st = makeStage([0.6, 1.5, 5.2], [0, 1.0, 0], 34, ['#2c4a66', '#13202f'], false);
    const fill = new THREE.DirectionalLight(0xffe7d0, 1.2);
    fill.position.set(-1, 2, 5);
    st.scene.add(fill, pedestal(1.5, '#d9c7a4', '#8b7656'));
    const spin = new THREE.Mesh(new THREE.TorusGeometry(0.3, 0.05, 12, 48, Math.PI * 1.5), new THREE.MeshStandardMaterial({ color: '#8fd6ff', emissive: '#2a6c9a' }));
    spin.position.y = 1;
    st.scene.add(spin);
    const holder = new THREE.Group();
    st.scene.add(holder);
    let mixer: THREE.AnimationMixer | null = null;
    let acts: THREE.AnimationAction[] = [];
    let names: string[] = [];
    let cur = -1, manualClip = -1, hue = -1, manualHue = -1, dead = false, status = '불러오는 중…';
    let props: THREE.Group[] = [];
    let cloth: THREE.Mesh | null = null;
    let helper: THREE.SkeletonHelper | null = null;
    let showBones = false, showProps = true;
    let bones: string[] = [];
    const mats: { m: THREE.MeshStandardMaterial; c: THREE.Color }[] = [];
    const ownMats: THREE.Material[] = [];
    const play = (i: number): void => {
      if (!mixer || i === cur || !acts[i]) return;
      const next = acts[i]!;
      next.reset();
      next.setLoop(names[i] === 'Death' ? THREE.LoopOnce : THREE.LoopRepeat, Infinity);
      next.clampWhenFinished = true;
      next.play();
      if (cur >= 0) acts[cur]!.crossFadeTo(next, 0.35, false);
      cur = i;
    };
    const tint = (h: number): void => {
      if (h === hue) return;
      hue = h;
      for (const { m, c } of mats) {
        m.color.copy(c);
        if (h > 0) m.color.offsetHSL(h, 0.15, 0.06);
      }
    };
    loadDragon()
      .then((gltf) => {
        if (dead) return;
        const model = SkeletonUtils.clone(gltf.scene);
        model.traverse((o) => {
          const m = o as THREE.Mesh;
          if (!m.isMesh) return;
          m.castShadow = true;
          m.frustumCulled = false;
          const list = (Array.isArray(m.material) ? m.material : [m.material]).map((mt) => {
            const c = (mt as THREE.MeshStandardMaterial).clone();
            ownMats.push(c);
            if (/Main|Secondary/.test(c.name)) mats.push({ m: c, c: c.color.clone() });
            return c;
          });
          m.material = Array.isArray(m.material) ? list : list[0]!;
          const sm = m as THREE.SkinnedMesh;
          if (sm.isSkinnedMesh && !bones.length) bones = sm.skeleton.bones.map((b) => b.name);
        });
        model.updateMatrixWorld(true);
        const box = new THREE.Box3().setFromObject(model, true);
        const size = box.getSize(new THREE.Vector3());
        const H = 1.9;
        const s = Math.min(H / Math.max(size.y, 1e-3), 2.5 / Math.max(size.x, size.z, 1e-3));
        model.scale.multiplyScalar(s);
        const ctr = box.getCenter(new THREE.Vector3()).multiplyScalar(s);
        model.position.set(-ctr.x, -box.min.y * s + 0.1, -ctr.z);
        holder.add(model);
        holder.updateMatrixWorld(true);
        st.reveal(model);
        // 소품
        const head = model.getObjectByName('Head'), headEnd = model.getObjectByName('Head_end');
        const tail = model.getObjectByName('Body4'), tailEnd = model.getObjectByName('Body4_end');
        if (head && headEnd) {
          const hat = partyHat(H);
          const p = head.getWorldPosition(new THREE.Vector3()).lerp(headEnd.getWorldPosition(new THREE.Vector3()), 0.8);
          p.y += 0.17 * s * size.y;
          attachTo(head, hat, p, new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, -0.25)));
          props.push(hat);
        }
        if (tail && tailEnd) {
          const f = flag(H * 1.5);
          attachTo(tail, f.g, tailEnd.getWorldPosition(new THREE.Vector3()));
          props.push(f.g);
          cloth = f.cloth;
        }
        mixer = new THREE.AnimationMixer(model);
        const byShort = new Map(gltf.animations.map((c) => [c.name.split('|').pop()!, c]));
        names = CLIP_ORDER.filter((n) => byShort.has(n));
        for (const [n] of byShort) if (!names.includes(n)) names.push(n);
        acts = names.map((n) => mixer!.clipAction(byShort.get(n)!));
        helper = new THREE.SkeletonHelper(model);
        (helper.material as THREE.LineBasicMaterial).depthTest = false;
        helper.visible = showBones;
        st.scene.add(helper);
        spin.visible = false;
        status = '';
        play(0);
      })
      .catch((e) => {
        console.error('[i467] 모델 불러오기 실패', e);
        status = '모델을 불러오지 못했어요';
      });
    const pLoad = st.hud.add(120, 22, 'free', (g, w, h) => {
      plate(g, w, h);
      txt(g, status, w / 2, h / 2 + 0.5, 9, '#fff', 'center', 800);
    }, 0.2);
    pLoad.fx = 0.5;
    pLoad.fy = 0.22;
    st.hud.add(132, 46, 'tl', (g, w, h) => {
      plate(g, w, h);
      const n = names[cur] ?? '';
      txt(g, cur >= 0 ? '▶ ' + (CLIP_KO[n] ?? n) + `  (${cur + 1}/${names.length})` : 'GLB 모델', 8, 11, 9, '#fff', 'left', 800);
      txt(g, `클립 ${names.length}개 · 뼈 ${bones.length}개`, 8, 25, 7.5, '#cfe0ff', 'left', 600);
      txt(g, '모자 → Head 뼈 · 깃발 → Body4 뼈', 8, 37, 7.5, '#ffd9a8', 'left', 600);
    }, 0.15);
    const pBones = st.hud.add(92, 118, 'tr', (g, w, h) => {
      plate(g, w, h);
      txt(g, '뼈 이름', 8, 10, 8, '#fff', 'left', 800);
      bones.slice(0, 12).forEach((b, i) => txt(g, (b === 'Head' || b === 'Body4' ? '● ' : '· ') + b, 8, 22 + i * 7.6, 6.6, b === 'Head' || b === 'Body4' ? '#ffd9a8' : '#c9d4ee', 'left', 600));
      if (bones.length > 12) txt(g, `… 외 ${bones.length - 12}개`, 8, h - 6, 6.6, '#8fa0c8', 'left', 600);
    }, 0.5);
    st.hud.add(124, 13, 'br', (g, w, h) => {
      g.fillStyle = 'rgba(0,0,0,0.35)';
      g.beginPath();
      g.roundRect(0, 0, w, h, 4);
      g.fill();
      txt(g, 'Dragon Evolved · Quaternius · CC0', w / 2, h / 2 + 0.5, 6.3, '#ffffffcc', 'center', 600);
    }, 5);
    return {
      scene: st.scene,
      camera: st.camera,
      update(t, dt) {
        const d = Math.min(dt, 0.05);
        spin.rotation.z = -t * 4;
        pLoad.show = !!status;
        pBones.show = st.camera.aspect > 0 && bones.length > 0;
        holder.rotation.y = -0.75 + 0.35 * Math.sin(t * 0.35);
        if (mixer) {
          const slot = Math.floor(t / 2.8);
          play(manualClip >= 0 ? manualClip : slot % Math.max(1, names.length - 1));
          tint(manualHue >= 0 ? manualHue : HUES[slot % HUES.length]!);
          mixer.update(d);
        }
        for (const p of props) p.visible = showProps;
        if (helper) helper.visible = showBones;
        if (cloth) {
          const pa = cloth.geometry.attributes.position as THREE.BufferAttribute;
          const base = cloth.userData.base as Float32Array;
          for (let i = 0; i < pa.count; i++) {
            const x = base[i * 3]!;
            pa.setZ(i, Math.sin(x * 40 - t * 9) * x * 0.35);
          }
          pa.needsUpdate = true;
          cloth.geometry.computeVertexNormals();
        }
      },
      render: (r, w, h) => {
        pBones.show = h > 380 && bones.length > 0;
        st.render(r, w, h);
      },
      controls: [
        { type: 'range', label: '클립 (0 = 자동으로 바꾸기)', min: 0, max: 8, step: 1, value: 0, on: (v) => (manualClip = v - 1) },
        { type: 'range', label: '색 (0 = 원래 색)', min: 0, max: 1, step: 0.01, value: 0, on: (v) => (manualHue = v) },
        { type: 'toggle', label: '소품 (모자 · 깃발)', value: true, on: (v) => (showProps = v) },
        { type: 'toggle', label: '뼈 보기', value: false, on: (v) => (showBones = v) },
      ] as Control[],
      dispose() {
        dead = true;
        mixer?.stopAllAction();
        for (const m of ownMats) m.dispose();
        for (const p of props) disposeTree(p);
        helper?.dispose();
        // 모델 모양(geometry)은 캐시한 원본과 나눠 쓰므로 버리지 않는다
        holder.clear();
        st.disposeAll();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [코드로 뼈대 만들기 (Bone · SkinnedMesh)](https://ai-techstudio.web.app/ai/t/i456.md) `i456` · [애니메이션 섞기 (가만히 ↔ 걷기 ↔ 뛰기)](https://ai-techstudio.web.app/ai/t/i464.md) `i464`
- 다음에 해 볼 기술: [흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임)](https://ai-techstudio.web.app/ai/t/i462.md) `i462` · [실사 PBR 텍스처 (무료 CC0 스캔 재질)](https://ai-techstudio.web.app/ai/t/i474.md) `i474`
- 참고 문서: [three.js 문서 — GLTFLoader](https://threejs.org/docs/#examples/en/loaders/GLTFLoader) · [three.js 예제 — webgl_animation_skinning_blending](https://threejs.org/examples/#webgl_animation_skinning_blending) · [Quaternius (CC0 모델)](https://quaternius.com) · [Kenney (CC0 에셋)](https://kenney.nl)
