ウェブカメラが1台。ブラウザのタブが1つ。スーツも、深度カメラも、グローブもなし。これがアバタートークの完成版です。ウェブカメラが、アバターの腕・頭・表情・指・手首を動かします。すべてクライアント側で完結し、映像はこのマシンから出ません。ここまでの4部(ランドマーク → アバター → リターゲティング → 顔+手)を経て、この最後の仕上げは、ほとんどがバグ取りと、数字に正直になる話でした。コードは GitHub にあります。

目次
1台のカメラ、1つのタブ — 完成版
4回前は空のページでした。いまは3つの MediaPipe モデル——体・顔・手——が1台のウェブカメラを読み、ブラウザの単一レンダーループで1つのVRMアバターを動かします。体モデルから腕と頭、顔モデルから52個のブレンドシェイプ、手モデルから片手21点。これらを毎フレーム1つにまとめます。インストールも、アップロードも、特別なハードもなし。この記事は、その最後の仕上げ——手首・親指・目・鏡——を辿り、実際にどれだけのコストで動くのかを正直に見ます。
腕の計算では届かない手首
まず手首から。ここにこの手法の限界が出ます。腕の計算が使うのは、肩から肘、肘から手首という向きだけ。でも向きにはひねりの情報がありません。手の位置を変えずに手の平を裏返しても、そのベクトルはどれも変わらない——だから腕の計算をいくら重ねても手首は回りません。手そのものに語らせます。手の平を横切る2本のベクトル、指に沿う向きと指の付け根を横切る向きで姿勢は完全に決まり、基準姿勢のペアを測ったペアに重ねる回転が、そのまま手首です。その一部は前腕に渡します。手の平を正面に向ける動きは、手首の関節ではなく、橈骨が尺骨をまたぐ動きだからです。
// 手の平を定義する2本のベクトル:指に沿う向きと、付け根を横切る向き。
const wrist = toAvatarSpace(lm[WRIST], a, 1);
const middle = toAvatarSpace(lm[MIDDLE_MCP], b, 1);
forward.subVectors(middle, wrist).normalize(); // 指に沿う向き
const index = toAvatarSpace(lm[INDEX_MCP], b, 1);
const pinky = toAvatarSpace(lm[PINKY_MCP], c, 1);
across.subVectors(pinky, index).normalize(); // 付け根を横切る向き
// 基準姿勢の基底を測定した基底に重ねる回転=手首。
basisMatrix(forward, across, measured, scratch);
target.copy(qMeasured).multiply(qRest.invert());
// 腕自身の回転を差し引き、残りを手のボーンに載せる。
chainWorldQuaternion(vrm, FOREARM_CHAINS[side], parent);
target.premultiply(parent.invert());
死んで見えた親指
親指は完全に死んでいるように見えました。でも動いてはいたんです——毎フレーム0.25ラジアンほどが、間違った軸に入っていました。4本の指は手に沿って並ぶので、ある1方向に曲がります。ところが親指は手の平を斜めに横切って生えているので、同じ軸で回すと内側に折れずに横へ開くだけ。解決は、軸を決め打ちせずリグから読むこと。各ボーンについて、子ボーンへの向きが実際の曲がる方向を教えてくれるので、親指も自分専用のヒンジを手に入れます。
// 各指ボーンの本当のヒンジを、決め打ちではなくリグから導く。
// 次のボーンのローカル位置がこのボーンの向き。「手の平の上」との外積が、
// 手の平側へ折り込む軸になる。
const dir = next.position.clone().normalize();
const axis = new THREE.Vector3().crossVectors(PALM_UP, dir);
if (axis.lengthSq() > 1e-8) this.curlAxes.set(bone, axis.normalize());
// 指は±xに沿うのでzまわりに曲がる。斜めに座る親指はここで別の軸を得る——
// だから今度は、横に開かず内側に折れる。
フレーム精度のタイミング — 再利用と計数
カメラとレンダーループは同じ拍では進みません。しかも MediaPipe は同じタイムスタンプを拒否します——一度でも渡すと、そのセッションの残りはグラフがエラーになる。そこで各トラッカーは、小さくて正直な2つのことをします。映像に新しいフレームが無いときは、前回の結果を再利用する。だからカメラフレームの合間も One Euro のイージングが滑らかに走り続け、カクつきません。そして推論が実際に例外を投げたときは、クラッシュせずに取りこぼしを数える。劣化したトラッキングは、死んだタブではなく、読める数字になります。
/** カメラフレームの合間は前回の結果を返し、イージングを継続させる。 */
update(): HandStream[] {
if (!this.landmarker || this.video.videoWidth === 0) return [];
if (this.video.currentTime === this.lastVideoTime) return this.last; // 再利用
this.lastVideoTime = this.video.currentTime;
// タイムスタンプを厳密に前進させる:繰り返しはグラフにとって致命的。
const timestamp = Math.max(this.lastTimestamp + 1, Math.round(performance.now()));
this.lastTimestamp = timestamp;
try {
const result = this.landmarker.detectForVideo(this.video, timestamp);
this.last = result.landmarks.map(/* … handedness + world … */);
return this.last;
} catch {
this.droppedFrames++; // クラッシュせず数える
return [];
}
}
ニュートラルな顔:0.3が新しいゼロ
アバターがずっと半目でした。原因は顔モデルの小さな真実です。実際に大きく開いた目でも、スコアは0ではない。3分の1ほど——eyeBlink で0.1〜0.3——で揺れていて、それがそのまま3分の1の瞬きとして入っていたのです。口角も完全にはゼロになりません。だから各表情は、ゲインをかける前に安静時スコアから基準化します:floor を引き、残りを0〜1に再スケールする。すると、リラックスした顔はニュートラルと読まれます。0.3が新しいゼロになり、それを超える動きだけが効きます。
// floor=安静時の顔がすでに出すスコア。基準化してから、ゲイン前に
// 残りの範囲が0..1を保つよう再スケールする。
const raw = sum / n;
let value = ((raw - floor) / (1 - floor)) * gain;
if (name.startsWith("blink")) value -= squint * 0.8; // 笑うと頬が上がり、誤検出の瞬きに
value = Math.min(1, Math.max(0, value));
// 例:blink は floor 0.4——0.3を出す開いた目は、きれいに0へクランプされる。
これは鏡 — すべての入れ替えは意図的
片目を閉じると、逆の目が閉じました。MediaPipe の左目はあなたの左目、VRM の左目はアバターの左目。素直に対応させると、解剖学的には完璧で、見た目には間違いです——アバターはこちらを向いているから。これがアプリ全体を貫く原則です:これは鏡。あなたの右腕がアバターの左腕を、右手が左手を動かし、左目は画面の同じ側の目を閉じさせる必要がある。どれも意図的な入れ替えで、目だけ、それを忘れていました。
// 手も腕と同じ:人の「右」手がアバターの左を動かし、
// アバター全体が鏡のように振る舞う。
for (const hand of hands) {
this.poseHand(hand, hand.handedness === "Right" ? "left" : "right", dt);
}
// 瞬きも同じ向きに入れ替える——目と目を素直に対応させると逆目がウインクする:
{ vrm: "blinkLeft", from: ["eyeBlinkRight"], gain: 1.15, floor: 0.4 },
{ vrm: "blinkRight", from: ["eyeBlinkLeft"], gain: 1.15, floor: 0.4 },
数字に正直になる
ここからは正直な話です。前のエピソードで私は「毎秒30フレーム出ています」と言いました。測っていませんでした。測ったら、およそ9フレーム——3つのニューラルネットが、毎フレーム、ブラウザのタブの中で動いているからです。ランドマークへの One Euro フィルターは、揺れをおよそ4分の1減らします——目分量ではなく実測です。使えるだけのリアルタイム性はある。でも、言った速さでは全くありませんでした。手を動かさずに前腕だけひねることはまだできず、奥行きは推測のまま、足は最初から呼ばれていません。それでも、タブの中で、カメラ1台で、ちゃんと動く——そしていまフレーム予算は、仮定するものではなく、見るものになりました。

