最後のピースです。パート1〜3で、アバターは私の動きに合わせて体を動かせるようになりました——けれどそれはマネキンでした。指はなく、顔は無表情で、スムージングも「わざと単純なまま」。パート4では、顔と手、そしてそれらを「見られる」ものにするスムージングを加えます。3つのMediaPipeモデル(体・顔・手)が、1つのVRMを単一のレンダーループで動かします。コードはfilter.ts・expression.ts・hands.tsにあり、GitHubで公開しています。(動画・コードの画面表示は英語です。)

目次
ジッターの罠と One Euro フィルター
生のランドマークはフレームごとに揺れます。素朴なローパスフィルターという分かりやすい対策は、しかし罠です。揺れを消すほど強くかけるとアバターは遅延し、弱くかけると震えが残る。この二律背反を1つの設定では勝てません——2つの問題は異なる速度に存在するからです。One Euro フィルター(Casiez, Roussel & Vogel, CHI 2012)は、設定そのものを変えます。値がどれだけ速く動いているかを見て、カットオフを速度に合わせて上げるのです。止まっていれば強くかけ、静止した手の震えを止める。速く動けば信号を通し、遅延を出さない。
One Euro フィルターをコードで
約40行、定数は2つ。カットオフは minCutoff + beta·|速度|。そして速度の推定値自体もローパスして、1つのノイズサンプルでゲートが開かないようにします。
export class OneEuroFilter {
constructor(
private minCutoff = 1.2, // 静止時のカットオフ — 低いほど安定・遅延
private beta = 0.02, // 速度がカットオフをどれだけ上げるか
private dCutoff = 1.0 // 速度推定そのもののカットオフ
) {}
private static alpha(cutoff: number, dt: number): number {
const tau = 1 / (2 * Math.PI * cutoff);
return 1 / (1 + tau / dt);
}
filter(value: number, dt: number): number {
if (!this.started) { this.started = true; this.previous = value; return value; }
// 速度。1つのノイズでゲートが開かないよう、速度自体もローパス。
const rawDerivative = (value - this.previous) / dt;
this.derivative += OneEuroFilter.alpha(this.dCutoff, dt) * (rawDerivative - this.derivative);
// カットオフは速度で上がる:遅いときは強く、速いときは通す。
const cutoff = this.minCutoff + this.beta * Math.abs(this.derivative);
this.previous += OneEuroFilter.alpha(cutoff, dt) * (value - this.previous);
return this.previous;
}
}
ボーンではなく「源」で平滑化する
どこでかけるかは、どのフィルターかよりも重要です。パート3ではスムージングをボーンの回転——パイプラインの末端——に置きました。今回はランドマーク、つまり入口でかけます。1ランドマークにつき3つのフィルター(x・y・z)。ジッターは測定の性質なので、源で掃除すれば、その後に導くあらゆる角度がきれいな信号を受け継ぎ、リターゲティングは渡された数値の純粋な関数のままでいられます。
// ランドマークごと・軸ごとに1フィルター — リターゲティングの前に列を平滑化。
export class LandmarkFilter {
apply<T extends Point3>(points: T[], dt: number): T[] {
for (let i = 0; i < points.length; i++) {
const f = this.axes[i] ??= [this.make(), this.make(), this.make()];
const p = points[i];
p.x = f[0].filter(p.x, dt);
p.y = f[1].filter(p.y, dt);
p.z = f[2].filter(p.z, dt);
}
return points;
}
}
顔 → VRMの表情
ポーズモデルは頭部全体に対して粗い4点しか返しません。顔モデルはその横で走る2つ目のモデルで、コストに見合います:ARKit と同じ52個のブレンドシェイプ——jawOpen、左右それぞれのまばたき、口角、眉——をすべて個別に返します。VRMは独自の小さな表情セットを定義するので、2つの語彙を橋渡しする必要があります:jawOpen は「あ」の口へ、両口角の平均は happy へ、眉を下げると angry へ。
// MediaPipe の52ブレンドシェイプ → VRM 自身の表情チャンネル。
// gain はスコアが1.0に届きにくいのを補正、floor は静止した顔を基準に戻す。
const MAPPING = [
{ vrm: "blink", from: ["eyeBlinkLeft", "eyeBlinkRight"], gain: 1.15, floor: 0.4 },
{ vrm: "aa", from: ["jawOpen"], gain: 1.4, floor: 0.08 },
{ vrm: "happy", from: ["mouthSmileLeft", "mouthSmileRight"], gain: 1.6, floor: 0.12 },
{ vrm: "angry", from: ["browDownLeft", "browDownRight"], gain: 1.2, floor: 0.15 },
// …sad, surprised, そしてリップシンク用の母音
];
そして、必ずハマる落とし穴が1つ:笑うと頬が持ち上がり、それがまばたきのスコアを押し上げるので、笑顔のアバターが延々とまばたきします。頬のスクイント分をまばたきから引けば止まります。(まばたき自体にも対の癖があります——アバターは鏡なので、左右の目はパート3の腕と同じく入れ替わります。)
手 — 開閉(bend)だけを取る
手は3つ目のモデルで、片手21点。4本の指については、曲げ(bend)——各関節で出会う2つのセグメントの間の角度——だけを取り、そのボーン自身の折り曲げ軸まわりに適用します。横方向の開き(splay)は意図的に捨てます:曲げよりずっとノイズが多く、様式化された手ではほとんど見えないからです。静止した手も完全にはまっすぐではないので、関節ごとに小さな基準角度を引きます(最大は親指で、その第1関節は完全伸展でも約0.5ラジアンを示します)——アバターが常時半開きの手を保つのを防ぐためです。
// 指1本につき4関節。ノイズの多い splay は捨て、bend だけを残す。
const bends = [
angleBetween(lm[WRIST], lm[p1], lm[p2]),
angleBetween(lm[p1], lm[p2], lm[p3]),
angleBetween(lm[p2], lm[p3], lm[p4]),
];
for (let i = 0; i < 3; i++) {
const rest = isThumb ? REST_BEND.thumb[i] : REST_BEND.finger;
const curl = Math.min(MAX_CURL, Math.max(0, bends[i] - rest)) * scale;
this.drive(bone, curl, dt); // ボーンの折り曲げ軸まわりに回す
}

