チュートリアル / Bifrost for Maya Rigging Challenge~一歩先のリグ・アニメーションに挑戦~
第7回:機械学習でリュックの連動リグ〜実践編〜

  • Maya
  • アニメ
  • キャラクター・リグ
  • ゲーム
  • コラム
  • シミュレーション
  • チュートリアル
  • 上級者
  • 中級者
  • 映画・TV

みなさん、こんにちは。

本コラムではMayaのプラグイン"Bifrost"を使って、リグ、アニメーション、物理シミュレーションなどの観点から作成例を紹介していきます。今回は「機械学習で事前に用意したポーズデータを近似する連動リグ」を作ってみたいと思います。

前回の導入編に続き実践編になります。以下の成果物を実際に作っていきます。

今回の成果物
今回の成果物

【環境】
・Windows 11
・NVIDIA GeForce RTX 4060 8GB
・Maya 2026
・Bifrost 2.13.0.0
・Python 3.11.9
・PyTorch 2.7.0+cu118
・numpy 2.2.6
・matplotlib 3.10.3
・tensorboard 2.19.0

※機械学習関連のコンパウンドは Bifrost 2.12 以降で使用できます。

訓練データの準備

前回で基本的な流れは確認済みですので、さっそくリュックのリグで訓練データを準備していきたいと思います。まずは入出力を何にするか考える必要がありますが、上半身のポーズを入力としてリュックのコントローラを連動させたいので、入力を上半身のコントローラ複数×回転、出力をリュックのコントローラ複数×位置、にしてみます。これらのペアを大量に収集するために、各コントローラをランダムに回転アニメーションさせて簡易的なメッシュでクロスシミュレーションを行います。そして、クロスの表面の各位置座標がどのように変化したかを取得します。

簡易的なメッシュでクロスシミュレーション

アニメーションは手付けでは大変なので、周期の異なるsinを複数加算したものを使います。

sin

n:sinの個数
w:sin波のスケール
ω:周波数
Φ:位相
t:時間

次のグラフは上記の合成sin波 S(t) を指定したmin-max範囲にリマップし、その値の推移(横軸がtで縦軸がS(t))と分布(S(t)を30分割したときの各区画の値の個数)をプロットしたものです。sinひとつだけだと両端に偏った分布になり、逆にたくさん合成すると中央に偏った正規分布に近づきます。今回は3つほど合成したものを使います。配列の序盤は初期ポーズから徐々に移行するようlerpも入れてあります。

bifrostGraphShape > vector3_wave_sequence
bifrostGraphShape > vector3_wave_sequence
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > for_each
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > for_each
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > iterate
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > iterate
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > iterate > add_sin
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > iterate > add_sin
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence >  max_abs_normalize
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > max_abs_normalize
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > min_max_scale
bifrostGraphShape > vector3_wave_sequence > random_wave_sequence > min_max_scale
プロット結果(サンプルデータ:vector3_wave_sequence.ma)
プロット結果(サンプルデータ:vector3_wave_sequence.ma)

以下のグラフは、上記の vector3_wave_sequence コンパウンドを使用してオイラー角の配列を20,000個用意し、フレームが進むごとに値を取り出して各ジョイントの回転へ出力しています。動き続けてしまうとクロスが落ち着かないので「100F動いたら50F停止」を繰り返すようindexを操作しています。結果として、20,000個の角度データから30,000フレームのアニメーションが得られます。
(※このMayaシーンは訓練データ収集用として、元のリグのシーンとは分けておきます。)

bifrostGraphShape
bifrostGraphShape
bifrostGraphShape > stopped_sequence
bifrostGraphShape > stopped_sequence
Bifrostの出力で背骨と両肩のコントローラを駆動
Bifrostの出力で背骨と両肩のコントローラを駆動
作成したアニメーション(5倍速)
作成したアニメーション(5倍速)

リュックと同じ形状のクロス用メッシュを作成しnClothを適用します。ひとまずここでシミュレーションを30,000フレーム実行し、alembicでキャッシュ化してしまいます。また、コントローラへ接続したRotateアニメーションもキーフレームにベイクしてしまいます(*1)。

*1) データを安定させる目的もありますが、今回のようなケースでは、Bifrostによりコントローラ及びjointが駆動しその結果のuvPinの変位をこの後Bifrostに戻す予定があるため、Cycle対策としてベイクが必須になっています。

