# AI 꾸러미 — 수천 캐릭터 군중 (구운 뼈 애니메이션 텍스처) — Baked animation texture (bone-matrix VAT, GPU crowd skinning)
> 뼈 애니메이션을 프레임마다 뼈 행렬 텍스처 한 장에 구워 두고, 정점 셰이더가 사람마다 다른 클립 · 박자로 읽어 스키닝해 수천 명을 그리기 한 번으로 움직인다.  
> 견본: https://ai-techstudio.web.app/#t/i498

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

## 주문서

### 만들어 줘: 수천 캐릭터 군중 (구운 뼈 애니메이션 텍스처) — Baked animation texture (bone-matrix VAT, GPU crowd skinning)

#### 1. 목표
광장을 걷고 뛰고 손 흔드는 블록 사람 군중을 뼈 애니메이션 텍스처 굽기로 수천 명 띄워 줘 — 클립(걷기 · 뛰기 · 손 흔들기)을 뼈 행렬 텍스처에 굽고, InstancedMesh 하나가 정점 셰이더에서 사람마다 다른 클립 · 박자로 스키닝. 분위기는 밝은 낮 광장, 알록달록한 블록 장난감.

#### 2. 핵심 기술 용어
- **Baked animation texture (bone-matrix VAT, GPU crowd skinning)** — 뼈 행렬을 텍스처에 구워 정점 셰이더에서 스키닝하는 군중 기법
- **InstancedMesh + skinIndex / skinWeight** — 같은 캐릭터 수천 개를 한 번에 — 스킨 속성은 그대로 둔다
- **DataTexture (RGBA32F) · texelFetch** — 실수 텍스처 한 장 · 정수 좌표로 텍셀 하나를 그대로 읽기
- **AnimationClip · AnimationMixer.setTime** — 굽기 때 클립을 원하는 시각의 자세로 맞추는 방법

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

#### 4. 조건
- 캐릭터는 코드로 SkinnedMesh + Bone 계층, 정점 가중치는 관절(무릎 · 팔꿈치) 둘레에서 위 · 아래 뼈에 나눠 줄 것 — 상자마다 뼈 하나만 주면 굽힐 때 찢어진다
- 굽는 값은 bone.matrixWorld × skeleton.boneInverses[b] (캐릭터는 원점 · 단위 행렬에서 bind) — matrixWorld 만 구우면 정점이 두 번 옮겨진다
- 텍스처는 DataTexture(Float32Array, RGBAFormat, FloatType) · NearestFilter · 밉맵 끔, 셰이더에서 texelFetch 로 정수 좌표 읽기 (보간은 셰이더에서 두 프레임을 직접 섞는다)
- 정점 속성은 16칸뿐 — instanceMatrix 가 4칸을 먹으니 사람마다 값(클립 · 위상 · 박자)은 vec3 하나로 묶는다
- InstancedMesh.frustumCulled = false, 그림자는 customDepthMaterial 에도 같은 스키닝 식을 넣기 (안 넣으면 그림자만 차렷 자세)

#### 5. 완성 기준 (이게 보이면 성공)
- 광장에 5,000명이 걷고 뛰고 손을 흔드는데 화면 구석 그리기 수가 군중 몫 2번(몸 + 그림자)으로 그대로다
- 「SkinnedMesh 로 그렸다면」을 켜면 1,000명만으로 그리기가 천 단위로 늘고 fps 가 뚝 떨어진다 — 끄면 바로 돌아온다
- 「구운 텍스처 보기」로 뼈 × 프레임 텍스처가 구석에 확대되고, 흰 줄(사람 하나가 지금 읽는 프레임)이 클립 구간 안에서 움직인다
- 가까이 보면 무릎 · 팔꿈치가 매끄럽게 굽고, 「박자 모두 똑같이」를 켜기 전에는 이웃끼리 걸음이 다르다
- 첫 만들기(굽기 포함)가 300ms 안 (견본 약 50ms, 굽기 약 7ms)

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

## 원리
- 캐릭터는 보통 SkinnedMesh 처럼 만든다: 뼈 계층(Bone) + 정점마다 skinIndex · skinWeight. 무릎 · 팔꿈치 둘레는 위 · 아래 뼈에 가중치를 나눠야 굽힐 때 안 찢어진다.
- 굽기: 클립마다 30fps 로 mixer.setTime(t) → 뼈마다 행렬 = bone.matrixWorld × boneInverse 를 텍스처 한 줄에. 가로 = 뼈 × 4 텍셀(행렬 열 넷), 세로 = 프레임 (클립들을 이어 붙임).
- 그리기: InstancedMesh 하나 + 사람마다 속성 aAnim(클립 번호 · 시작 위상 · 박자). 정점 셰이더에서 f = fract(시간 × 박자 / 길이 + 위상) × 프레임 수 → 줄 둘을 읽어 사이 비율로 섞는다.
- 스킨 행렬 = Σ 가중치 × 뼈 행렬, 정점과 법선에 곱한 뒤 instanceMatrix 로 제자리에. 그림자도 같은 식(customDepthMaterial)이라 그리기 1번 + 그림자 1번.
- 걸음 박자는 실제 이동 속력에 맞춘다: 박자 = 속력 ÷ (한 바퀴에 나아가는 거리) × 클립 길이. 그래야 발이 미끄러지지 않는다.

