はじめに

Avaturn は写真から 3D アバターを生成できるサービスです。生成されたアバターは GLB 形式でダウンロードでき、Idle(待機)アニメーション付きのモデルも取得できます。

しかし 歩行アニメーションは含まれていません。VR 空間でアバターを歩かせたい場合、別途アニメーションを用意する必要があります。

この記事では、既存の Mixamo 歩行アニメーションを Avaturn アバターに適用(リターゲット)する過程を記録します。3つのアプローチを試しました:

  1. Blender 5.0 CLI でオフライン変換
  2. Node.js で結果を比較検証
  3. Three.js ランタイムでブラウザ内リターゲット

前提知識

ボーン名の対応

Avaturn と Mixamo は同じ骨格構造(Humanoid)を使いますが、ボーン名が異なります:

MixamoAvaturn
mixamorig:HipsHips
mixamorig:LeftArmLeftArm
mixamorig:HeadHead

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 ランタイム判定
Hips0.011⚠ ほぼ一致
Spine0.090⚠ 微小差
LeftUpLeg0.000✓ 完全一致
LeftArm0.000✓ 完全一致
LeftShoulder0.000✓ 完全一致
Head0.112✗ 若干ズレ
Neck0.241✗ 若干ズレ

四肢は完全一致。Head/Neck の差(5〜14°)は「若干顎が上を向く」程度で、実用上は許容範囲です。


3つのアプローチの比較

Blender CLINode.js 検証Three.js ランタイム
依存Blender 5.0Node.js + fsなし(ブラウザ)
処理タイミングビルド時テスト時実行時
精度最高(Blender の座標変換込み)比較専用実用十分
ユーザー体験開発者が事前変換GLB アップロードで即使用
用途プリセットアバターデバッグユーザーカスタムアバター

まとめ

判明した事実

  1. 四肢のアニメーションはボーン名リネームだけで完全に動く。Avaturn と Mixamo の四肢 rest pose は同一
  2. Hips(ルートボーン)のみ座標系補正が必要。Blender の Y-up ↔ Z-up 変換に相当する 90° X 回転
  3. position トラックは使えない。Mixamo は cm 単位、Avaturn は m 単位で 100 倍の差がある
  4. Idle ↔ Walk の切替は lerp でクロスフェードしないと点滅する
  5. Blender 5.0 の Action API は完全に刷新されたaction.fcurvesaction.layers[0].strips[0].channelbags[0].fcurves

デバッグ手法

GLB を直接パースして数値比較するアプローチが最も効果的でした。ブラウザで目視確認を繰り返すより、数値で差分を特定する方が確実です。

node scripts/compare-anims.mjs   # Blender版 vs Mixamo 生データ
node scripts/verify-retarget.mjs  # ランタイム補正後 vs Blender版