動くと、アバターも動く。パート3はその集大成です。パート1でウェブカメラから取り出した骨格が、パート2のVRMアバターをついに動かします——ブラウザだけで、トラッキングスーツも深度カメラもなし。多くのチュートリアルが飛ばすところ、リターゲティング(ランドマークをボーンの回転へ)です。コードはretarget.tsにあり、GitHubで公開しています。(動画・コードの画面表示は英語です。)

目次
落とし穴:位置ではなく回転
リターゲティングを見た目より難しくしている落とし穴があります。MediaPipeが返すのは位置です——「ここが肘、ここが手首」。しかし骨格は位置ではなく回転でできています。ボーンをある点へ移動させることはできません。回転させることしかできず、子ボーンはそれについてきます。だから、すべてのランドマークを角度に変換する必要があります。retarget.tsの全体は、実のところ1つの考えの繰り返しです——ボーンが向くべき方向を取り、そこへ回す回転を求める。
2つの座標系(と鏡映し)
2つの世界は食い違っています。MediaPipeはメートル単位で、Xは画像の右、Yは下、Zはカメラから遠ざかる方向。VRM 1.0はYが上、Zは手前(見ている側)が正面です。YとZの反転はX軸まわりの180°回転——ここを間違えると、アバターが上下逆さや裏返しになります。さらにXも反転すると、ポーズが鏡映しになります。右手を上げるとアバターも画面の同じ側の手を上げる。鏡なので、自分の右側のランドマークがアバターの左のボーンを動かす——意図的な入れ替えです。
// MediaPipe(x右・y下・z奥)→ VRM(y上・z手前)、
// さらにxを反転して鏡のように。zTrust は深度を縮める。
private toAvatarSpace(lm: Landmark, out: Vector3, zTrust = Z_TRUST): Vector3 {
return out.set(-lm.x, -lm.y, -lm.z * zTrust);
}
// 鏡映し:人物の「右」肩がアバターの「左」になる
const avatarLeftShoulder = this.toAvatarSpace(world[LM.rightShoulder], a);
const avatarRightShoulder = this.toAvatarSpace(world[LM.leftShoulder], b);
基準の向き → 目標回転
いよいよ本題のリターゲティングです。どのボーンにも、正規化リグ上の基準の向きがあります——左腕は+X方向。ボーンが向くべき方向(肩→肘)を取り、基準を目標へ回す回転を求めます。three.jsなら setFromUnitVectors 1回で計算できます。この単一のプリミティブ——方向がクォータニオンになる——が、このファイルの心臓部です。
// 上腕:基準の向きを 肩→肘 ベクトルへ回す
c.subVectors(elbow, shoulder).normalize();
target.setFromUnitVectors(restDir, c); // 方向 → クォータニオン、1行
ワールド空間 → ローカル:チェーンをたどる
この回転はワールド空間のものですが、ボーンの回転は親からの相対で保存されます。そこで、チェーンを上へたどり、そのボーンの上位の回転を掛け合わせ、それを割り戻します(逆回転で premultiply)。正規化ヒューマノイドリグでは、どのボーンも基準姿勢でワールドと軸が揃っているので、チェーンに沿ってローカル回転を掛けるだけで、行列を触らずにその関節のワールド回転が得られます。前腕が巧いところ:親に今設定した上腕が含まれるため、出てくるのは肘の曲がりそのもの——腕に対する相対値です。
// 関節のワールド回転 = その上位のローカル回転の積
private chainQuaternion(chain: VRMHumanBoneName[], out: Quaternion): Quaternion {
out.identity();
for (const name of chain) {
const node = this.vrm?.humanoid.getNormalizedBoneNode(name);
if (node) out.multiply(node.quaternion);
}
return out;
}
// ワールド → ローカル:親の回転を剥がす
this.chainQuaternion(CHAINS.leftArm, parent);
target.premultiply(parent.invert());
this.driveBone(upper, target, alpha);
イージングと復帰
生のランドマークは揺れ、揺れるアバターは壊れて見えます。そこでボーンは目標へ一気に飛ばず滑らかに近づけます——1 - exp(-rate·dt) のスムージングにより、30fpsでも144fpsでも同じ感触です。そして手が画面から出たら、その腕は空中で固まらず、アバターの基準姿勢へ自然に戻ります。
const FOLLOW_RATE = 14; // 追跡中に目標を追う速さ
const RELEASE_RATE = 4; // 追跡を失ったとき自然位置へ戻る速さ
// フレームレート非依存のスムージング:30/144fpsで同じ感触
private ease(rate: number, delta: number): number {
return 1 - Math.exp(-rate * delta);
}
// 目標回転への球面補間として適用:
node.quaternion.slerp(target, this.ease(FOLLOW_RATE, delta));
深度の落とし穴(Zを信用しすぎない)
もう1つ——これで一晩溶かしました。両手を上げているのに、アバターが片腕を横へ投げ出し続けたのです。計算は正しかった。間違っていたのは入力でした。カメラ1台では奥行きを測れず、MediaPipeは推測します。左右対称のポーズなのに、片方の肘をもう片方の2倍も手前にあると返してきました。対策はZをXやYほど信用しないこと——Z_TRUST係数で縮めると、手足はカメラが実際に見たシルエットへ戻ります。そこがカメラの得意な部分です。