nClothシミュレーション(サンプルデータ:backpack_01a_data_generation.ma)
nClothシミュレーション(サンプルデータ:backpack_01a_data_generation.ma)

シミュレーション結果が反映されたメッシュ上にuvPinを使用してlocatorを設置します(*2)。さらに、これらのlocatorを複製し初期位置取得用ノードとし、コントローラの親ノードへ親子付けします。そして、これらすべてのlocatorとコントローラの親のworldMatrixを先ほどと同じBifrostグラフに入力します。これで入力と出力のデータがひとつのグラフ内に存在することとなりました。

*2) サンプルデータに「create_uvpin.py」というスクリプトが含まれています。ジョイント等を複数選択した後、最後にメッシュを選択してこのスクリプトを実行すると、各コントローラから最も近いフェース上にlocatorが設置されます。

緑のlocatorがメッシュ上にuvPinで設置されたもの。赤いlocatorとの移動差分を学習する。
緑のlocatorがメッシュ上にuvPinで設置されたもの。赤いlocatorとの移動差分を学習する。
出力位置、初期位置、親空間の各15個のworldMatrixをBifrostグラフに接続
出力位置、初期位置、親空間の各15個のworldMatrixをBifrostグラフに接続
bifrostGraphShape:コントローラの親空間におけるoutput_featuresの移動差分を取得
bifrostGraphShape:コントローラの親空間におけるoutput_featuresの移動差分を取得

それでは、訓練データを集めるためにグラフをさらに改変していきます。入力の回転姿勢については、オイラー角、クォータニオン、回転行列など複数の表現方法がありますが、今回はすべてクォータニオン [x,y,z,w] にします。出力の位置座標は、各コントローラの親空間を基準とした、初期位置からの移動差分 [x,y,z] を使います。各値は全てひとつのフラットなfloat配列に結合します。入力はジョイント5つのクォータニオンで20次元、出力は15点分の位置で45次元となりました。

訓練データの改変

以下のグラフは、整形した入出力それぞれのfloat配列を1フレーム進むごとにさらに配列へ格納し、二次元配列にまとめる工程を、先ほどのグラフの右側に追加したものです。この二次元配列が訓練データになります。現在フレームが29,999フレームになった時のみwrite_NumPyコンパウンドを使用して外部ファイルに書き出すようにしてあります。

bifrostGraphShape :input_features, output_features, write 関係を追加
bifrostGraphShape :input_features, output_features, write 関係を追加
bifrostGraphShape > euler_deg_to_quat
bifrostGraphShape > euler_deg_to_quat
bifrostGraphShape > euler_deg_to_quat > positive_quat
bifrostGraphShape > euler_deg_to_quat > positive_quat
bifrostGraphShape > expand_array
bifrostGraphShape > expand_array
bifrostGraphShape > collect_animated_data
bifrostGraphShape > collect_animated_data

準備が整ったので訓練データの収集を行います!0フレームから再生を始め29,999フレームに達するまで、つまり30,000ペアの訓練データ収集が完了するまでしばらく見守ります。

いったんティータイムにしましょう…!

訓練データの収集
訓練データの収集(サンプルデータ:backpack_01b_data_generation.ma)
訓練データの収集(サンプルデータ:backpack_01b_data_generation.ma)

学習

前項で無事に入力データ(input_features.npy)と出力データ(output_features.npy)が作成できていたら、これらを使ってモデルの学習を行います。学習スクリプトは前回使用したものを使い回していきますが、3点ほど改変項目がありますのでひとつずつ確認していきます。

まず1点目はモデルの構造です。今回は入力が20次元、出力は45次元となっています。ひとまず層を4つにして、ノード数はすべて64にしてみます。このあたりの初期設定はなんとなくです。実際に学習してみて最適な構成を探していくことになります。

class Network(nn.Module):
    def __init__(self):
        super(Network, self).__init__()
        self.net = nn.Sequential(
            nn.Linear(20, 64), # quat(x,y,z,w) x 5 = 20
            nn.ReLU(),
            nn.Linear(64, 64),
            nn.ReLU(),
            nn.Linear(64, 64),
            nn.ReLU(),
            nn.Linear(64, 45) # pos(x,y,z) x 15 = 45
        )

    def forward(self, x):
        return self.net(x)

