Unityでモーションキャプチャを自作する

これまでのヒューマノイドシリーズ(①移動・②IK・③手続き的歩行)では、用意したアニメや計算で動かしてきました。今回 は逆に、キャラクターの動きを「収録」して、後から再生する簡易モーションキャプチャを自作します。高価な 機材は不要で、ボーンの回転をJSONに保存するだけ。さらに、収録した動きを使った”最近傍マッチング”まで触れます。

何を作るのか

市販のモーキャプは専用スーツやカメラで体の位置を記録しますが、今回作るのはもっとシンプルな仕組みです。

  • 記録:毎フレーム、各ボーンの回転(localRotation)を配列に貯める
  • 保存:貯めたデータをJSONファイルに書き出す
  • 再生:JSONを読み込み、フレーム順にボーン回転を当てはめる

つまり「ボーンの回転の時系列」を記録・再生するだけ。これだけで、アニメ実行中の動きや手動で動かしたポーズの連続を”自 分のモーションクリップ”として残せます。言語はUnity標準のC#です(言語選びに迷っている人は別記事も参考に)。

1. データ構造を定義する

UnityのJsonUtilityでシリアライズできるよう、[System.Serializable]を付けたクラスで「1フレーム分」と「ク リップ全体」を表します。QuaternionはそのままだとJSON化しにくいので、x/y/z/wに分解して保持します。

using System.Collections.Generic;
  using UnityEngine;

  [System.Serializable]
  public class BoneRot
  {
      public float x, y, z, w;
      public BoneRot(Quaternion q) { x=q.x; y=q.y; z=q.z; w=q.w; }
      public Quaternion ToQuaternion() => new Quaternion(x, y, z, w);
  }

  [System.Serializable]
  public class MotionFrame
  {
      public float time;              // 収録開始からの経過秒
      public List<BoneRot> rotations; // ボーン順の回転
  }

  [System.Serializable]
  public class MotionClip
  {
      public List<string> boneNames;  // どのボーンを記録したか
      public List<MotionFrame> frames; // フレーム列
  }

2. MotionRecorder:Rキーで収録してJSON保存

記録対象のボーンを集め、収録中は毎フレーム回転を追加します。停止したらJSON化して保存します。入力は新しいInput SystemのKeyboard.currentを使います。

using System.Collections.Generic;
  using System.IO;
  using UnityEngine;
  using UnityEngine.InputSystem;

  public class MotionRecorder : MonoBehaviour
  {
      [SerializeField] Animator animator;
      Transform[] _bones;
      MotionClip _clip;
      bool _recording;
      float _startTime;

      void Start()
      {
          // 記録するボーンを集める(ここでは全Humanoidボーン)
          var list = new List<Transform>();
          foreach (HumanBodyBones b in System.Enum.GetValues(typeof(HumanBodyBones)))
          {
              if (b == HumanBodyBones.LastBone) continue;
              var t = animator.GetBoneTransform(b);
              if (t != null) list.Add(t);
          }
          _bones = list.ToArray();
      }

      void Update()
      {
          var kb = Keyboard.current;
          if (kb == null) return;

          if (kb.rKey.wasPressedThisFrame)  // Rで収録トグル
          {
              if (!_recording) BeginRecord();
              else SaveRecord();
          }

          if (_recording) CaptureFrame();
      }

      void BeginRecord()
      {
          _recording = true;
          _startTime = Time.time;
          _clip = new MotionClip { boneNames = new List<string>(), frames = new List<MotionFrame>() };
          foreach (var b in _bones) _clip.boneNames.Add(b.name);
          Debug.Log("収録開始");
      }

      void CaptureFrame()
      {
          var frame = new MotionFrame { time = Time.time - _startTime, rotations = new List<BoneRot>() };
          foreach (var b in _bones) frame.rotations.Add(new BoneRot(b.localRotation));
          _clip.frames.Add(frame);
      }

      void SaveRecord()
      {
          _recording = false;
          string json = JsonUtility.ToJson(_clip);
          string path = Path.Combine(Application.persistentDataPath, "motion.json");
          File.WriteAllText(path, json);
          Debug.Log($"保存しました: {path}({_clip.frames.Count}フレーム)");
      }
  }

ポイントはワールド回転(rotation)ではなくlocalRotationを記録すること。親子関係に沿った相対回転なの で、再生時に位置がずれず、別の場所でも同じポーズを再現できます。

3. MotionPlayer:Pキーで再生する