よくある質問
なぜ腕の計算では手首を回せないのですか?
腕の計算が知っているのは向きだけ——肩から肘、肘から手首——で、向きにはひねりがないからです。手を動かさずに手の平を返しても、それらのベクトルは変わらず、ひねりは見えません。手そのものの姿勢を読む必要があります:手の平を横切る2本のベクトル(指に沿う向き、付け根を横切る向き)で姿勢が完全に決まり、基準姿勢へ重ねる回転が手首になります。
なぜ親指はほとんど動かなかったのですか?
間違った軸で折り曲げていたからです。4本の指は手に沿って並び、ある1方向に曲がりますが、親指は手の平を斜めに横切って座るので、同じ軸で回すと内側に折れずに横へ開いてしまいます。各ボーンのヒンジをリグから——子ボーンへの向きから——読むと、親指は自分専用の正しい軸を得て、追従し始めます。
ブラウザのウェブカメラ・モーキャプは本当に30fpsで動きますか?
正直に言うと、ここでは違いました。体・顔・手を同時に——3つの MediaPipe モデルを毎フレーム、1つのブラウザタブで——動かすと、このマシンでは実測およそ9fpsで、以前言った30ではありませんでした。One Euro フィルターは残る揺れをおよそ4分の1取り除きます。使えるだけのリアルタイム性はありますが、毎フレーム3つのネットワークは、仮定せず測るに値する実コストです。
シリーズ全体、タグごとに
ウェブカメラ1台、ブラウザのタブ1つ、そしてあなたのように動き・見つめ・身振りするアバター——スーツも、深度カメラも、マシンから出ていくものもなし。各エピソードは git タグになっているので、どの段階でも取り出して動かせます。シリーズは 第1回:ブラウザ・モーションキャプチャ → 第2回:VRMアバターの読み込み → 第3回:リターゲティング → 第4回:顔・手・フィルタリング → この完成版。全コードは GitHub に。
福島 崇(Takashi Fukushima) — スポーツ科学と姿勢推定。
▶ YouTubeで登録 · GitHub · ウェブサイト · お問い合わせ