2点目は訓練データのロードと正規化です。一般的にMLPの入出力とするデータは-1.0〜1.0などに正規化されていたほうが学習効率が良いとされているため、値が大きくなりがちな出力の位置座標は学習前に正規化を行います。正規化の手法はMachine Learning in Bifrost でも採用されている「Z-Score Normalize」を使用してみます。入力についてはすでに単位クォータニオンなので正規化は不要です。

# 正規化(z-score normalize)関数。公式サンプルより拝借
def normalize(X, axis, savefile=None, epsilon=1e-10):
    Xmean = X.mean(axis=axis)
    Xstd = X.std(axis=axis)

    # Set standard deviation to 1 for very small values to avoid division by near-zero numbers
    Xstd[Xstd < epsilon] = 1

    X = (X - Xmean) / Xstd

    if savefile is not None:
        np.save(savefile + '_mean.npy', Xmean)
        np.save(savefile + '_std.npy', Xstd)

    return X

BATCH_SIZE = 128
LEARNING_RATE = 5e-3
EPOCH = 300

if __name__ == '__main__':

    model_name = 'backpack'
    model_version = 'v1'
    save_dir = os.path.join('models', model_name, model_version)
    os.makedirs(save_dir, exist_ok=True)

    # 訓練データの準備(Mayaから出力したnpyを読み込み)
    data_dir = os.path.join(r'D:\BifrostML\data', model_name) # ← パスは適宜書き換えてください
    input_data = np.load(os.path.join(data_dir, 'input_features.npy'))
    output_data = np.load(os.path.join(data_dir, 'output_features.npy'))

    # 出力データの正規化(z-score normalize)
    normalized_output_data = normalize(output_data, axis=0, savefile=os.path.join(save_dir, 'output'))
    print("output_data min-max:", output_data.min(), output_data.max())
    print("norm output_data min-max:", normalized_output_data.min(), normalized_output_data.max())

    # torch.tensorに変換
    input_tensor = torch.tensor(input_data, dtype=torch.float32).to(device)
    output_tensor = torch.tensor(normalized_output_data, dtype=torch.float32).to(device)

    # 入力と出力をまとめてデータローダーに
    dataset = TensorDataset(input_tensor, output_tensor)
    train_loader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True)

    # モデル定義
    model = Network().to(device)

    # 損失関数とオプティマイザの定義
    criterion = nn.MSELoss()
    optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)

    # 学習
    log_dir = f'runs/{model_name}/{model_version}_batch_{BATCH_SIZE}_lr_{LEARNING_RATE}'
    train(model, train_loader, criterion, optimizer, num_epochs=EPOCH, log_dir=log_dir)

    # 学習したモデルのパラメータを保存
    save_model_params(model, save_dir)

3点目はLossのプロットです。導入編ではmatplotlibで学習終わりに1度だけ画像へプロットしましたが、今回はTensorBoard(*3)という可視化ツールを使用してリアルタイムに経過を追ってみます。複数のグラフを重ねて比較することも可能なため、しっかりと検証したい時にはオススメです。

*3)torch.utils.tensorboard — PyTorch 2.7 documentation:PyTorchでTensorBoardを扱う詳細はこちらのドキュメントをご確認ください

from torch.utils.tensorboard import SummaryWriter

def train(model:nn.Module, train_loader, criterion, optimizer, num_epochs=100, log_dir='runs/exp1'):
    """ 学習しつつLossの推移をTensorBoardに記録する関数 """

    writer = SummaryWriter(log_dir=log_dir) # ← 変更箇所

    model.train()
    for epoch in range(num_epochs):
        total_loss = 0.0
        total_samples  = 0

        for train_x, teacher_y in train_loader:
            train_x = train_x.to(device)
            teacher_y = teacher_y.to(device)

            optimizer.zero_grad()  # 勾配リセット
            pred_y = model(train_x)  # 予測
            loss = criterion(pred_y, teacher_y)  # 損失取得
            loss.backward()  # 勾配計算
            optimizer.step()  # パラメータ更新

            # loss記録
            batch_size = train_x.size(0)
            total_loss += loss.item() * batch_size
            total_samples  += batch_size

        avg_train_loss = total_loss / total_samples
        writer.add_scalar('Loss/train', avg_train_loss, epoch) # ← 変更箇所

        print(f'[Epoch {epoch+1:2d}/{num_epochs:2d}], Train Loss:{avg_train_loss:.4f}')

    writer.close()

