[Astro #39] VRMアバターの待機アニメーションをJSONで動的&ランダムに制御

[Astro #39] VRMアバターの待機アニメーションをJSONで動的&ランダムに制御

はじめに

前回までは固定のアニメーションを流していましたが、ずっと同じポーズだと「生きてる感」が足りない。
そこで今回は、複数の待機モーションを重み付け抽選で切り替え、さらにJSONで外部管理できるように拡張しました。

モーションは、先日の記事の通りUNITY + AIで作成してます。

更に、モデルデータもフリー素材をクレジット表記で利用してたのを、VRoid Studioでモデルの自作もして差し替えてます。

まだ一部使ってるのものありますが、合間で差し替えるよ予定です。

前回の記事:

[Astro #39] VRMアバターの待機アニメーションをJSONで動的&ランダムに制御(VRoid Studioでモデルの自作) [Astro #39] VRMアバターの待機アニメーションをJSONで動的&ランダムに制御(VRoid Studioでモデルの自作) [Astro #39] VRMアバターの待機アニメーションをJSONで動的&ランダムに制御(VRoid Studioでモデルの自作) [Astro #39] VRMアバターの待機アニメーションをJSONで動的&ランダムに制御(VRoid Studioでモデルの自作)

1. 待機モーションを「データ」として切り出す

これまで、アバターの待機時(アイドル状態)のアニメーションは、コンポーネント内に定数として直接ハードコーディング(const DEFAULT_ANIME = '...')していました。しかし、これではアバターが常に同じポーズを取り続けるため、どうしても「プログラムで動かされている感」が出てしまいます。

そこで、複数の待機モーションを用意し、それぞれのアニメーションに対する出現確率や再生時間、さらに連動する表情を外部のJSONファイルとして定義し、ロジックから分離することにしました。

プロジェクト内に src/data/IdleAnimation.json を作成し、以下のように配列形式でデータを定義します。

[
  {
    "id": "IDLE_DEFAULT",
    "file": "/animations/VRMA_01.vrma",
    "weight": 40,
    "duration": 15000,
    "description": "初期/待機用アニメ",
    "author": "CreatorA",
    "credit": "https://example.com/credita",
    "expression": "",
    "expressionLevel": 0.0
  },
  {
    "id": "IDLE_CURIOUS",
    "file": "/animations/anim/GirlIdleCurious.vrma",
    "weight": 60,
    "duration": 8000,
    "description": "周囲を見渡す",
    "author": "CreatorB",
    "credit": "https://example.com/creditb",
    "expression": "surprised",
    "expressionLevel": 0.4
  }
]

各パラメーターの設計意図

単なるファイルパスのリストではなく、アニメーションの「振る舞い」を細かく制御するためのパラメーターを持たせているのがポイントです。

  • weight(重み): この数値が高いほど、ランダム抽選で選ばれやすくなります。すべての weight の合計値に対する比率で計算されるため、「基本の立ちポーズは高確率、特殊な動きは低確率のレアモーション」といった柔軟な確率調整が可能です。
  • duration(再生持続時間): そのアニメーションを再生し続ける時間(ミリ秒)です。この時間が経過するとタイマーが発火し、次の待機アニメーションが自動的に再抽選されます。
  • expression / expressionLevel(表情の連動): ポーズに合わせてVRMの表情プリセット(BlendShape)を変化させます。例えば「周囲を見渡す時に少し驚いた顔(surprised)にする」といった細かな演出が、コンポーネントのコードを汚すことなくJSONの記述だけで完結します。

Tips: 未来の自分へのドキュメントを残す

標準的な JSON ファイルにはコメント(//)を書き込むことができません。数ヶ月後の自分が「この weight ってパーセント指定だっけ?単なる比率だっけ?」と迷わないよう、同じディレクトリに IdleAnimation.md という仕様書(Markdown)を作成してパラメーターの役割を明記しておくことを強くおすすめします。

TypeScriptを使用している場合は、インターフェースを定義して型安全にしておくとさらに開発体験が向上します。

2. 重み付けランダム抽選の実装

JSONで定義したアニメーションリストから、実際に再生するモーションを選ぶロジックを作成します。

配列の中から単純にランダムで選ぶ(Math.random() でインデックスを決定する)方法もありますが、それだとすべてのアニメーションが均等な確率で選ばれてしまいます。「基本は普通の立ちポーズだけど、ごくたまに珍しいポーズをとる」といったキャラクターの自然な個性を表現するためには、先ほどJSONに設定した weight(重み)を活用した抽選が必要です。

コンポーネントの冒頭でJSONデータをインポートし、以下のような抽選関数を実装します。

import IDLE_ANIMATIONS from '../data/IdleAnimation.json';

// 🌟 重み付けランダム選択関数
const getRandomIdleAnim = () => {
  // 1. すべての weight の合計値を計算する
  const totalWeight = IDLE_ANIMATIONS.reduce((sum, anim) => sum + anim.weight, 0);

  // 2. 0 〜 totalWeight の間でランダムな数値を生成
  let random = Math.random() * totalWeight;

  // 3. ループで weight を引き算していき、条件を満たしたアニメーションを返す
  for (const anim of IDLE_ANIMATIONS) {
    if (random < anim.weight) return anim;
    random -= anim.weight;
  }

  // 万が一すり抜けた場合のフォールバック(最初の要素を返す)
  return IDLE_ANIMATIONS[0];
};

ロジックの仕組み

この抽選アルゴリズムは、ゲームのアイテムドロップ処理などでもよく使われる定番の手法です。

例えば、A(weight: 60)、B(weight: 30)、C(weight: 10) という3つのアニメーションがあった場合、totalWeight は100になります。 Math.random() * 100 で 0〜100 のランダムな数値を出し、先頭から順に判定していきます。

  • 乱数が 45 だった場合: Aの重み(60)より小さいので、Aが選ばれます。
  • 乱数が 80 だった場合: Aの重み(60)より大きいのでAをスキップし、乱数から60を引いて残り 20 にします。次のBの重み(30)より小さくなったので、Bが選ばれます。

このように実装することで、JSON側の数値をいじるだけで、パーセンテージや複雑な計算を意識せずに「基本ポーズの比率を上げよう」「この動きはレアにしよう」といった微調整が直感的に行えるようになります。

3. 自動切り替えとステート管理の融合

データの準備と抽選ロジックができたので、次はいよいよそれをアバターの動きに適用します。

アバターは「常に待機している」わけではありません。ユーザーからのコマンドで「近づいて喋る」「ダンスする」といったアクションを実行します。そのため、「何もしていない時は自動でポーズを変え続け、アクションが来たら中断し、終わったらまたランダム待機に戻る」というスマートなステート管理が必要になります。

これを実現するために、Reactの useEffectuseCallback を使ってサイクルを構築しました。

① アイドル状態へ戻るための共通関数 (revertToIdle)

まずは、どんな状態からでも「現在のランダム選出された待機アニメーション」に安全に戻れる共通処理を作ります。

// 現在選択されている待機アニメを保持
const currentIdleRef = useRef(getRandomIdleAnim());
const [animPath, setAnimPath] = useState(currentIdleRef.current.file);
const [idleTimerKey, setIdleTimerKey] = useState(0); // タイマー再起動用のキー

// 🌟 待機状態に戻すための共通関数
const revertToIdle = useCallback(() => {
  const idle = currentIdleRef.current;
  setAnimPath(idle.file); // アニメーションを適用

  // JSONに表情が設定されていれば適用、なければ真顔(0)に戻す
  if (idle.expression) {
    stateRef.current.expressionType = idle.expression;
    stateRef.current.expressionTarget = idle.expressionLevel || 1.0;
  } else {
    stateRef.current.expressionTarget = 0;
  }

  setIdleTimerKey(prev => prev + 1); // タイマーを再スタート
}, []);

② 自動切り替えタイマー(待機中)

次に、JSONに設定した duration(再生時間)が経過したら、次のアニメーションを再抽選するタイマーを useEffect で回します。

useEffect(() => {
  let timer: NodeJS.Timeout;
  const idle = currentIdleRef.current;

  // 1. 待機中(IDLE)であり、
  // 2. 他のアクション(ダンスなど)をしておらず、
  // 3. 現在再生中のアニメが「待機アニメ」である時だけタイマーを稼働
  if (stateRef.current.phase === 'IDLE' && !isDancingRef.current && animPath === idle.file) {
    timer = setTimeout(() => {
      // 時間が来たら次を再抽選して切り替え
      currentIdleRef.current = getRandomIdleAnim();
      revertToIdle();
    }, idle.duration);
  }

  // クリーンアップ関数で安全にタイマーを破棄
  return () => clearTimeout(timer);
}, [idleTimerKey, animPath, revertToIdle]);

ここでのポイントは、条件付け(phase === 'IDLE' など)とクリーンアップ関数(clearTimeout)です。これにより、アバターが別の動きをしている間に裏でタイマーが暴発するのを防いでいます。

③ アクション発生時の「中断」と「復帰」

外部から「喋って!」や「ダンスして!」というイベントコマンドを受信した場合は、アバターの phase(状態)を書き換え、別のアニメーションパスを setAnimPath に渡します。

すると、先ほどのタイマー監視 useEffect の条件(phase === 'IDLE')から外れるため、自動的にタイマーが中断(キャンセル)されます。

そして、喋り終わって元の位置に帰還したタイミングで、先ほど作った共通関数を呼び出します。

} else if (s.phase === 'RETURNING') {
  // ... 元の位置に戻る移動処理 ...

  if (s.posZ < 0.1) { // 戻りきったら
    s.phase = 'IDLE'; // 状態を待機に戻す
    revertToIdle();   // 🌟 待機アニメを再開&タイマー再スタート!
  }
}

このサイクルを組むことで、固定のアニメーション(以前の DEFAULT_ANIME)への依存を完全に排除。どんなアクションの後でも、自然に「次のランダムなポーズ」へと復帰する、非常に生命感のある挙動が完成しました。

4. 未来の自分へのドキュメント(MD管理)

機能が完成して一安心!……と言いたいところですが、ここで終わらせないのが継続的な開発における地味で重要なポイントです。

今回作成した設定ファイルは .json 形式です。JSONの仕様上、ファイル内にコメント(///* */)を記述することができません。 このまま放置すると、数ヶ月後にアニメーションを追加しようとした時、あるいは他の開発者がコードを見た時に「この weight ってパーセント指定?それとも比率?」「duration の単位は秒?ミリ秒?」と必ず迷ってしまいます。

「コードの意図を忘れる」ことは、未来の自分への最大の負債です。これを防ぐために、JSONファイルと全く同じディレクトリに IdleAnimation.md というMarkdownファイルを作成し、パラメーターの仕様を明文化しました。

仕様書(Markdown)の記述例

# 📂 IdleAnimation.json パラメーター仕様書

待機アニメーションのランダム再生を制御するための設定ファイルです。

| パラメーター | 型 | 説明 |
| :--- | :--- | :--- |
| id | `string` | アニメーションの識別子。コンソールログのデバッグなどに使用。 |
| file | `string` | 読み込む `.vrma` ファイルのパス。 |
| weight | `number` | 出現確率の重み。全項目の合計値に対する比率で抽選。 |
| duration | `number` | 再生持続時間(ミリ秒)。この時間が経過すると次に切り替わる。 |
| expression | `string` | 連動させるVRM表情プリセット名(`angry`, `surprised` など)。 |

VSCodeなどのエディタを使っていれば、画面の左半分で IdleAnimation.json を編集しつつ、右半分でこの IdleAnimation.md をプレビュー表示させることができます。これにより、仕様を確認しながら迷わず数値を調整できる最高の開発体験(DX)が得られます。

💡 さらに堅牢にするなら:TypeScriptの活用

もしプロジェクトに TypeScript を導入しているなら、このMarkdownの仕様をベースに interface を定義しておくと完璧です。

// src/types/avatar.ts
export interface IdleAnimationConfig {
  id: string;
  file: string;
  weight: number;
  duration: number;
  description: string;
  author: string;
  credit: string;
  expression: string;
  expressionLevel: number;
}

コンポーネント側でJSONをインポートする際に const IDLE_ANIMATIONS = idleData as IdleAnimationConfig[]; のように型を当てておけば、必須項目の抜け漏れやタイポをエディタが一瞬で警告してくれます。

「ドキュメントを残す」「型を定義する」というひと手間は、未来の自分への最高のプレゼントになります。チーム開発はもちろん、個人開発でも絶対にサボらないようにしたいステップですね。