## 핵심 코드 — 뼈 행렬을 텍스처에 굽고, 정점 셰이더에서 두 프레임을 읽어 스키닝
(발췌: demos/demosWebGPU.ts bakeBones() · VAT_SKIN · injectVat() 를 정리)
```ts
// ① 굽기 — 클립마다 30fps, 한 줄 = 뼈 nb 개 × 행렬 열 4 텍셀
const data = new Float32Array(nb * 4 * rows * 4);
clips.forEach((clip, k) => {
  mixer.stopAllAction();
  mixer.clipAction(clip).play();
  for (let f = 0; f < info[k].frames; f++) {
    mixer.setTime((f / info[k].frames) * clip.duration);
    mesh.updateMatrixWorld(true);
    for (let b = 0; b < nb; b++) {
      m.multiplyMatrices(bones[b].matrixWorld, mesh.skeleton.boneInverses[b]);
      data.set(m.elements, ((info[k].start + f) * nb + b) * 16);
    }
  }
});
const tex = new THREE.DataTexture(data, nb * 4, rows, THREE.RGBAFormat, THREE.FloatType);
tex.minFilter = tex.magFilter = THREE.NearestFilter;
tex.needsUpdate = true;

// ② 정점 셰이더 (onBeforeCompile 로 skinbase_vertex 자리에) — aAnim = (클립, 위상, 박자)
const VAT_SKIN = [
  'vec4 c = uClip[int(aAnim.x + 0.5)];  // 시작 줄 · 프레임 수 · 길이',
  'float f = fract(uTime * aAnim.z / c.z + aAnim.y) * c.y;',
  'float f0 = floor(f); float ft = f - f0;',
  'int r0 = int(c.x + f0); int r1 = int(c.x + mod(f0 + 1.0, c.y));',
  'mat4 vatSkin = skinWeight.x * (vatBone(skinIndex.x, r0) * (1.0 - ft) + vatBone(skinIndex.x, r1) * ft)',
  '             + skinWeight.y * (vatBone(skinIndex.y, r0) * (1.0 - ft) + vatBone(skinIndex.y, r1) * ft);',
].join('\n');
// skinnormal_vertex → objectNormal = (vatSkin * vec4(objectNormal, 0.0)).xyz;
// skinning_vertex  → transformed  = (vatSkin * vec4(transformed, 1.0)).xyz;
// vatBone(b, row) = mat4(texelFetch(uBones, ivec2(b*4 + 0..3, row), 0) 넷)
```

## 흔한 실수 · 확인 목록
- [ ] **인스턴스 속성을 여러 개 따로 붙이면 셰이더가 「Too many attributes」로 안 만들어진다** — 정점 속성은 16칸, instanceMatrix 가 4칸을 쓴다. 클립 · 위상 · 박자를 vec3 하나로, 쓰지 않는 uv 는 셰이더가 안 읽게 둔다.
- [ ] **팔다리 상자를 뼈 하나에만 묶으면 무릎 · 팔꿈치에서 찢어지거나 접힌다** — 관절 높이 ± 반 칸 사이 정점은 위 · 아래 뼈에 선형으로 가중치를 나누고, 상자를 높이 방향으로 4칸 나눠 그 줄이 생기게 한다.
- [ ] **boneInverse 를 빼고 matrixWorld 만 구우면 몸이 두 배로 옮겨져 날아간다** — 스키닝 행렬은 「바인드 자세 → 지금 자세」 변환이다. bone.matrixWorld × boneInverse 를 굽는다.
- [ ] **그림자가 차렷 자세로 남는다** — 그림자는 customDepthMaterial 로 그린다. MeshDepthMaterial 에도 같은 onBeforeCompile(스키닝 식)을 넣고 customProgramCacheKey 를 따로 준다.
- [ ] **모두 같은 박자 · 같은 속력이면 군무처럼 어색하고, 박자와 속력이 안 맞으면 발이 미끄러진다** — 사람마다 시작 위상은 무작위, 박자는 이동 속력 ÷ 한 바퀴 거리 × 클립 길이로 맞춘다.

## 완성 기준 체크리스트
- [ ] 광장에 5,000명이 걷고 뛰고 손을 흔드는데 화면 구석 그리기 수가 군중 몫 2번(몸 + 그림자)으로 그대로다
- [ ] 「SkinnedMesh 로 그렸다면」을 켜면 1,000명만으로 그리기가 천 단위로 늘고 fps 가 뚝 떨어진다 — 끄면 바로 돌아온다
- [ ] 「구운 텍스처 보기」로 뼈 × 프레임 텍스처가 구석에 확대되고, 흰 줄(사람 하나가 지금 읽는 프레임)이 클립 구간 안에서 움직인다
- [ ] 가까이 보면 무릎 · 팔꿈치가 매끄럽게 굽고, 「박자 모두 똑같이」를 켜기 전에는 이웃끼리 걸음이 다르다
- [ ] 첫 만들기(굽기 포함)가 300ms 안 (견본 약 50ms, 굽기 약 7ms)