3つのモデル、1つのレンダーループ
つまり、毎ウェブカメラフレームで3つのニューラルネットワークが、ブラウザのタブの中で走る——これがこの回の正直なコストです。3つは1枚の映像フレームと1つのレンダーループを共有し、ライトモデルは小さく、それでもノートPCで30fpsを保ちます。ただしここが、フレーム予算を「当然ある」と仮定するのではなく監視し始める境目です。体・顔・手が同時に、ドロップフレーム0で動きます。
よくある質問
One Euro フィルターとは何で、なぜトラッキングに使うのですか?
速度適応のローパスフィルターです(Casiez, Roussel & Vogel, CHI 2012)。固定のカットオフではなく、信号が速くなるほどカットオフを上げます——値がほぼ静止しているときは強くかけ、速い動きでは信号を通す。これで「遅延か揺れか」の二律背反を、たった2つの定数で回避できます。だからノイズの多いインタラクティブ信号の定番の答えなのです。
ランドマークと最終ボーン回転、どちらを平滑化すべき?
入口、つまりランドマークを推奨します。ジッターは測定の性質なので、リターゲティングの前に掃除すれば、下流で導くすべての角度がきれいな信号を受け継ぎ、リターゲティングは入力の純粋な関数のままでいられます。最終回転だけを平滑化すると、途中の計算にはノイズが残ったままになります。
体・顔・手をブラウザで同時に動かせますか?
できます——同じ映像フレーム上で3つのMediaPipeモデルを、1つのレンダーループで。ここではノートPCで30fps、ドロップフレーム0でした。ライトモデルのおかげで実現できますが、毎フレーム3つのネットワークは、フレーム時間を「余裕がある」と仮定せず意識的に配分し始める境目です。
これがシリーズの完成
ウェブカメラ1台、ブラウザのタブ1つ、そしてあなたのように動き、見て、身振りするアバター——スーツも深度カメラもなく、何も端末の外に出ません。コードはGitHubにあり、エピソードごとにタグ付けしています(パート4は v4-face-hands)。はじめての方は、パート1:ブラウザ・モーションキャプチャ → パート2:VRMアバターの読み込み → パート3:リターゲティング → この完結編へ。
福島 崇(Takashi Fukushima) — Sports Science & Pose Estimation.
▶ YouTubeで登録 · GitHub · ウェブサイト · お問い合わせ