パート1では、ウェブカメラから骨格を取り出しました。パート2では、その骨格が動かす「相手」を用意します。本物の3D VRMアバターを、three.js と @pixiv/three-vrm でブラウザに読み込み、呼吸させます。この記事では、なぜVRMなのか、three.jsのシーン、15MBモデルで効く後処理、2行の呼吸、そして後の顔トラッキングを支える表情システムまでを解説します。コードはGitHubにあります。(動画・コードの画面表示は英語です。)

目次
なぜVRMか — 標準化されたボーンマップ
アバターの形式はVRMです。VRMはglTFの拡張ですが、決定的な追加が1つあります——標準化された人型ボーンマップです。どのVRMでもボーンの名前が同じで、「左上腕」は必ず左上腕。この保証こそが、次回書くリターゲティングのコードを、このアバターだけでなくどんなアバターでも動かせる理由です。標準のボーン名に対して一度だけ書けば、ユーザーが持ち込むどのVRMでもそのまま動きます。
three.js のシーン:カメラ・コントロール・ライト
シーンは素直な three.js です——レンダラ、上半身に寄せたカメラ(VTuberアプリはほぼ「ポートレート」で、追跡するのも上半身だけ)、OrbitControls、ライト2つ。1点だけ強調しておくと、preserveDrawingBuffer: true を有効にすると描画後もフレームが読み取れるので、後でキャンバスをスクリーンショットしたり録画したりできます。
this.renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true, // フレームを録画・スクショ可能に保つ
});
// 追跡するのは上半身だけなので、上半身に寄せる
this.camera = new THREE.PerspectiveCamera(30, 1, 0.1, 20);
this.camera.position.set(0, 1.3, 2.2);
this.controls = new OrbitControls(this.camera, canvas);
this.controls.target.set(0, 1.25, 0);
this.controls.enablePan = false;
const key = new THREE.DirectionalLight(0xffffff, 2);
key.position.set(1, 2, 2);
this.scene.add(key);
this.scene.add(new THREE.AmbientLight(0xffffff, 1.2));
three-vrm での読み込み(効いてくる後処理)
ここで @pixiv/three-vrm が真価を発揮します。標準の GLTFLoader にVRMプラグインを登録すると、解析済みのアバターが gltf.userData.vrm に入ってきます。続いて3つの後処理——約15MBのモデルでは最後の1つが本当に効きます。VRM 0.x系モデルを回転させて正面を揃え、使っていない頂点を削り、スケルトンを統合します。
this.loader = new GLTFLoader();
this.loader.register((parser) => new VRMLoaderPlugin(parser));
// …load() 内で:
const gltf = await this.loader.loadAsync(url);
const vrm = gltf.userData.vrm as VRM;
VRMUtils.rotateVRM0(vrm); // VRM 0.x は +Z 向き — 正面へ
VRMUtils.removeUnnecessaryVertices(vrm.scene); // GPUメモリを解放
VRMUtils.combineSkeletons(vrm.scene); // 15MBモデルで効く
this.vrm = vrm;
this.scene.add(vrm.scene);
2行で足す呼吸
動かないアバターは、壊れて見えます。そこで、トラッキングを繋ぐ前に呼吸を足します——胸のボーンにゆっくりしたサインカーブ、背骨にはもう少し小さい揺れ。たった2行で、モデルが生きているように見えます。ヒューマノイドの正規化されたボーンノードを介するので、リグの作り方に関わらず、どんなVRMでも動きます。
const breath = Math.sin(this.elapsed * 1.6);
const chest = humanoid.getNormalizedBoneNode("chest");
if (chest) chest.rotation.x = breath * 0.02; // 呼吸の上下
const spine = humanoid.getNormalizedBoneNode("spine");
if (spine) spine.rotation.y = Math.sin(this.elapsed * 0.5) * 0.04; // 揺れ
VRMの表情(後の布石)
VRMは表情も標準化しています——まばたき、笑顔、怒り、悲しみ、リラックス、そしてリップシンク用の母音。固定のセットを仮定するのではなく、読み込んだアバターにどれを持っているかを尋ね、それぞれにボタンを作ります。これがこの先の顔トラッキングを乗せる、まさにその仕組みです。

// アバターが対応している表情を尋ね、1つずつボタンを作る
const EXPRESSION_PRESETS = ["blink", "happy", "angry", "sad", "relaxed", "aa"];
const available = viewer.expressionNames; // vrm.expressionManager から
for (const name of EXPRESSION_PRESETS) {
if (!available.includes(name)) continue;
const btn = document.createElement("button");
btn.textContent = name;
btn.addEventListener("pointerdown", () => viewer.setExpression(name, 1));
btn.addEventListener("pointerup", () => viewer.setExpression(name, 0));
expressionsEl.appendChild(btn);
}
どんなVRMでも動く — ドラッグ&ドロップ
ボーンマップが標準化されているので、どんなVRMでも動きます。.vrmファイルをペインにドラッグ&ドロップするだけで読み込まれます——同じコードで、あなたのアバターが動く。特定のモデルではなく標準に対して作ることの、これが見返りです。
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
const file = e.dataTransfer?.files[0];
if (file?.name.toLowerCase().endsWith(".vrm")) loadAvatar(file);
});
よくある質問
VRMファイルとは何で、なぜアバターに使うのですか?
VRMは、標準化された人型ボーンマップと標準化された表情セットを持つ、glTFベースの3Dアバター形式です。ボーンと表情の名前がどのモデルでも一貫しているため、標準に対して書いたコードはどんなVRMでも動きます。これが「自分のアバターを持ち込む」を可能にします。
three.js でVRMをどう読み込みますか?
@pixiv/three-vrm の VRMLoaderPlugin を標準の three.js GLTFLoader に登録し、ファイルを読み込み、解析済みのアバターを gltf.userData.vrm から取り出します。その後、シーンに追加する前にVRMUtilsの後処理(回転・不要頂点の削除・スケルトン統合)を実行します。
静止したアバターが壊れて見えるのはなぜ?
人間は完全に静止することがないからです。まったく動かない3Dモデルは、固まった、あるいは死んでいるように見えます。控えめなアイドルアニメーション——胸のゆっくりした呼吸と背骨の小さな揺れ——を足すだけで、実際のトラッキングが繋がる前でも生きているように感じられます。
次回 — パート3
アバターと骨格は、いま画面に並んでいます——呼吸し表情を動かす本物のアバターと、本物の骨格が隣り合って。しかし、まだ繋がっていません。繋ぐところが一番の難所で、それがパート3:リターゲティング——ウェブカメラのランドマークをアバターのボーン回転へ写す——のすべてです。コードはGitHubにあります(パート2は v2-avatar でタグ付け)。まだの方は、パート1:MediaPipeによるブラウザ・モーションキャプチャからどうぞ。
福島 崇(Takashi Fukushima) — Sports Science & Pose Estimation.
▶ YouTubeで登録 · GitHub · ウェブサイト · お問い合わせ