以上を繋げたスクリプト全文がサンプルデータ「train_v1.py」です。

いよいよ学習を開始します!導入編で用意した仮想環境で python train_v1.py を実行します。

学習を開始

学習が始まったら別のターミナルで tensorboard --logdir=runs/backpack を実行しTensorBoardを起動します。表示されたURL(http://localhost:6006/)にブラウザでアクセスすると、リアルタイムにグラフが更新されていきます。

TensorBoardを起動
TensorBoardの画面
TensorBoardの画面

結果の確認

ではMayaシーンとBifrostグラフを新たに推論用に組み替えます。元のリグシーンに戻り新たにBifrostグラフを作成し、訓練データ作成時に作ったグラフから input_features の部分をコピペ、network やモデルパラメータの読み込み部分は導入編で作ったsinの近似モデルからコピペしてしまいましょう。層の数が変わっていますのでモデル構造に合わせて少し改変します。残りの部分は以下のように組みます。

bifrostGraphShape
bifrostGraphShape
bifrostGraphShape > network
bifrostGraphShape > network
bifrostGraphShape > read_normalize_params
bifrostGraphShape > read_normalize_params
bifrostGraphShape > iterate
bifrostGraphShape > iterate
出力結果を適用
jointのRotateを入力として、コントローラ位置をオフセット
jointのRotateを入力として、コントローラ位置をオフセット

動きました!胴を大きく前後に曲げた時もリュック本体が伸びずに引き上げられたり垂れ下がったりしているのが分かります。

動作結果(サンプルデータ:backpack_02_prediction.ma)
動作結果(サンプルデータ:backpack_02_prediction.ma)

できるだけ精度を上げてみる

動いたのは良いものの、ぎこちない動きが随所に見られます。ここから先は少しでも精度を高めるために行った調整をいくつか紹介していきます。

1点目は訓練データの拡充です。そもそも、いくつかのモデル構造を試している過程で常に下記のような問題が見受けられました。

①背骨を動かさず肩を上げ下げした際に不自然なガタつきが目立つ
②背骨をまとめて動かした際に挙動が破綻する領域がある

①については、実際にそのような「肩だけ動かしたデータ」を訓練データに含めていないので当然のことかもしれません。極端ですが、背骨を一切動かさずに肩だけランダムに動かしたデータを新たに用意してみます。

肩だけ動かしたデータ

②については単純にデータ不足かもしれません。seedを変えて別のパターンで訓練データを新規追加してみようと思いますが、ここで少し変更を加えます。当初用意したデータでは背骨3本にそれぞれ別々のアニメーションを適用していたため、背骨が不自然なS字に折れ曲がったポーズも存在していました。追加データでは背骨3本全てに同じランダムシーケンスを適用し、全体が揃って大きく曲がるポーズに限定してみます。肩はseed変更のみです。

訓練データを新規追加

追加データはそれぞれ30,000ずつ用意しましたので、合計90,000ペア手元にあります。ただし①の肩だけ動いているパターンは背骨の入力が全て初期値の極端なデータですので、影響を減らすため2つ飛ばしで取り出して10,000ペアだけ使用します。②のデータは1つ飛ばしで取り出して半量の15,000ペアを使用し、これらすべてを結合します。結果として新しい訓練データが55,000ペアとなりました。

# 訓練データの準備(Mayaから出力したnpyを読み込み)
data_dir = os.path.join(r'D:\BifrostML\data', model_name)
input_data = np.load(os.path.join(data_dir, 'input_features.npy'))
output_data = np.load(os.path.join(data_dir, 'output_features.npy'))

# 追加で用意した訓練データの読み込み
input_data_add01 = np.load(os.path.join(data_dir, 'input_features_add01.npy'))
output_data_add01 = np.load(os.path.join(data_dir, 'output_features_add01.npy'))
input_data_add02 = np.load(os.path.join(data_dir, 'input_features_add02.npy'))
output_data_add02 = np.load(os.path.join(data_dir, 'output_features_add02.npy'))

# すべてのデータを結合
#   add01(肩だけ動いているデータ)については、2つおきに10000ペア使用
#   add02(背骨がすべて同じ回転のデータ)については、1つおきに15000ペア使用
input_data = np.concatenate([input_data, input_data_add01[2::3], input_data_add02[1::2]], axis=0)
output_data = np.concatenate([output_data, output_data_add01[2::3], output_data_add02[1::2]], axis=0)

2点目は損失関数の変更です。訓練データに外れ値が含まれていても MSE Loss より比較的影響を受けにくいとされるSmoothL1Lossへ変更しました。SmoothL1Lossについて詳しくはこちらをご確認ください。

criterion = nn.SmoothL1Loss()

3点目はモデル構造です。各層のノード数を増やし、活性化関数をMish(*4)に変更、さらに中間層に1箇所だけスキップ接続(*5)を追加してみました。スキップ接続は処理的には単純な加算ですのでBifrostの既存ノードで簡単に組めます。

*4)Mish:負の値にもなめらかな傾きがあり、ReLUよりも精度が上がる可能性があるようです。