## 이 기술 정보
- id: `i498` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — CPU 는 인스턴스 행렬 쓰기뿐 (5,000명 ≈ 0.5ms), 그리기 2번. GPU 는 정점마다 texelFetch 16번(뼈 둘 × 두 프레임 × 4). 텍스처는 44 × 84 × 16바이트 ≈ 59KB. 같은 1,000명을 SkinnedMesh 로 두면 그리기 약 1,900번 · 24fps.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i498
- 쓰면 좋을 때: 관중 · 행진 · 떼처럼 같은 캐릭터가 수백 ~ 수천 명 움직여야 할 때 / SkinnedMesh 를 사람마다 두면 그리기 호출 · AnimationMixer 계산으로 CPU 가 막힐 때 / 동작이 몇 가지 정해진 클립(걷기 · 뛰기 · 손 흔들기)으로 충분할 때
- 쓰지 말 때: 가까이서 보는 주인공 — 클립 섞기 · IK · 몸짓 반응이 필요하면 SkinnedMesh + AnimationMixer(i464) 그대로 / 사람마다 동작이 계속 새로 바뀌는(래그돌 · 손으로 잡기) 장면 — 구운 클립 밖의 자세는 못 낸다. 그때는 가까운 몇 명만 진짜 뼈대로 바꿔 끼운다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i498 — `src/demos/demosWebGPU.ts:2071`
```ts
const i498: Demo3D = {
  kind: '3d',
  caption: '뼈 애니메이션(걷기 · 뛰기 · 손 흔들기)을 텍스처 한 장에 구워 두고, 정점 셰이더가 사람마다 다른 프레임을 읽어 스키닝 — 수천 명이 그리기 1번',
  make(T) {
    const tMake = performance.now();
    const st = { count: CROWD_MAX, run: 0.2, wave: 0.15, skin: false, tex: true, sync: false };
    const scene = new T.Scene();
    const SKY = 0xa9cdee;
    scene.background = new T.Color(SKY);
    scene.fog = new T.Fog(SKY, 30, 90);
    const cam = new T.PerspectiveCamera(36, 1.6, 0.5, 260);

    // ── 캐릭터 · 클립 · 굽기 ──
    const baseGeo = crowdGeometry(T);
    const rig = crowdRig(T, baseGeo);
    const clips = crowdClips(T);
    const tBake = performance.now();
    const bk = bakeBones(T, rig.mesh, rig.bones, clips);
    const bakeMs = performance.now() - tBake;
    const boneTex = new T.DataTexture(bk.data, bk.width, bk.rows, T.RGBAFormat, T.FloatType);
    boneTex.minFilter = boneTex.magFilter = T.NearestFilter;
    boneTex.generateMipmaps = false;
    boneTex.needsUpdate = true;
    const U: Record<string, THREE.IUniform> = {
      uBones: { value: boneTex },
      uClip: { value: bk.clips.map((c) => new T.Vector4(c.start, c.frames, c.dur, 0)) },
      uTime: { value: 0 },
      uSync: { value: 0 },
    };

    // ── 사람마다 값 (인스턴스 속성) ──
    const geo = baseGeo.clone();
    // aAnim = (클립 번호, 시작 위상, 박자) — 사람마다 vec3 하나
    const aAnim = new T.InstancedBufferAttribute(new Float32Array(CROWD_MAX * 3), 3);
    const aShirt = new T.InstancedBufferAttribute(new Float32Array(CROWD_MAX * 3), 3);
    const aPants = new T.InstancedBufferAttribute(new Float32Array(CROWD_MAX * 3), 3);
    const aHair = new T.InstancedBufferAttribute(new Float32Array(CROWD_MAX * 3), 3);
    geo.setAttribute('aAnim', aAnim);
    geo.setAttribute('aShirt', aShirt);
    geo.setAttribute('aPants', aPants);
    geo.setAttribute('aHair', aHair);

    let sd = 7;
    const rnd = (): number => (sd = (sd * 16807) % 2147483647) / 2147483647;
    const px = new Float32Array(CROWD_MAX);
    const pz = new Float32Array(CROWD_MAX);
    const hd = new Float32Array(CROWD_MAX);
    const vel = new Float32Array(CROWD_MAX);
    const sc = new Float32Array(CROWD_MAX);
    const mixR = new Float32Array(CROWD_MAX);
    const gait = new Float32Array(CROWD_MAX);
    const wf = new Float32Array(CROWD_MAX);
    const wp = new Float32Array(CROWD_MAX);
    const col = new T.Color();
    const SHIRTS = [0xe8504a, 0x3f7fe0, 0xf2c14e, 0x4cb36a, 0xa063d8, 0xff8a3d, 0x31b5b0, 0xf27fb0, 0xf4f1ea, 0x50607a];
    const PANTS = [0x2b3a5c, 0x3c3c44, 0x5a4636, 0x1f4a3a, 0x6b6f7a, 0x7a3b3b];
    const HAIRS = [0x1d1612, 0x3b2617, 0x6b4223, 0xa86a32, 0xd9b26a, 0x2b2b30];
    const rad = (n: number): number => Math.sqrt((n * 1.1) / Math.PI + 4.2 * 4.2);
    const place = (i: number, R: number): void => {
      const r = Math.sqrt(4.4 * 4.4 + rnd() * (R * R - 4.4 * 4.4));
      const a = rnd() * TAU;
      px[i] = Math.cos(a) * r;
      pz[i] = Math.sin(a) * r;
    };
    for (let i = 0; i < CROWD_MAX; i++) {
      hd[i] = rnd() * TAU;
      sc[i] = 0.9 + rnd() * 0.22;
      mixR[i] = rnd();
      gait[i] = rnd();
      wf[i] = 0.3 + rnd() * 0.5;
      wp[i] = rnd() * TAU;
      aAnim.setY(i, rnd());
      col.setHex(SHIRTS[Math.floor(rnd() * SHIRTS.length)]!).offsetHSL((rnd() - 0.5) * 0.04, 0, (rnd() - 0.5) * 0.1);
      aShirt.setXYZ(i, col.r, col.g, col.b);
      col.setHex(PANTS[Math.floor(rnd() * PANTS.length)]!);
      aPants.setXYZ(i, col.r, col.g, col.b);
      col.setHex(HAIRS[Math.floor(rnd() * HAIRS.length)]!);
      aHair.setXYZ(i, col.r, col.g, col.b);
      place(i, rad(CROWD_CARD));
    }
    let mixKey = '';
    /** 클립 섞기: 사람마다 고정 난수로 클립을 고르고, 이동 속력 ↔ 걸음 박자를 맞춘다 */
    const assignClips = (): void => {
      const key = st.run.toFixed(2) + '/' + st.wave.toFixed(2);
      if (key === mixKey) return;
      mixKey = key;
      const tot = Math.max(1, st.run + st.wave);
      const pr = st.run / tot;
      const pw = st.wave / tot;
      for (let i = 0; i < CROWD_MAX; i++) {
        const r = mixR[i]!;
        const k = r < pr ? 1 : r < pr + pw ? 2 : 0;
        aAnim.setX(i, k);
        const g = gait[i]!;
        if (k === 0) {
          vel[i] = (0.8 + g * 0.4) * sc[i]!;
          aAnim.setZ(i, (vel[i]! / CLIP_STRIDE[0]!) * bk.clips[0]!.dur);
        } else if (k === 1) {
          vel[i] = (2.5 + g * 0.9) * sc[i]!;
          aAnim.setZ(i, (vel[i]! / CLIP_STRIDE[1]!) * bk.clips[1]!.dur);
        } else {
          vel[i] = 0;
          aAnim.setZ(i, 0.8 + g * 0.4);
        }
      }
      aAnim.needsUpdate = true;
      for (const p of pool) p.clip = -1;
    };

    // ── 재질: 보통 MeshStandardMaterial + 정점 셰이더에 구운 스키닝을 끼움 ──
    const mat = new T.MeshStandardMaterial({ vertexColors: true, roughness: 0.72 });
    mat.onBeforeCompile = (sh) => injectVat(sh, U, true);
    mat.customProgramCacheKey = () => 'i498-vat-color';
    const depth = new T.MeshDepthMaterial({ depthPacking: T.RGBADepthPacking });
    depth.onBeforeCompile = (sh) => injectVat(sh, U, false);
    depth.customProgramCacheKey = () => 'i498-vat-depth';
    const crowd = new T.InstancedMesh(geo, mat, CROWD_MAX);
    crowd.customDepthMaterial = depth;
    crowd.instanceMatrix.setUsage(T.DynamicDrawUsage);
    crowd.frustumCulled = false; // 자리 · 자세가 셰이더에서 바뀌어 경계 구가 틀린다
    crowd.castShadow = true;
    crowd.receiveShadow = true;
    scene.add(crowd);

    // ── 비교: 진짜 SkinnedMesh + AnimationMixer 를 사람마다 ──
    const variants = SHIRTS.slice(0, 6).map((sh, k) => {
      const g2 = baseGeo.clone();
      const c = g2.attributes.color as THREE.BufferAttribute;
      const tn = g2.attributes.aTint as THREE.BufferAttribute;
      const cs = new T.Color(sh);
      const cp = new T.Color(PANTS[k % PANTS.length]!);
      const ch = new T.Color(HAIRS[k % HAIRS.length]!);
      for (let i = 0; i < c.count; i++) {
        const t = tn.getX(i);
        const cc = t === 1 ? cs : t === 2 ? cp : t === 3 ? ch : null;
        if (cc) c.setXYZ(i, cc.r, cc.g, cc.b);
      }
      return g2;
    });
    const skinMat = new T.MeshStandardMaterial({ vertexColors: true, roughness: 0.72 });
    const skinGroup = new T.Group();
    skinGroup.visible = false;
    scene.add(skinGroup);
    const pool: { root: THREE.SkinnedMesh; mixer: THREE.AnimationMixer; clip: number }[] = [];
    const growPool = (want: number): void => {
      const t0 = performance.now();
      while (pool.length < want && performance.now() - t0 < 4) {
        const i = pool.length;
        const root = skelClone(rig.mesh) as THREE.SkinnedMesh;
        root.geometry = variants[i % variants.length]!;
        root.material = skinMat;
        root.castShadow = true;
        root.receiveShadow = true;
        skinGroup.add(root);
        pool.push({ root, mixer: new T.AnimationMixer(root), clip: -1 });
      }
    };

    // ── 광장 ──
    const PLAZA = rad(CROWD_MAX) + 3;
    const GROUND = PLAZA + 26;
    const gtex = new T.CanvasTexture(plazaCanvas(GROUND, PLAZA));
    gtex.colorSpace = T.SRGBColorSpace;
    gtex.anisotropy = 8;
    const ground = new T.Mesh(new T.CircleGeometry(GROUND, 96), new T.MeshStandardMaterial({ map: gtex, roughness: 0.95 }));
    ground.rotation.x = -Math.PI / 2;
    ground.receiveShadow = true;
    scene.add(ground);
    const stone = new T.MeshStandardMaterial({ color: 0xe6dccb, roughness: 0.7 });
    const basin = new T.Mesh(new T.CylinderGeometry(3.6, 3.8, 0.55, 48), stone);
    basin.position.y = 0.27;
    const water = new T.Mesh(new T.CylinderGeometry(3.3, 3.3, 0.05, 48), new T.MeshStandardMaterial({ color: 0x5fb3d9, roughness: 0.15, metalness: 0.1 }));
    water.position.y = 0.48;
    const tower = new T.Mesh(new T.CylinderGeometry(0.55, 0.9, 5.4, 12), stone);
    tower.position.y = 3;
    const top = new T.Mesh(new T.ConeGeometry(1.0, 1.5, 12), new T.MeshStandardMaterial({ color: 0xd2523f, roughness: 0.5 }));
    top.position.y = 6.45;
    for (const o of [basin, tower, top]) {
      o.castShadow = true;
      o.receiveShadow = true;
    }
    scene.add(basin, water, tower, top);
    // 둘레 나무 (인스턴스 둘 — 줄기 · 잎)
    const NT = 70;
    const trunk = new T.InstancedMesh(new T.CylinderGeometry(0.18, 0.25, 1.6, 6), new T.MeshStandardMaterial({ color: 0x7a5236, roughness: 0.9 }), NT);
    const leaf = new T.InstancedMesh(new T.IcosahedronGeometry(1.5, 1), new T.MeshStandardMaterial({ color: 0x4f8f3e, roughness: 0.85, flatShading: true }), NT);
    const om = new T.Object3D();
    for (let k = 0; k < NT; k++) {
      const a = (k / NT) * TAU;
      const r = PLAZA + 3 + (k % 2) * 2.2;
      const s = 0.85 + rnd() * 0.45;
      om.position.set(Math.cos(a) * r, 0.8 * s, Math.sin(a) * r);
      om.scale.setScalar(s);
      om.updateMatrix();
      trunk.setMatrixAt(k, om.matrix);
      om.position.y = 2.3 * s;
      om.scale.set(s, s * 1.15, s);
      om.updateMatrix();
      leaf.setMatrixAt(k, om.matrix);
    }
    trunk.castShadow = leaf.castShadow = true;
    scene.add(trunk, leaf);

    const sun = new T.DirectionalLight(0xfff1d6, 2.5);
    sun.position.set(26, 42, 16);
    sun.castShadow = true;
    sun.shadow.mapSize.set(2048, 2048);
    sun.shadow.bias = -0.0006;
    sun.shadow.normalBias = 0.02;
    sun.shadow.camera.near = 1;
    sun.shadow.camera.far = 140;
    scene.add(sun, sun.target, new T.HemisphereLight(0xdfeeff, 0x6b5a45, 1.15));

    // ── 화면 위 층 (HUD · 구운 텍스처) ──
    const ui = new T.Scene();
    const uiCam = new T.OrthographicCamera(0, 1, 1, 0, -1, 1);
    const hudCv = document.createElement('canvas');
    hudCv.width = 620;
    hudCv.height = 150;
    const hudTex = new T.CanvasTexture(hudCv);
    hudTex.colorSpace = T.SRGBColorSpace;
    const uiMat = (map: THREE.Texture | null, color = 0xffffff, opacity = 1): THREE.MeshBasicMaterial =>
      new T.MeshBasicMaterial({ map, color, transparent: true, opacity, depthTest: false, depthWrite: false, toneMapped: false });
    const hud = new T.Mesh(new T.PlaneGeometry(1, 1), uiMat(hudTex));
    const pnl = bakedPanel(bk);
    const pnlTex = new T.CanvasTexture(pnl.canvas);
    pnlTex.colorSpace = T.SRGBColorSpace;
    pnlTex.minFilter = T.LinearFilter;
    pnlTex.generateMipmaps = false;
    const panel = new T.Mesh(new T.PlaneGeometry(1, 1), uiMat(pnlTex));
    const marker = new T.Mesh(new T.PlaneGeometry(1, 1), uiMat(null, 0xffffff, 0.95));
    ui.add(hud, panel, marker);

    let card = true;
    let n = CROWD_CARD;
    let tt = 0;
    let lastN = CROWD_CARD;
    let fps = 0;
    let fpsAcc = 0;
    let fpsN = 0;
    let lastRender = 0;
    let calls = 0;
    let cpuMs = 0;
    let hudT = 1;
    const makeMs = performance.now() - tMake;
    assignClips();

    const drawHud = (): void => {
      const g = hudCv.getContext('2d')!;
      g.clearRect(0, 0, hudCv.width, hudCv.height);
      g.fillStyle = 'rgba(8,10,20,.74)';
      g.beginPath();
      g.roundRect(0, 0, hudCv.width, hudCv.height, 14);
      g.fill();
      g.textBaseline = 'middle';
      const shown = st.skin ? Math.min(n, pool.length) : n;
      g.fillStyle = st.skin ? '#ffb36b' : '#8ff0b4';
      g.font = `800 30px ${FONT}`;
      g.fillText(`${shown.toLocaleString('ko-KR')}명 · 그리기 ${calls.toLocaleString('ko-KR')}번`, 18, 34);
      g.font = `600 19px ${FONT}`;
      g.fillStyle = '#e8ecff';
      g.fillText(st.skin ? 'SkinnedMesh + AnimationMixer 를 사람마다 — 몸 · 그림자 따로따로 (비교)' : `군중 = InstancedMesh 하나 → 몸 1번 + 그림자 1번 (나머지 ${Math.max(0, calls - 2)}번은 광장)`, 18, 76);
      g.fillStyle = '#9fb0e8';
      g.font = `500 17px ${MONO}`;
      const extra = st.skin && pool.length < Math.min(n, SKIN_MAX) ? ` · 만드는 중 ${pool.length}/${Math.min(n, SKIN_MAX)}` : st.skin && n > SKIN_MAX ? ' · 최대 1,000명' : '';
      g.fillText(card ? `처음 만들기 ${makeMs.toFixed(0)} ms (굽기 ${bakeMs.toFixed(1)} ms)` : `${fps.toFixed(0)} fps · CPU ${cpuMs.toFixed(2)} ms · 처음 만들기 ${makeMs.toFixed(0)} ms${extra}`, 18, 116);
      hudTex.needsUpdate = true;
    };

    const controls: Control[] = [
      { type: 'range', label: '인원', min: 200, max: CROWD_MAX, step: 100, value: CROWD_MAX, on: (v) => (st.count = v) },
      { type: 'range', label: '뛰기 비율', min: 0, max: 1, step: 0.05, value: st.run, on: (v) => (st.run = v) },
      { type: 'range', label: '손 흔들기 비율', min: 0, max: 1, step: 0.05, value: st.wave, on: (v) => (st.wave = v) },
      { type: 'toggle', label: 'SkinnedMesh 로 그렸다면 (비교, 최대 1,000명)', value: false, on: (v) => (st.skin = v) },
      { type: 'toggle', label: '구운 텍스처 보기', value: true, on: (v) => (st.tex = v) },
      { type: 'toggle', label: '박자 모두 똑같이 (비교)', value: false, on: (v) => (st.sync = v) },
    ];

    return {
      scene,
      camera: cam,
      controls,
      resize(w) {
        card = w / Math.min(devicePixelRatio, 2) < 500;
      },
      update(t, dt) {
        const t0 = performance.now();
        n = card ? Math.min(st.count, CROWD_CARD) : Math.round(st.count);
        const R = rad(n);
        if (n > lastN) for (let i = lastN; i < n; i++) place(i, R);
        lastN = n;
        assignClips();
        tt += dt;
        U.uTime!.value = tt;
        U.uSync!.value = st.sync ? 1 : 0;
        // 걷기: 천천히 방향이 흔들리고, 광장 밖 · 분수 안쪽으로 가면 돌아선다
        const arr = crowd.instanceMatrix.array as Float32Array;
        for (let i = 0; i < n; i++) {
          const v = vel[i]!;
          let h = hd[i]!;
          const x = px[i]!;
          const z = pz[i]!;
          if (v > 0) {
            h += dt * Math.sin(tt * wf[i]! + wp[i]!) * 0.35;
            const d2 = x * x + z * z;
            const out = d2 > R * R;
            if (out || d2 < 4.6 * 4.6) {
              let diff = Math.atan2(out ? -x : x, out ? -z : z) - h;
              diff = Math.atan2(Math.sin(diff), Math.cos(diff));
              const turn = (v > 2 ? 2.2 : 1.4) * dt;
              h += Math.max(-turn, Math.min(turn, diff));
            }
            hd[i] = h;
            px[i] = x + Math.sin(h) * v * dt;
            pz[i] = z + Math.cos(h) * v * dt;
          }
          const s = sc[i]!;
          const cs = Math.cos(h) * s;
          const sn = Math.sin(h) * s;
          const o = i * 16;
          arr[o] = cs;
          arr[o + 1] = 0;
          arr[o + 2] = -sn;
          arr[o + 3] = 0;
          arr[o + 4] = 0;
          arr[o + 5] = s;
          arr[o + 6] = 0;
          arr[o + 7] = 0;
          arr[o + 8] = sn;
          arr[o + 9] = 0;
          arr[o + 10] = cs;
          arr[o + 11] = 0;
          arr[o + 12] = px[i]!;
          arr[o + 13] = 0;
          arr[o + 14] = pz[i]!;
          arr[o + 15] = 1;
        }
        crowd.count = n;
        crowd.instanceMatrix.clearUpdateRanges();
        crowd.instanceMatrix.addUpdateRange(0, n * 16);
        crowd.instanceMatrix.needsUpdate = true;
        crowd.visible = !st.skin;
        skinGroup.visible = st.skin;
        if (st.skin) {
          const want = Math.min(n, SKIN_MAX);
          growPool(want);
          for (let i = 0; i < pool.length; i++) {
            const p = pool[i]!;
            p.root.visible = i < want;
            if (i >= want) continue;
            const k = Math.round(aAnim.getX(i));
            if (p.clip !== k) {
              p.mixer.stopAllAction();
              const a = p.mixer.clipAction(clips[k]!);
              a.play();
              a.time = aAnim.getY(i) * clips[k]!.duration;
              a.timeScale = aAnim.getZ(i);
              p.clip = k;
            }
            p.mixer.update(dt);
            p.root.position.set(px[i]!, 0, pz[i]!);
            p.root.rotation.y = hd[i]!;
            p.root.scale.setScalar(sc[i]!);
          }
        }
        // 광장 크기에 맞춘 카메라 · 그림자 범위
        const a = t * 0.045 + 0.6;
        const dist = R * 0.95 + 7;
        cam.position.set(Math.sin(a) * dist, R * 0.42 + 3.5, Math.cos(a) * dist);
        cam.lookAt(0, 0.4, 0);
        (scene.fog as THREE.Fog).near = dist * 0.9;
        (scene.fog as THREE.Fog).far = dist * 2.6 + 20;
        const sc2 = sun.shadow.camera;
        const ext = R + 6;
        if (sc2.right !== ext) {
          sc2.left = -ext;
          sc2.right = ext;
          sc2.top = ext;
          sc2.bottom = -ext;
          sc2.updateProjectionMatrix();
        }
        cpuMs = cpuMs * 0.9 + (performance.now() - t0) * 0.1;
        hudT += dt;
      },
      render(renderer, w, h) {
        const now = performance.now();
        if (lastRender) {
          fpsAcc += (now - lastRender) / 1000;
          fpsN++;
          if (fpsAcc > 0.5) {
            fps = fpsN / fpsAcc;
            fpsAcc = 0;
            fpsN = 0;
          }
        }
        lastRender = now;
        renderer.render(scene, cam);
        calls = renderer.info.render.calls; // 그림자 그리기까지 든 값 (render 시작에 0 으로)
        if (hudT > 0.25) {
          hudT = 0;
// … (64줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

### bakeBones — `src/demos/demosWebGPU.ts:1904`
```ts
function bakeBones(T: T3, mesh: THREE.SkinnedMesh, bones: THREE.Bone[], clips: THREE.AnimationClip[]): Baked {
  const nb = bones.length;
  const info = clips.map((c) => ({ start: 0, frames: Math.round(c.duration * BAKE_FPS), dur: c.duration }));
  let rows = 0;
  for (const c of info) {
    c.start = rows;
    rows += c.frames;
  }
  const data = new Float32Array(nb * 4 * rows * 4);
  const mixer = new T.AnimationMixer(mesh);
  const inv = mesh.skeleton.boneInverses;
  const m = new T.Matrix4();
  clips.forEach((clip, k) => {
    const ci = info[k]!;
    mixer.stopAllAction();
    mixer.clipAction(clip).play();
    for (let f = 0; f < ci.frames; f++) {
      mixer.setTime((f / ci.frames) * ci.dur);
      mesh.updateMatrixWorld(true);
      const row = ci.start + f;
      for (let b = 0; b < nb; b++) {
        m.multiplyMatrices(bones[b]!.matrixWorld, inv[b]!);
        data.set(m.elements, (row * nb + b) * 16);
      }
    }
  });
  mixer.stopAllAction();
  mixer.uncacheRoot(mesh);
  // 굽기가 끝나면 기준 캐릭터는 바인드 자세로 (비교 복제가 이 자세에서 시작)
  mesh.skeleton.pose();
  return { data, width: nb * 4, rows, clips: info };
}
```

### crowdGeometry — `src/demos/demosWebGPU.ts:1710`
```ts
function crowdGeometry(T: T3): THREE.BufferGeometry {
  const parts: THREE.BufferGeometry[] = [];
  type WFn = (y: number) => [number, number, number, number];
  const rigid =
    (b: number): WFn =>
    () => [b, 1, b, 0];
  // 관절: J ± half 사이는 위 뼈 · 아래 뼈를 선형으로 나눠 가진다 (안 나누면 굽힐 때 팔다리가 찢어진다)
  const joint =
    (up: number, low: number, J: number, half: number): WFn =>
    (y) => {
      if (y >= J + half - 1e-4) return [up, 1, low, 0];
      if (y <= J - half + 1e-4) return [low, 1, up, 0];
      const t = (y - (J - half)) / (2 * half);
      return [up, t, low, 1 - t];
    };
  const add = (w: number, h: number, d: number, segH: number, cx: number, y0: number, cz: number, col: number, tint: number, wf: WFn): void => {
    const g = new T.BoxGeometry(w, h, d, 1, segH, 1);
    g.translate(cx, y0 + h / 2, cz);
    const pos = g.getAttribute('position') as THREE.BufferAttribute;
    const n = pos.count;
    const si = new Uint16Array(n * 4);
    const sw = new Float32Array(n * 4);
    const cc = new Float32Array(n * 3);
    const tn = new Float32Array(n).fill(tint);
    const c = new T.Color(col);
    for (let i = 0; i < n; i++) {
      const [a, wa, b, wb] = wf(pos.getY(i));
      si[i * 4] = a;
      si[i * 4 + 1] = b;
      sw[i * 4] = wa;
      sw[i * 4 + 1] = wb;
      cc[i * 3] = c.r;
      cc[i * 3 + 1] = c.g;
      cc[i * 3 + 2] = c.b;
    }
    g.setAttribute('skinIndex', new T.Uint16BufferAttribute(si, 4));
    g.setAttribute('skinWeight', new T.Float32BufferAttribute(sw, 4));
    g.setAttribute('color', new T.Float32BufferAttribute(cc, 3));
    g.setAttribute('aTint', new T.Float32BufferAttribute(tn, 1));
    parts.push(g);
  };
  const SKIN = 0xf3c9a2;
  // 몸통 (아래 줄은 엉덩이 뼈) · 골반
  add(0.32, 0.36, 0.2, 3, 0, 0.5, 0, 0xffffff, 1, joint(1, 0, 0.56, 0.06));
  add(0.3, 0.1, 0.19, 1, 0, 0.44, 0, 0xffffff, 2, rigid(0));
  // 다리 (무릎 0.27 에서 반반) · 신발
  for (const [sx, up, low] of [
    [-1, 7, 8],
    [1, 9, 10],
  ] as [number, number, number][]) {
    add(0.13, 0.46, 0.15, 4, sx * 0.08, 0.04, 0, 0xffffff, 2, joint(up, low, 0.27, 0.115));
    add(0.14, 0.06, 0.21, 1, sx * 0.08, 0, 0.025, 0x3a2f2c, 0, rigid(low));
  }
  // 팔 (팔꿈치 0.66 에서 반반) · 손
  for (const [sx, up, low] of [
    [-1, 3, 4],
    [1, 5, 6],
  ] as [number, number, number][]) {
    add(0.09, 0.36, 0.1, 4, sx * 0.215, 0.48, 0, 0xffffff, 1, joint(up, low, 0.66, 0.09));
    add(0.08, 0.07, 0.09, 1, sx * 0.215, 0.42, 0, SKIN, 0, rigid(low));
  }
  // 머리 · 머리카락 · 눈 · 볼
  add(0.28, 0.28, 0.27, 1, 0, 0.86, 0, SKIN, 0, rigid(2));
  add(0.3, 0.08, 0.29, 1, 0, 1.1, -0.005, 0xffffff, 3, rigid(2));
  add(0.3, 0.17, 0.06, 1, 0, 0.95, -0.125, 0xffffff, 3, rigid(2));
  for (const sx of [-1, 1]) {
    add(0.04, 0.06, 0.02, 1, sx * 0.065, 0.97, 0.14, 0x1d1a22, 0, rigid(2));
    add(0.05, 0.025, 0.01, 1, sx * 0.1, 0.925, 0.138, 0xf2938f, 0, rigid(2));
  }
  const geo = mergeGeometries(parts)!;
  parts.forEach((p) => p.dispose());
  return geo;
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [인스턴싱 (InstancedMesh)](https://ai-techstudio.web.app/ai/t/u36.md) `u36` · [애니메이션 섞기 (가만히 ↔ 걷기 ↔ 뛰기)](https://ai-techstudio.web.app/ai/t/i464.md) `i464`
- 다음에 해 볼 기술: [절차 걷기 · 뛰기 (애니메이션 파일 없이)](https://ai-techstudio.web.app/ai/t/i458.md) `i458` · [노드 재질 (TSL — 코드로 셰이더 짜기)](https://ai-techstudio.web.app/ai/t/i494.md) `i494`
- 참고 문서: [three.js 소스 — skinning_vertex.glsl.js (기본 스키닝 식)](https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/skinning_vertex.glsl.js) · [three.js 예제 — webgl_instancing_dynamic](https://threejs.org/examples/#webgl_instancing_dynamic)
