はじめに
Avaturn は写真から 3D アバターを生成できるサービスです。生成されたアバターは GLB 形式でダウンロードでき、Idle(待機)アニメーション付きのモデルも取得できます。
しかし 歩行アニメーションは含まれていません。VR 空間でアバターを歩かせたい場合、別途アニメーションを用意する必要があります。
この記事では、既存の Mixamo 歩行アニメーションを Avaturn アバターに適用(リターゲット)する過程を記録します。3つのアプローチを試しました:
- Blender 5.0 CLI でオフライン変換
- Node.js で結果を比較検証
- Three.js ランタイムでブラウザ内リターゲット
前提知識
ボーン名の対応
Avaturn と Mixamo は同じ骨格構造(Humanoid)を使いますが、ボーン名が異なります:
| Mixamo | Avaturn |
|---|---|
mixamorig:Hips | Hips |
mixamorig:LeftArm | LeftArm |
mixamorig:Head | Head |
mixamorig: プレフィックスを外すだけで名前が一致します。
Rest Pose の違い
ボーン名が一致しても、rest pose(初期姿勢)のボーン回転が微妙に異なる場合があります。アニメーションのクォータニオン値は rest pose 基準なので、そのまま適用すると姿勢が崩れます。
アプローチ 1: Blender 5.0 CLI
Blender 5.0 の Layered Action API
Blender 5.0 でアニメーション API が大幅に変わりました。従来の action.fcurves は使えなくなり、レイヤー構造になっています。
# Blender 4.x(旧)
fcurves = action.fcurves
# Blender 5.0(新)
fcurves = action.layers[0].strips[0].channelbags[0].fcurves
キーフレームの一括挿入も insert() ではなく foreach_set() + update() を使う必要があります:
fc = channelbag.fcurves.new(data_path, index=0)
fc.keyframe_points.add(num_keys)
fc.keyframe_points.foreach_set('co', [frame0, val0, frame1, val1, ...])
fc.update() # 必須:ソート&重複削除
スクリプトの流れ
/Applications/Blender.app/Contents/MacOS/Blender --background --python scripts/retarget-anim.py
# 1. Mixamo モデルをインポートし、アニメーションの fcurve データを抽出
src_fcs = mixamo_action.layers[0].strips[0].channelbags[0].fcurves
for fc in src_fcs:
# bone名、プロパティ、キーフレームデータを保存
fc.keyframe_points.foreach_get('co', co_array)
# 2. Avaturn モデルをインポートし、rest pose を取得
src_rest = bone.matrix_local.to_quaternion() # armature 空間
# 3. ボーン名をリネームし、rest pose 補正を適用
avaturn_bone = mixamo_bone.replace("mixamorig:", "")
# 体幹ボーンは補正スキップ(Hips, Spine系, Neck, Head)
if skip_correction:
q_corrected = q_src # そのままコピー
else:
# 四肢は rest pose 差分を補正
src_rest_local = src_parent_world.inverted() @ src_world
tgt_rest_local = tgt_parent_world.inverted() @ tgt_world
q_corrected = tgt_rest_local.inverted() @ src_rest_local @ q_src
# 4. NLA トラックに Idle + Walk を配置してエクスポート
試行錯誤で判明したこと
| 問題 | 原因 | 解決 |
|---|---|---|
| 手足が動かない | keyframe_points.insert() でキーフレームが 2 個しか入らない | foreach_set('co', ...) + update() に変更 |
| 仰向けで歩く | Hips の rest pose 補正が全体の向きを反転させた | Hips は補正スキップ |
| 猫背になる | Spine 系の rest pose 補正で前傾 | Spine 系も補正スキップ |
| 顎が上を向く | Head/Neck の rest pose が微妙に異なる | 許容範囲として受容 |
最終的な補正スキップリスト:Hips, Spine, Spine1, Spine2, Neck, Head(体幹ライン全体)
失敗の詳細記録
ここに至るまでに経験した失敗を時系列で記録します。同じ問題に遭遇した場合の参考になるように。
失敗 1: ボーン名リネームだけで適用 → 消滅
最初のアプローチは「mixamorig: を外して track 名を変えるだけ」でした。
// 最もシンプルなアプローチ(失敗)
const cloned = track.clone();
cloned.name = track.name.replace(/^mixamorig:?/, "");
症状: アバターが完全に消える。移動するたびに一瞬表示されて消える(点滅)。
原因: Mixamo ソースモデルと Avaturn モデルの rest pose が異なるため、生のクォータニオン値をそのまま適用するとボーンが極端な方向に回転し、メッシュが裏返る・潰れる。特に Hips の回転値が Mixamo では [-0.71, -0.01, -0.04, 0.70](約 90° 回転)なのに対し、Avaturn の rest pose は [0, 0, 0, 1](ほぼ無回転)。この ~90° の差がスケルトン全体を破壊していた。
失敗 2: 全ボーンに rest pose 補正 → 仰向けで歩く
次に、各ボーンの rest pose 差分を計算して補正する方式を実装。
src_rest_local = src_parent_world.inverted() @ src_world
tgt_rest_local = tgt_parent_world.inverted() @ tgt_world
q_corrected = tgt_rest_local.inverted() @ src_rest_local @ q_src
症状: アバターが仰向け(あお向け)の姿勢で地面を滑る。
原因: Hips(ルートボーン)の rest pose 補正が、モデル全体の向きを 90° 反転させていた。Blender の bone.matrix_local.to_quaternion() が返す値には、Blender の座標系変換(Y-up → Z-up)が含まれており、この変換が Hips の補正に混入していた。
失敗 3: Hips だけ補正スキップ → 猫背
Hips を補正対象から外した。
症状: 前傾姿勢(猫背)で歩く。
原因: Spine, Spine1, Spine2 の rest pose 補正が背骨を前方に傾けていた。Blender の座標系変換の影響が Hips だけでなく体幹チェーン全体に波及していた。
失敗 4: 体幹全体を補正スキップ → 顎が上を向く
Hips, Spine, Spine1, Spine2, Neck, Head を全て補正スキップ。
症状: ほぼ正常に歩くが、若干顎が上を向く。
原因: Head と Neck の rest pose が Mixamo と Avaturn で約 5〜14° 異なる。これは両モデルの骨格設計の微妙な違いで、補正スキップでは解消しきれない。ただし実用上は許容範囲。
失敗 5: Idle と Walk の即時切替 → 点滅
Idle アニメーション(Avaturn 内蔵)と Walk アニメーション(リターゲット)を walking フラグで切り替え。
// 即時切替(失敗)
walkAction.setEffectiveWeight(store.walking ? 1 : 0);
idleAction.setEffectiveWeight(store.walking ? 0 : 1);
症状: 歩き始め/停止のたびにアバターが一瞬消える(点滅)。
原因: Idle と Walk で体幹ボーンのクォータニオン値が大きく異なる(rest pose の違い + 座標系変換の差)。weight を 0 → 1 に瞬時切替すると、補間なしでポーズが急変し、メッシュが一瞬崩壊する。
解決: THREE.MathUtils.lerp で毎フレーム 10% ずつ滑らかに遷移させる。
失敗 6: 地面に埋まったり浮いたりする
Walk アニメーション適用後、アバターが上下にバウンドする。
症状: 歩行中にアバターが地面に沈んだり浮いたりを繰り返す。
原因: Walk アニメーションの Hips ボーンに Y 方向の position キーフレーム(歩行時の上下動)が含まれており、root motion 除去コードが X/Z しかロックしていなかった。
解決: root motion 除去に Y 座標のロックも追加。
// 修正前: X/Z のみ
hipsNode.position.x = restX;
hipsNode.position.z = restZ;
// 修正後: X/Y/Z 全てロック
hipsNode.position.x = restX;
hipsNode.position.y = restY; // 追加
hipsNode.position.z = restZ;
アプローチ 2: Node.js での比較検証
Blender 版(正常動作)と Three.js ランタイム版(動作しない)の差分を特定するため、GLB を直接パースして比較するスクリプトを作りました。
GLB パーサー
function parseGlb(path) {
const buf = readFileSync(path);
const jsonLen = buf.readUInt32LE(12);
const json = JSON.parse(buf.slice(20, 20 + jsonLen).toString());
const binOffset = 20 + jsonLen + 8;
const bin = buf.slice(binOffset);
return { json, bin };
}
比較結果が明かした真実
Hips (keys: blender=25, mixamo=31):
Blender frame0: [-0.0222, 0.0178, -0.0353, 0.9990]
Mixamo frame0: [-0.7147, -0.0120, -0.0377, 0.6983]
Max diff: 0.692513 ✗ DIFFERENT
LeftUpLeg (keys: blender=25, mixamo=31):
Blender frame0: [-0.0033, -0.3042, -0.9521, 0.0316]
Mixamo frame0: [-0.0033, -0.3042, -0.9521, 0.0316]
Max diff: 0.000000 ✓ SAME
四肢のクォータニオン値は Mixamo と Blender で完全一致。つまりリネームだけで正しい。
一方 Hips は約 90° の差がある。これは Blender の GLB インポーターが Y-up → Z-up の座標変換を適用し、エクスポート時に逆変換するため。この往復で Hips の回転値が変わる。
発見のまとめ
| ボーン種別 | Blender vs Mixamo | 必要な処理 |
|---|---|---|
| 四肢(Arm, Leg, Hand, Foot) | 完全一致 | リネームのみ |
| Hips(ルート) | ~90° の差 | 座標系補正が必要 |
| Spine, Neck, Head | 小さな差 | そのままでほぼ OK |
アプローチ 3: Three.js ランタイム(ブラウザ完結)
最終的な retarget.ts
比較検証の結果から、シンプルな実装に辿り着きました:
// Hips のみ座標系補正(90° X 回転)
const BLENDER_COORD_FIX = new THREE.Quaternion()
.setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
export function retargetMixamoToAvaturn(
mixamoClip: THREE.AnimationClip,
_mixamoScene: THREE.Object3D,
targetScene: THREE.Object3D,
): THREE.AnimationClip | null {
const targetBones = new Set<string>();
targetScene.traverse((node) => {
if ((node as THREE.Bone).isBone) targetBones.add(node.name);
});
if (!targetBones.has("Hips")) return null;
const newTracks: THREE.KeyframeTrack[] = [];
for (const track of mixamoClip.tracks) {
const dotIdx = track.name.indexOf(".");
const srcBone = track.name.slice(0, dotIdx);
const prop = track.name.slice(dotIdx + 1);
const tgtBone = srcBone.replace(/^mixamorig:?/, "");
if (!targetBones.has(tgtBone)) continue;
if (prop === "quaternion" && tgtBone === "Hips") {
// Hips: 座標系補正
const values = new Float32Array(track.values.length);
const q = new THREE.Quaternion();
for (let i = 0; i < track.values.length; i += 4) {
q.set(track.values[i], track.values[i+1],
track.values[i+2], track.values[i+3]);
q.premultiply(BLENDER_COORD_FIX);
values[i] = q.x; values[i+1] = q.y;
values[i+2] = q.z; values[i+3] = q.w;
}
newTracks.push(new THREE.QuaternionKeyframeTrack(
`${tgtBone}.${prop}`, Array.from(track.times), Array.from(values)
));
} else if (prop === "quaternion") {
// 四肢: リネームのみ
const cloned = track.clone();
cloned.name = `${tgtBone}.${prop}`;
newTracks.push(cloned);
}
// position/scale トラックは除外
// (Mixamo は cm 単位、Avaturn は m 単位で 100 倍の差がある)
}
return new THREE.AnimationClip("walk-retargeted",
mixamoClip.duration, newTracks);
}
なぜ position トラックを除外するのか
比較で判明した致命的なスケール差:
Mixamo Hips translation: Y=83.6 (cm 単位)
Avaturn Hips rest pos: Y=0.97 (m 単位)
約 100 倍。position トラックをそのまま適用するとアバターが巨大化します。position は Avatar コンポーネントの root motion 除去コードが管理するため、アニメーションからは除外して問題ありません。
Idle ↔ Walk のクロスフェード
即時切替だと点滅が起きたため、lerp で滑らかにブレンドします:
const targetWalk = store.walking ? 1 : 0;
const currentWalk = walkAction.getEffectiveWeight();
const blend = THREE.MathUtils.lerp(currentWalk, targetWalk, 0.1);
walkAction.setEffectiveWeight(blend);
idleAction.setEffectiveWeight(1 - blend);
毎フレーム 10% ずつ重みを変化させることで、ポーズの急激な変化を防ぎます。
Web アップロードの実装
最終的に、ユーザーが Avaturn の GLB をアップロードするだけで歩行アニメーション付きアバターとして使えるようにしました。
// Settings.tsx — アップロードボタン
<input type="file" accept=".glb" onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setCustomAvatar(url); // Blob URL をストアに保存
}} />
// Avatar.tsx — ランタイムリターゲット
if (!hasWalkAnimation) {
// Mixamo ソースを読み込み
loader.load("/models/avatar.glb", (mixamoGltf) => {
const walkClip = retargetMixamoToAvaturn(
mixamoData.clip, mixamoGltf.scene, scene
);
if (walkClip) {
const action = mixer.clipAction(walkClip);
action.play();
action.setEffectiveWeight(0);
}
});
}
Blender 不要、Node.js 不要。ブラウザだけで完結します。
ランタイム版の失敗記録
ブラウザ内リターゲットでも Blender 版と同じ失敗を再現し、さらにランタイム固有の問題にも遭遇しました。
失敗 R1: rest pose 補正付きで適用 → 奇怪な姿勢
Blender 版の rest pose 補正式をそのまま Three.js に移植。
// Three.js での rest pose 補正(失敗)
const srcRestLocal = new THREE.Quaternion();
srcRestLocal.copy(srcParentWorld).invert().multiply(srcWorld);
q.premultiply(srcRestLocal).premultiply(tgtRestLocalInv);
症状: 体が大きく伸びて仰向けになる。Blender 版よりも酷い崩壊。
原因: Blender の bone.matrix_local.to_quaternion() はアーマチュア空間の値を返すが、Three.js の getWorldQuaternion() はシーン空間(親オブジェクトの回転を含む)の値を返す。同じ「rest pose クォータニオン」でも座標系が異なるため、補正式の結果が全く異なっていた。
失敗 R2: アニメーション再生中に rest pose を取得
// 順序の問題(失敗)
const idleAction = mixer.clipAction(idleClip);
idleAction.play(); // ← Idle アニメーション開始
// この時点で bone の world quaternion は Idle の影響を受けている
const walkClip = retargetMixamoToAvaturn(clip, mixamoScene, scene);
症状: 補正値が毎回異なり、予測不可能な姿勢になる。
原因: retargetMixamoToAvaturn 内で getWorldQuaternion() を呼ぶ時点で、Idle アニメーションが既に再生されており、ボーンの回転値が rest pose ではなく Idle アニメーションの現在フレームの値になっていた。
解決: アニメーション再生前に rest pose のスナップショットを scene.clone(true) で取得。
失敗 R3: position トラックをそのまま適用 → 巨大化
// リネームのみ(position 含む)で全トラック適用(失敗)
const cloned = track.clone();
cloned.name = newTrackName;
症状: アバターが画面を覆うほど巨大化。
原因: Mixamo の Hips position は Y=83.6(cm 単位)、Avaturn の Hips rest position は Y=0.97(m 単位)。約 100 倍のスケール差。position トラックをそのまま適用すると、アバターの骨盤が地上 83m に配置される。
解決: quaternion(回転)トラックのみ使用し、position/scale トラックは完全に除外。
失敗 R4: 全ボーンに座標系補正 → Spine 以降が二重回転
Hips だけでなく体幹全ボーンに 90° X 回転を適用。
if (SKIP_CORRECTION.has(tgtBoneName)) {
q.premultiply(BLENDER_COORD_FIX); // 全体幹に適用(失敗)
}
症状: Hips は正しいが、Spine 以降が異常な角度に回転。
原因: Hips に適用した座標系補正は子ボーン(Spine, Neck, Head)に自動伝播する。子ボーンにも個別に同じ補正を適用すると二重適用になる。
解決: Node.js 比較スクリプトで検証し、Hips のみに補正適用。
Spine (center):
Runtime: [0.6695, -0.0123, 0.0338, 0.7420] ← 二重適用で大きくズレ
Blender: [0.0385, 0.0122, 0.0338, 0.9986]
MaxDiff: 0.630967 ✗ DIFFERENT
↓ Hips のみに変更後
Spine (center):
Runtime: [-0.0513, 0.0152, 0.0326, 0.9980] ← ほぼ一致
Blender: [0.0385, 0.0122, 0.0338, 0.9986]
MaxDiff: 0.089769 ⚠ CLOSE
精度の比較
Node.js の比較スクリプトで Blender 版とランタイム版を検証した結果:
| ボーン | Blender vs ランタイム | 判定 |
|---|---|---|
| Hips | 0.011 | ⚠ ほぼ一致 |
| Spine | 0.090 | ⚠ 微小差 |
| LeftUpLeg | 0.000 | ✓ 完全一致 |
| LeftArm | 0.000 | ✓ 完全一致 |
| LeftShoulder | 0.000 | ✓ 完全一致 |
| Head | 0.112 | ✗ 若干ズレ |
| Neck | 0.241 | ✗ 若干ズレ |
四肢は完全一致。Head/Neck の差(5〜14°)は「若干顎が上を向く」程度で、実用上は許容範囲です。
3つのアプローチの比較
| Blender CLI | Node.js 検証 | Three.js ランタイム | |
|---|---|---|---|
| 依存 | Blender 5.0 | Node.js + fs | なし(ブラウザ) |
| 処理タイミング | ビルド時 | テスト時 | 実行時 |
| 精度 | 最高(Blender の座標変換込み) | 比較専用 | 実用十分 |
| ユーザー体験 | 開発者が事前変換 | — | GLB アップロードで即使用 |
| 用途 | プリセットアバター | デバッグ | ユーザーカスタムアバター |
まとめ
判明した事実
- 四肢のアニメーションはボーン名リネームだけで完全に動く。Avaturn と Mixamo の四肢 rest pose は同一
- Hips(ルートボーン)のみ座標系補正が必要。Blender の Y-up ↔ Z-up 変換に相当する 90° X 回転
- position トラックは使えない。Mixamo は cm 単位、Avaturn は m 単位で 100 倍の差がある
- Idle ↔ Walk の切替は lerp でクロスフェードしないと点滅する
- Blender 5.0 の Action API は完全に刷新された。
action.fcurves→action.layers[0].strips[0].channelbags[0].fcurves
デバッグ手法
GLB を直接パースして数値比較するアプローチが最も効果的でした。ブラウザで目視確認を繰り返すより、数値で差分を特定する方が確実です。
node scripts/compare-anims.mjs # Blender版 vs Mixamo 生データ
node scripts/verify-retarget.mjs # ランタイム補正後 vs Blender版