保存したJSONを読み込み、経過時間に対応するフレームのボーン回転を当てはめます。隣り合うフレームをSlerp で補間すると、カクつかず滑らかになります。

using System.IO;
  using UnityEngine;
  using UnityEngine.InputSystem;

  public class MotionPlayer : MonoBehaviour
  {
      [SerializeField] Animator animator;
      MotionClip _clip;
      Transform[] _bones;
      bool _playing;
      float _playTime;

      void Update()
      {
          var kb = Keyboard.current;
          if (kb != null && kb.pKey.wasPressedThisFrame) Load();

          if (_playing) Step();
      }

      void Load()
      {
          string path = Path.Combine(Application.persistentDataPath, "motion.json");
          if (!File.Exists(path)) { Debug.LogWarning("motion.json が無い"); return; }

          _clip = JsonUtility.FromJson<MotionClip>(File.ReadAllText(path));
          _bones = new Transform[_clip.boneNames.Count];
          for (int i = 0; i < _clip.boneNames.Count; i++)
              _bones[i] = FindBone(_clip.boneNames[i]);

          _playTime = 0f;
          _playing = true;
      }

      void Step()
      {
          _playTime += Time.deltaTime;

          // 経過時間を挟む2フレームを探す
          var frames = _clip.frames;
          if (_playTime >= frames[frames.Count - 1].time) { _playing = false; return; }

          int i = 0;
          while (i < frames.Count - 1 && frames[i + 1].time < _playTime) i++;

          var a = frames[i];
          var b = frames[i + 1];
          float t = Mathf.InverseLerp(a.time, b.time, _playTime);

          for (int j = 0; j < _bones.Length; j++)
          {
              if (_bones[j] == null) continue;
              Quaternion qa = a.rotations[j].ToQuaternion();
              Quaternion qb = b.rotations[j].ToQuaternion();
              _bones[j].localRotation = Quaternion.Slerp(qa, qb, t);
          }
      }

      Transform FindBone(string name)
      {
          foreach (var t in animator.GetComponentsInChildren<Transform>())
              if (t.name == name) return t;
          return null;
      }
  }

⚠️ 注意:再生中はAnimatorが回転を上書きしないよう、AnimatorのControllerを外すか、ボーン適用をLateUpdateで行ってください( このシリーズ①で解説した「Updateで動かしてもAnimatorが後から上書きする」問題と同じです)。

4. 応用:最近傍マッチングで「似た動き」を探す

収録データが溜まると、「今のポーズに一番近いフレーム」を探す、ごく簡単なモーション学習に発展できます。各フレームの 全ボーン回転の差を合計し、最小のものを選ぶ”最近傍探索”です。

// 現在の姿勢に最も近いフレーム番号を返す
  int FindNearest(Quaternion[] current, MotionClip clip)
  {
      int best = -1;
      float bestCost = float.MaxValue;
      for (int f = 0; f < clip.frames.Count; f++)
      {
          float cost = 0f;
          var rots = clip.frames[f].rotations;
          for (int j = 0; j < current.Length; j++)
              cost += Quaternion.Angle(current[j], rots[j].ToQuaternion());
          if (cost < bestCost) { bestCost = cost; best = f; }
      }
      return best;
  }

Quaternion.Angleで2つの回転の差を角度で測り、合計コストが最小のフレームを採用します。これだけでも「似 た姿勢の続きを再生する」といった簡単なモーション合成の土台になります。

よくあるハマりどころ

< tr>
症状原因
再生しても動か ないAnimatorが毎フレーム上書きしている(Controllerを外す/LateUpdate適用)
ポーズが崩れるrotation(ワールド)を記録した。localRotationにする
JSONが空・読めない保存先はApplicati on.persistentDataPath。パスをログで確認
再生がカクつくフレーム間をSlerpで補間する
ボーンが対応しない記録時と再生時でボーン名が一致しているか確認

まとめ

  • モーキャプの正体は「ボーン回転の時系列の記録・再生」
  • 記録はlocalRotationで。JSON化はx/y/z/wに分解
  • 再生はフレーム間をSlerp補間して滑らかに
  • Animatorの上書きに注意(Controllerを外す/LateUpdate)
  • 応用:最近傍マッチングで簡単なモーション学習に発展できる

専用機材がなくても、Unityとコードだけで動きの収録・再生は実現できます。シリーズ①〜③の歩行・IK・手続き的アニメと組み 合わせれば、自作の動きをキャラに持たせる表現の幅が一気に広がります。まずはRキーで数秒の動きを収録し、Pキーで再生すると ころから試してみてください。