/**
* カメラ1台ではzを測れず、モデルは推測する——そしてそれは大きくずれる:
* 左右対称のポーズで片方の肘をもう片方の2倍も手前と報告することがある。
* zを縮めると、手足はカメラが実際に見たシルエットへ戻る。
*/
const Z_TRUST = 0.45; // out.set(-lm.x, -lm.y, -lm.z * Z_TRUST)
1つ注意点:頭だけは深度をそのまま使います。耳→鼻のベクトルはほぼZ方向を向くので、ここでZを縮めると、頭の向きが安定するどころか、あらゆる首振りが誇張されてしまいます。深度が当てにならないのは、ポーズがカメラに対しておおむね平ら(正面)なときだけです。
よくある質問
なぜアバターのボーンをランドマークの位置へそのまま動かせないのですか?
骨格は自由に浮かぶ点の集まりではなく、回転の階層だからです。ボーンは親の関節まわりにしか回転できず、子はそれに従います。だから各ランドマークは、テレポートする位置ではなく、ボーンが向くべき方向——つまり回転——へ変換する必要があります。
なぜアバターは自分をそのまま真似ず、鏡映しになるのですか?
X軸を反転すると鏡のように見え、画面上では自然だからです——右手を上げるとアバターの同じ側が上がる。副作用として、自分の右側のランドマークがアバターの左側のボーンを動かすため、左右の対応はコード上で意図的に入れ替えています。
なぜカメラ1台の深度はそれほど当てにならないのですか?
2Dカメラ1台には真の奥行き情報がなく、モデルがZを推測するためです。その推測が信号の中で最も弱い部分で、左右対称のポーズでは片方の手足を大きく前へ置いてしまうことがあります。カメラが実際に見ているX/Yのシルエットより Z を信用しない——これが安価で効果的な補正になります。
次回 — パート4
これがリターゲティングです——ウェブカメラがアバターを動かす、ブラウザだけで、トラッキングスーツも深度カメラもなく。まだ荒さは残ります——指のトラッキングも、顔からの表情もなく、スムージングもわざと単純なまま。それがパート4:スムージング・手・顔で、パート2で用意したVRMの表情システムを動かします。コードはGitHubにあります(パート3は v3-retargeting でタグ付け)。はじめての方は、パート1:MediaPipeによるブラウザ・モーションキャプチャ、続いてパート2:VRMアバターの読み込みからどうぞ。
福島 崇(Takashi Fukushima) — Sports Science & Pose Estimation.
▶ YouTubeで登録 · GitHub · ウェブサイト · お問い合わせ