ReLUよりも精度が上がる可能性

*5) 中間層の出力を後続の層に加算して伝播させることで、層を跨いで情報を保持し、学習を安定させる手法です。

class Network(nn.Module):
    def __init__(self):
        super(Network, self).__init__()
        self.fc1 = nn.Linear(20, 128)
        self.fc2 = nn.Linear(128, 256)
        self.fc3 = nn.Linear(256, 256)
        self.fc4 = nn.Linear(256, 45)
        self.activation = nn.Mish()

    def forward(self, x):
        x = self.activation(self.fc1(x))
        res = self.activation(self.fc2(x))
        x = self.fc3(res) + res # Skip Connection
        x = self.activation(x)
        x = self.fc4(x)
        return x

さらに、ハイパーパラメータを微調整し、最終的に出来上がった改良版スクリプト全文がサンプルデータ「train_v2.py」です。

こちらで再度学習を行ってみた結果、訓練Lossの推移は以下のようになりました。最もLossが低くなった水色のラインが最終的に採用したパターンです(*6)。

*6) 今回は簡易検証のため訓練Lossのみを使用していますが、過学習対策などを考慮すると評価Lossのモニタリングも推奨されます。

※検証の過程で試した他のパターンもいくつか含まれています
※検証の過程で試した他のパターンもいくつか含まれています

推論用Bifrostグラフは以下の通りです。networkはスキップ接続付きのモデルです。

bifrostGraphShape
bifrostGraphShape
bifrostGraphShape > network(2層目の出力→活性化の結果を3層目の出力に加算)
bifrostGraphShape > network(2層目の出力→活性化の結果を3層目の出力に加算)
改良版の動作結果(サンプルデータ:backpack_03_prediction_v2.ma)
改良版の動作結果(サンプルデータ:backpack_03_prediction_v2.ma)

以上になります!

全2回に渡ってBifrostの機械学習ノードを試してきましたが、訓練データを作成したりスタンドアロンのPythonで学習したりとBifrost以外での手順が多く今回はなかなかヘビーな内容でした。

ところで機械学習といえば、Maya 2025.2より標準搭載されたML Deformerも気になるところですが、こちらはあくまで“デフォーマー”ですので、出力はメッシュ(の頂点)限定になります。それと比較すると、Bifrost機械学習の利点は入出力データやモデル構造を自由に組めることかと思いますので、デフォームに限らず様々な応用例が考えられます。やりすぎると沼にハマりそうですが、他にも面白い使いどころがないか探っていきたいところです。

ちなみに、ここまでやっておいて言うのも少し気が引けますが、かけた労力のわりに得られた効果はだいぶ地味だったなと感じています。部分的な処理に限定してドリブンキーやRBFで済ませた方が無難だったかもしれませんし、もっとリアルな動きが欲しければそのままシミュレーションを採用した方が良いかもしれません。"そこそこ複雑で速度も量も求められる"といった場合にこそ、今回のような近似モデルが役に立つのだろうなと思います。あくまでアプローチの1つとして参考になれば幸いです。

今回(実践編)のまとめ

・Bifrostでランダムかつ滑らかなアニメーションを作成して、クロスシミュレーションの結果から訓練データを作成。
・入出力データはベクトルもクォータニオンも全て繋げてフラットなfloat配列へ。状況に応じて正規化も忘れずに。
・モデル構造を入出力の次元数に合わせて調整
・Bifrostの動作結果とLossの推移を見ながら精度向上!


製品購入に関するお問い合わせ
オートデスク メディア&エンターテインメント 製品のご購入に関してご連絡を希望される場合は、こちらからお問い合わせください。