【Unity WebGL】MediaPipeで3Dポーズを動かす
投稿日: 2026年5月6日(2026年10月5日更新)
対象読者: Unity で WebGL アプリを作りたいエンジニア / MediaPipe を Web に組み込みたい方
この記事はこんな方向け:
- MediaPipe で取った関節の座標を、Unity の3Dモデルに渡す方法を知りたい方
- Unity WebGL と JavaScript の間でデータをやり取りする方法(SendMessage と jslib)を知りたい方
- 座標を渡したのに、3Dモデルの腕や脚がおかしな向きになる方
全体像を先に
PoseMirror(写真から3Dデッサン人形のポーズを作るアプリ)は、次の順に処理しています。
- ブラウザの JavaScript で、MediaPipe の PoseLandmarker が写真から33点の座標を取る
- 座標を、腰の中点を原点にした3D座標に組み直す
- 33点を JSON 文字列にして、
SendMessageで Unity の C# に渡す - C# で、骨ごとに「向ける方向」と「上方向」を計算して回し、可動域を超えた分を戻す
難しいのは4です。点と点を結ぶ方向だけでは、骨がその方向を軸にどれだけねじれているかが決まりません。
はじめに
PoseMirror は、MediaPipe(JavaScript)で取った姿勢を、Unity WebGL(C#)の3D人形に反映するアプリです。この記事では、その受け渡しの実装を、PoseMirror のコードから抜き出して説明します。
実装①:MediaPipe で33点を取る
MediaPipe は、Tasks API(@mediapipe/tasks-vision)の PoseLandmarker を使います。写真1枚を解析するので、runningMode は IMAGE にします。モデルは精度の高い heavy を使っています。
import { PoseLandmarker, FilesetResolver } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0";
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/wasm"
);
const poseLandmarker = await PoseLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_heavy/float16/1/pose_landmarker_heavy.task",
delegate: "GPU",
},
runningMode: "IMAGE",
});
const result = poseLandmarker.detect(imgElement);
// result.landmarks[0] … 画像の幅・高さを1とした33点
// result.worldLandmarks[0] … 腰の中点を原点にした、メートル単位の33点
heavy のモデルは 30.7MB あるので、写真を選ぶまで読み込まないようにしています(パフォーマンス改善の記事を参照)。33点の番号と出力の意味は、MediaPipe Pose 33ランドマーク完全ガイドにまとめています。
実装②:3D座標に組み直す
MediaPipe は、メートル単位の3D座標(worldLandmarks)も返します。ただ、これをそのまま3Dモデルに使うと、肘の角度が写真とずれました。写真から作ったポーズ20件で、肘の角度は写真の見た目より平均 +34.1° ずれています。
そこで PoseMirror では、画像上の座標(landmarks)をピクセル単位に戻し、腰の中点を原点にして組み直しています。奥行きの z だけは 1/3 に縮めます。最後に、worldLandmarks の腰幅に合わせて大きさをメートル単位にそろえます。同じ20件で、肘のずれは平均 +10.3° まで減りました。
// pose-keypoints3d.js(抜粋)
const px = imageLandmarks.map(lm => [lm.x * imgW, lm.y * imgH, (lm.z * imgW) / 3]);
const hip = [0, 1, 2].map(i => (px[23][i] + px[24][i]) / 2); // 左右の腰の中点
const rel = px.map(p => [p[0] - hip[0], p[1] - hip[1], p[2] - hip[2]]);
// scale = worldLandmarks の腰幅 ÷ rel の腰幅
return rel.map((p, i) => ({ x: p[0] * scale, y: p[1] * scale, z: p[2] * scale,
visibility: imageLandmarks[i].visibility ?? 1.0 }));
z を縮める理由と、写真で比べた結果はランドマークの記事に図で載せています。
実装③:SendMessage でUnity に渡す
JavaScript から Unity の C# を呼ぶには、SendMessage を使います。
unityInstance.SendMessage("PoseBootstrap", "LoadPoseFromJson",
JSON.stringify({ landmarks })); // landmarks は33点の配列
1つ目の引数はシーン上の GameObject の名前、2つ目はそこに付いたスクリプトのメソッド名です。3つ目に渡せる値は、文字列か数値のどちらか1つです(値なしでも呼べます)。配列やオブジェクトは渡せないので、33点は JSON 文字列にまとめて送ります。
C# 側では、JsonUtility で受け取ります。
public void LoadPoseFromJson(string json)
{
var frame = JsonUtility.FromJson<PoseFrame>(json); // landmarks: List<NormalizedLandmark>
if (frame != null && frame.landmarks != null && frame.landmarks.Count > 0)
SetBodies(frame);
}
実装④:骨ごとに方向と上方向を決めて回す
座標の符号をそろえる
MediaPipe の座標は、画像の下向きが y の正です。C# に入った座標は、x、y、z の符号をすべて反転してから使います。
public static Vector3 ToUnityVector3(NormalizedLandmark lm, float scale)
=> new Vector3(-lm.x * scale, -lm.y * scale, -lm.z * scale);
方向だけでは、骨のねじれが決まらない
上腕なら「肩から肘」の方向に骨を向ければよさそうに見えます。ところが、方向が決まっても、骨がその方向を軸にどれだけ回っているか(ねじれ)は決まりません。Quaternion.LookRotation の上方向に、いつも Vector3.up を渡すとします。すると、腕を真上に上げたときに向ける方向と上方向が重なり、ねじれが決まらなくなります。
PoseMirror では、骨ごとに「上方向」を別に計算して渡しています。腕の場合は次のとおりです。
| 骨 | 向ける方向 | 上方向の決め方 |
|---|---|---|
| 上腕 | 肩 → 肘 | 体の横方向と胸の向きを基準に、ひねりすぎない範囲で決める |
| 前腕 | 肘 → 手首 | 手のひらの面(手首・小指・人差し指の3点)から決める。3点が一直線に近いときや信頼度が低いときは、上腕から引き継ぐ |
| 手 | 手首 → 人差し指 | 手のひらの面から決める |
// PoseModelController.Arms.cs(抜粋)
Vector3 upperDir = (pElbow - pShoulder).normalized;
Vector3 lowerDir = (pWrist - pElbow).normalized;
Vector3 rawHandUp = isRight
? Vector3.Cross(pPinky - pWrist, pIndex - pWrist)
: Vector3.Cross(pIndex - pWrist, pPinky - pWrist); // 左右で外積の順番を入れ替える
外積の順番を左右で入れ替えないと、片方の手だけ手のひらが裏返ります。この符号の問題は、脚や前腕が180°ねじれる原因の記事で詳しく扱っています。
回したあとに、骨の軸の違いと可動域を補正する
方向と上方向が決まったら、LookRotation で回転を作ります。そのあと2つの補正を通してから、骨に設定します。
private void RotateBone(Transform bone, Vector3 forward, Vector3 up, RigPart part, bool applyLimit)
{
if (!TryLookRotation(forward, up, out var rotation)) return;
Quaternion world = rig.Fix(part, rotation); // モデルの骨の軸の向きに合わせる
SetBoneRotation(bone, applyLimit ? ApplyJointLimit(part, bone, world) : world); // 可動域の外は戻す
}
1つ目の rig.Fix は、モデルごとに違う骨の軸の向きに合わせる補正です。骨のどの軸が「前」を向いているかは、モデルによって違います。2つ目の ApplyJointLimit は、肘が逆に曲がるような、人の体ではありえない角度を戻す処理です。
足首とつま先には、同じ回転を設定しています。MediaPipe には、つま先だけを曲げた情報がないためです。以前は足首だけ可動域で戻し、つま先はそのままにしていました。そのせいで、用意したポーズ369件のすべてで、つま先が足から最大157°折れていました。
C# から JavaScript を呼ぶ(jslib)
逆向きに、C# から JavaScript を呼ぶ場面もあります。PoseMirror では、取り消し(アンドゥ)の残り回数をページのボタンに反映するときや、男性モデルの読み込み状態を伝えるとき、ファイルをダウンロードさせるときに使っています。Assets/Plugins/ に .jslib を置き、C# から DllImport("__Internal") で呼びます。
// Assets/Plugins/WebGLFileHelper.jslib(抜粋)
mergeInto(LibraryManager.library, {
PMHistoryChanged: function (canUndo, canRedo) {
if (typeof window.pmOnHistoryChanged === "function")
window.pmOnHistoryChanged(!!canUndo, !!canRedo);
},
});
[DllImport("__Internal")]
private static extern void PMHistoryChanged(bool canUndo, bool canRedo);
公開するとき
Unity WebGL を Firebase Hosting で公開するときは、Unity の Decompression Fallback を有効にしてビルドしています。firebase.json に書いた Content-Encoding のヘッダーが、指定どおりには返らないためです。手順と実測はFirebase で公開する記事にまとめました。
まとめ
MediaPipe の座標を Unity に渡すところまでは、SendMessage に JSON 文字列を渡すだけで済みます。手間がかかるのは、その先の骨の回し方です。方向だけで骨を向けると、ねじれが決まらずに腕や脚が裏返ります。上方向を骨ごとに計算し、骨の軸の違いと可動域を補正してから回すと、写真に近い形で付きました。腕や脚がおかしな向きになるときは、まず上方向に何を渡しているかを確かめてください。
この連携で動いているのが、PoseMirror です。
関連記事:
- 【Unity×MediaPipe】脚や前腕が180°ねじれる原因:外積の符号と前方の基準
- 【Unity】ローカル座標で関節を回転:方向を保ったままのねじり
- MediaPipe Pose 33ランドマーク完全ガイド:33点の番号と、奥行き z のずれ