第1章 Unity 3D キャラクターアニメーションのチューニング

斉藤 俊介Shunsuke Saito

株式会社Colorful Palette

1.1 はじめに

本章では「Unityでの3Dキャラクターアニメーション機能の最適化手法」について、アセット設定や実装の複数の観点で紹介します。

1.1.1 計測環境

本章の実装・計測は以下の環境で行いました。

表1.1: 計測環境

項目値
Unity6000.5.7f1
プラットフォームmacOS Standalone (StandaloneOSX)
Scripting BackendIL2CPP / IL2CPP Compiler Configuration = Release / Code Generation = OptimizeSpeed
Graphics APIMetal / GPU Skinning = 有効
Quality LevelPC / Skin Weights = Four Bones

1.1.2 使用アセットとその構造

計測に使うキャラクターはunity-chanをベースに、Genericリグとして再インポートしたものです。 後述するOptimize Game Objectsの適用前後で以下のようなスペックとなっています。

表1.2: 計測に使うプレハブの Transform 数と頂点数

プレハブTransform数頂点数
Base16613,391
Optimized(Optimize Game Objects)2413,391

1.2 Unity Animationの基本制御について

まず、Unityでアニメーションを制御する3つの手法を整理します。

1.2.1 Animatorコンポーネントを使用した制御

AnimatorにRuntimeAnimatorControllerを割り当てる、もっとも一般的な方法です。ステートマシン・トランジション・ブレンドツリー・パラメータといった仕組みが揃っており、Mecanimのワークフローがそのまま使えます。

リスト1.1: Animator コンポーネントでの制御

1
2
3
var animator = GetComponent<Animator>();
animator.runtimeAnimatorController = controller;   // ステートマシンを割り当てる
animator.SetFloat("Blend", 0.5f);                  // パラメータで制御する

重要なのは、Animatorは内部的にPlayable Graphを組み立てて実行していることです。ステートマシンの評価もクリップのサンプリングも、内部ではグラフのノードとして処理されています。次の2手法は、この内部グラフを自分の手で組む方法にあたります。

1.2.2 Playable GraphをPlayable APIで構築し、自動更新で制御

PlayableGraphを自分で組み立てる方法です。ステートマシンを介さずにブレンド構造を完全に制御でき、後述するAnimation Job(AnimationScriptPlayable)をグラフの任意の位置に差し込めます。

リスト1.2: Playable API でのグラフ構築(自動更新)

1
2
3
4
5
6
7
8
9
10
11
12
13
var graph = PlayableGraph.Create("MyGraph");
var output = AnimationPlayableOutput.Create(graph, "Animation", animator);

var clipA = AnimationClipPlayable.Create(graph, waitClip);
var clipB = AnimationClipPlayable.Create(graph, walkClip);
var mixer = AnimationMixerPlayable.Create(graph, 2);
graph.Connect(clipA, 0, mixer, 0);
graph.Connect(clipB, 0, mixer, 1);
mixer.SetInputWeight(0, 0.5f);
mixer.SetInputWeight(1, 0.5f);

output.SetSourcePlayable(mixer);
graph.Play();

更新モードは既定でDirectorUpdateMode.GameTimeです。このモードではUnityのライフサイクル(UpdateとLateUpdateの間にあるAnimationフェーズ)で、Animatorコンポーネントと同じタイミングで自動的に評価されます。呼び出し側で毎フレーム何かをする必要はありません。

1.2.3 Playable GraphをPlayable APIで構築し、手動更新で制御

同じグラフにDirectorUpdateMode.Manualを設定すると、Unityは一切自動評価をしなくなり、Evaluate()を呼んだときだけグラフが評価されます。

リスト1.3: 手動更新でのグラフ評価

1
2
3
4
5
6
7
graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
graph.Play();

void Update()
{
    graph.Evaluate(Time.deltaTime);   // 呼んだときだけ評価される
}

評価のタイミングと頻度を完全に制御できるため、「特定のキャラクター(グラフ)だけ更新を止める」「数フレームに1回だけ更新する」といった制御がそのまま実装できます。

3手法の関係をまとめると次の通りです。

表1.3: アニメーション制御 3 手法の比較

手法グラフの構築評価のタイミング
AnimatorコンポーネントUnity(内部)UnityのAnimationフェーズ
Playable API + 自動更新自分UnityのAnimationフェーズ
Playable API + 手動更新自分Evaluate()を呼んだとき

1.3 アセットの設定

コード側の最適化を紹介する前に、アセット側の設定で適用できる最適化から見ていきます。この節のテーマはボーンのTransform階層をなくすことです。同じ結果を得る手段が2つあります。

1.3.1 Optimize Game Objects(インポート時)

FBXインポート設定(Rigタブ)のOptimize Game Objectsを有効にすると、実行時にボーンのGameObject / Transformが丸ごと生成されなくなり、Animatorが内部スケルトンだけを持つようになります。今回のキャラクターではTransformが166個から24個(ルート + Renderer 23個)に減りました。

※ボーンがGameObjectとして存在しないため、階層をたどってボーンを触るコードは動かなくなります。武器のアタッチポイントなど特定のボーンだけが必要な場合は、Extra Transforms to Exposeで個別に残します。

1.3.2 Optimize Hierarchy(ランタイム)

同じことを実行時に行うAPIがAnimatorUtility.OptimizeTransformHierarchyです。

リスト1.4: ランタイムでの階層フラット化

1
2
3
4
5
// go は Animator(と有効な Avatar)を持つルート。第 2 引数は露出させたいボーン名の配列
AnimatorUtility.OptimizeTransformHierarchy(go, null);

// 逆変換もある(エディタ拡張やデバッグで階層を一時的に戻したいとき)
AnimatorUtility.DeoptimizeTransformHierarchy(go);

インポート設定を触れないアセットでも、生成時に実行することで同じ構造になります。実際に確認したところ、Baseプレハブ(Transform 166個)に適用するとTransform 24個・hasTransformHierarchy == falseとなり、インポート時にOptimize Game Objectsを適用したプレハブと完全に同じ構造になりました。

最適化前のHierarchy。<code class="tt">Character1_Hips</code>以下にボーンのTransform階層が166個続く。

図1.1: 最適化前のHierarchy。Character1_Hips以下にボーンのTransform階層が166個続く。

<code class="tt">AnimatorUtility.OptimizeTransformHierarchy</code>適用後。ボーン階層が消え、ルート直下にSkinnedMeshRenderer 23個が並ぶだけになる(Transform 24個)。

図1.2: AnimatorUtility.OptimizeTransformHierarchy適用後。ボーン階層が消え、ルート直下にSkinnedMeshRenderer 23個が並ぶだけになる(Transform 24個)。

1.3.3 実測: 階層フラット化の効果

描画負荷を2段階に変えて測りました。1つめはRendererを全部有効にした構成(300体 × 23 Renderer = 6,900 Renderer)、2つめは各キャラクターで頂点数が最大のSkinnedMeshRenderer 1個だけを有効にして描画負荷を下げた構成です。フレーム時間は中央値です。

表1.4: 階層フラット化の効果(300 体・全 Renderer と 600 体・1 Renderer)

構成バリアントフレーム時間MeshSkinning.Update (main)MeshSkinning.CalcMatrices (all)Semaphore.WaitForSignal
300体・全RendererBase18.59 ms6.23 ms36.633 ms5.33 ms
300体・全RendererOptimize Game Objects18.497 ms (±0)1.537 ms (−75.7 %)0 ms10.255 ms
300体・全RendererOptimize Hierarchy18.307 ms (±0)1.504 ms (−76.2 %)0 ms9.977 ms
600体・1 RendererBase6.797 ms0.735 ms4.385 ms5.741 ms
600体・1 RendererOptimize Game Objects5.991 ms (−11.9 %)0.207 ms0 ms4.901 ms
600体・1 RendererOptimize Hierarchy6.009 ms (−11.6 %)0.208 ms0 ms5.010 ms

CPU側の処理は、どちらの構成でも同じ割合で減っています。メインスレッドのスキニング処理が6.321 → 約1.5 ms(全Renderer)、0.735 → 約0.21 ms(1 Renderer)で、いずれも7割超の削減を達成しています。スキニング行列の計算(MeshSkinning.CalcMatrices)に至っては全スレッド合計36.6 ms / 4.4 msが完全に消滅しています。

ただし「全Renderer構成」では、レンダースレッドの処理がフレーム時間に対するクリティカルパスとなっているため、フレーム時間の削減は見られません。

描画負荷を下げた1 Renderer構成では、同じ削減がそのまま12 %前後のフレーム時間短縮として現れます。300体でも同じ傾向で、3.312 → 2.899 / 2.901 ms(−12.5 % / −12.4 %)でした。インポート時のOptimize Game ObjectsとランタイムのOptimize Hierarchyの差は0.1 %未満で、構造が同じになる以上、効果も同じだということがわかります。

ProfilerのTimelineでも同じことが起きています。どちらも300体・全Renderer、フレーム時間の中央値付近のフレームです。

Base(Transform 166個)。フレーム時間18.59 msのうち、メインスレッドで<code class="tt">MeshSkinning.Update</code>が6.23 msを占め、<code class="tt">Gfx.WaitForPresentOnGfxThread</code> / <code class="tt">Semaphore.WaitForSignal</code>が5.33 ms。

図1.3: Base(Transform 166個)。フレーム時間18.59 msのうち、メインスレッドでMeshSkinning.Updateが6.23 msを占め、Gfx.WaitForPresentOnGfxThread / Semaphore.WaitForSignalが5.33 ms。

<code class="tt">AnimatorUtility.OptimizeTransformHierarchy</code>適用後。<code class="tt">MeshSkinning.Update</code>のブロックはラベルが読めない細さまで縮む一方、<code class="tt">Semaphore.WaitForSignal</code>が5.33 → 10.44 msに伸び、<code class="tt">PlayerLoop</code>は18.58 → 18.91 msとほぼ変わっていない。減らしたCPU時間が待ち時間に置き換わっている。

図1.4: AnimatorUtility.OptimizeTransformHierarchy適用後。MeshSkinning.Updateのブロックはラベルが読めない細さまで縮む一方、Semaphore.WaitForSignalが5.33 → 10.44 msに伸び、PlayerLoopは18.58 → 18.91 msとほぼ変わっていない。減らしたCPU時間が待ち時間に置き換わっている。

1.3.4 実測: Renderer全無効(アニメーション評価だけ)

描画とスキニングを完全に外し、アニメーション評価そのものへの寄与を見ます。300体です。

表1.5: Renderer を全無効にしたときのフレーム時間

バリアントフレーム時間
Base2.702 ms
Optimize Game Objects2.687 ms (−0.6 %)
Optimize Hierarchy2.496 ms (−7.6 %)

クリップのサンプリングやブレンドといった評価処理はTransform階層の有無に関係なく走るため、こちらへの寄与はほぼないと言えます。このことから判明するのは、この最適化の目的は主にスキニング行列の計算とTransformへの書き戻しに対するもので、アニメーション評価そのものではない、ということです。

1.3.5 この章の結論

  • 階層フラット化はスキニング行列計算(MeshSkinning.CalcMatrices)の削減に繋がる
  • インポート時(Optimize Game Objects)とランタイム(AnimatorUtility.OptimizeTransformHierarchy)で効果は同等
  • どちらを選ぶかは運用の問題: 自前アセットならインポート設定で済ませる、インポート設定を触れない事情があるならランタイムAPIを使う
  • モデル自体の頂点数・骨数を制作段階で適正化しておくことが最優先

1.4 Job Threadの活用

1.4.1 Unityのアニメーション並列化

Animatorコンポーネント、および自動更新のPlayable Graphは、Unityがすべてのグラフをまとめてスケジュールし、Animation Job Threadに分散します。

問題は手動更新です。PlayableGraph.Evaluate()はそのグラフ内部の評価処理を並列化するもので、グラフをまたいで並列化はしません。したがって「1キャラクター = 1 Playable Graph」という直感的な構成でN体を制御すると、Evaluate()をN回呼ぶことになり、N個のグラフが直列に処理されます。

これはUnityのPlayable Graphの避けられない仕様なのですが、対策はシンプルで、N体を1つのグラフにまとめることです。

リスト1.5: 全キャラクターを 1 本のグラフに集約する

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1 本のグラフに、キャラクターごとの出力とサブツリーをぶら下げる
_sharedGraph = PlayableGraph.Create("Shared");
_sharedGraph.SetTimeUpdateMode(DirectorUpdateMode.Manual);

foreach (var character in characters)
{
    var output = AnimationPlayableOutput.Create(_sharedGraph, "Animation", character.Animator);
    output.SetSourcePlayable(BuildClipSource(_sharedGraph));   // 各キャラのミキサー
}

_sharedGraph.Play();

// 更新は 1 回だけ
void Update() => _sharedGraph.Evaluate(Time.deltaTime);

1.4.2 実測1: 制御手法 × キャラクター数

Rendererは全無効にして、アニメーション評価のコストだけを取り出しています。フレーム時間の中央値です。

表1.6: 制御手法とキャラクター数ごとのフレーム時間

制御方法100体300体600体
Animatorコンポーネント0.899 ms2.413 ms5.004 ms
Playable API + 自動更新0.899 ms2.498 ms5.093 ms
Playable API + 手動更新(1体1グラフ)1.395 ms3.925 ms8.396 ms
Playable API + 手動更新(全体で1グラフ)0.800 ms2.115 ms4.500 ms

AnimatorとPlayable自動更新はほぼ同じコストです。 「Playable APIのほうが軽い」という言説は、少なくとも「シンプルなアニメーション制御」の条件では成立しません。Animatorのステートマシン評価(AnimatorControllerPlayable.PrepareFrame)は600体でも全スレッド合計2.624 ms、うちメインスレッドは0.161 msしかなく、この処理がボトルネックになる可能性は極めて低いと言えます。(ただし、2 msを大きい/小さいどちらと捉えるかはゲーム次第だと考えます。)

手動更新はグラフの構造で1.87倍変わります。同じ600体でも、グラフのまとめ方しだいで自動更新より速くも遅くもなります。

1.4.3 実測2: スレッドの使われ方

600体、Renderer無効。Unity Profilerのキャプチャからスレッド別に集計した結果です。

表1.7: グラフ粒度によるスレッドの使われ方の違い(600 体)

1体1グラフ(600グラフ)600体1グラフ
フレーム時間9.490 ms4.928 ms
メインスレッドのEvaluate9.028 ms4.556 ms
アニメーション処理を担ったワーカースレッド数515
Animator.ProcessGraph全スレッド合計0.920 ms19.034 ms
ワーカー1本あたりのAnimator.ProcessGraph0.32 / 0.15 / 0.03 / 0.02 / 0.002 ms各約1.19 ms(15本ほぼ均等)

1体1グラフのとき、アニメーション処理はほぼ全部メインスレッド上にあります。600回のEvaluate()が順番に走っているだけで、ワーカースレッドはほとんど働いていません。

600体1グラフのとき、15本すべてのワーカースレッドがほぼ均等にAnimator.ProcessGraphを実行しています。

1体1グラフ(600グラフ)のTimeline。メインスレッドの<code class="tt">ATB.ManualEvaluate</code>が約8.6 msを占め、Jobワーカーの大半が「Idle」のまま。

図1.5: 1体1グラフ(600グラフ)のTimeline。メインスレッドのATB.ManualEvaluateが約8.6 msを占め、Jobワーカーの大半が「Idle」のまま。

600体1グラフのTimeline。メインスレッドの<code class="tt">ATB.ManualEvaluate</code>は約4.3 msに半減し、全ワーカーが約1.2 msずつの<code class="tt">EngineJob</code>を同時に実行している。

図1.6: 600体1グラフのTimeline。メインスレッドのATB.ManualEvaluateは約4.3 msに半減し、全ワーカーが約1.2 msずつのEngineJobを同時に実行している。

数値の読み方に1点補足があります。

この表の数値はUnity Profilerを接続した状態のキャプチャから取っているため、Profilerなしで走らせた計測(4.500 ms / 8.396 ms)よりわずかに重く出ています。章末のまとめ表はProfiler非接続の数値を使っています。

1.4.4 この章の結論

表1.8: 600 体でのフレーム時間まとめ

構成600体のフレーム時間
1体1グラフ + 手動更新8.396 ms
Animator(自動)5.004 ms
600体1グラフ + 手動更新4.500 ms
  • Animatorと自動更新のPlayable Graphは、Unityが全グラフをまとめてAnimation Job Threadに分散してくれる
  • 手動更新はグラフ単位でしか並列化されない
  • 手動更新を選ぶ場合は、グラフの粒度を必ず設計項目に考慮すること
  • Playable APIへの置き換え自体は最適化効果はほぼない。Playable APIを用いる真の価値は「Animation Jobを差し込める」「グラフ構造と更新タイミングを制御できる」といったアニメーション制御の柔軟性にある

1.5 Animation C# Jobsの利用とSIMD対応について

「プロシージャルにアニメーションを制御したい」というニーズはゲーム開発中に必ず発生します。例えば「キャラクターの足を地面の形状に接地するように動かしたい」などの場合です。

この場合とりうる手段としてTwo-Bone IKという手法があります。

実際にTwo-Bone IKのようなプロシージャルアニメーション制御を実装するとき、選択肢は3つあります。

  1. LateUpdateでTransformを直接書き換える
  2. AnimationScriptPlayableにIAnimationJobを差し込む
  3. 2に加えてBurstでコンパイルする

今回はこれらの手法のパフォーマンスに着目します。

3つとも完全に同一の演算を行うように実装しました。解法は1つのstaticメソッドに切り出し、LateUpdate版・Job版・Burst版がそれを共有しているため、違うのは実行される場所だけです。

1.5.1 Animation C# Jobsとは

Animation C# Jobsは、AnimationScriptPlayableとIAnimationJobによってアニメーション評価パイプラインの中に自前の処理を差し込む仕組みです。ジョブはクリップの評価やブレンドと同じ流れの中で呼ばれ、ボーンにはAnimationStreamとTransformStreamHandleを通してアクセスします。

LateUpdateとの違いは、コードが動く場所です。LateUpdateはメインスレッド上で、アニメーション評価が終わったあとのTransformを直接書き換えます。処理はキャラクター数分メインスレッドに直列に積み上がります。Animation Jobは評価パイプラインの内部(Animation Job Thread)で呼ばれるため、Unityは他のアニメーション評価と同じようにワーカースレッドへ分散できます。同じ演算でも、メインスレッドの負担になるかどうかが変わります。

AnimationStream経由というアクセス制約はその代償です。ジョブから見えるのは事前に束ねたハンドルのぶんだけで、任意のTransformやGameObjectは触れません。

1.5.2 実装

ジョブ本体は、ProcessAnimationで解法を呼ぶだけです。

リスト1.6: Two-Bone IK の Animation Job(Burst 対応)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[BurstCompile]
public struct TwoBoneIkJobBurst : IAnimationJob
{
    public TransformStreamHandle Root;
    public TransformStreamHandle Mid;
    public TransformStreamHandle Tip;
    public NativeArray<float3> Target;   // 長さ 1。毎フレーム、メインスレッドから書き込む
    public float Weight;
    public int Iterations;

    public void ProcessRootMotion(AnimationStream stream) { }

    [BurstCompile]
    public void ProcessAnimation(AnimationStream stream)
    {
        TwoBoneIkCore.SolveStreamIterations(
            ref stream, Root, Mid, Tip, Target[0], Weight, Iterations);
    }
}

ボーンの読み書きは、TransformStreamHandleとAnimationStreamの組で行います。

リスト1.7: AnimationStream からボーンを読み書きする

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void SolveStream(
    ref AnimationStream stream,
    TransformStreamHandle root, TransformStreamHandle mid, TransformStreamHandle tip,
    float3 target, float weight)
{
    var rootPosition = (float3)root.GetPosition(stream);
    var midPosition = (float3)mid.GetPosition(stream);
    var tipPosition = (float3)tip.GetPosition(stream);
    var rootRotation = (quaternion)root.GetRotation(stream);
    var midRotation = (quaternion)mid.GetRotation(stream);

    Solve(rootPosition, midPosition, tipPosition, target, rootRotation, midRotation, weight,
        out var newRootRotation, out var newMidRotation);

    mid.SetRotation(stream, newMidRotation);
    root.SetRotation(stream, newRootRotation);
}

ハンドルはAnimator.BindStreamTransformで作り、ジョブはクリップの後段としてグラフに挿入します。

リスト1.8: Animation Job をグラフに挿し込む

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var job = new TwoBoneIkJobBurst
{
    Root = animator.BindStreamTransform(rootTransform),
    Mid = animator.BindStreamTransform(midTransform),
    Tip = animator.BindStreamTransform(tipTransform),
    Target = ikTarget,   // NativeArray<float3>
    Weight = 1f,
    Iterations = iterations,
};

var ikPlayable = AnimationScriptPlayable.Create(graph, job, 1);
graph.Connect(clipSource, 0, ikPlayable, 0);   // clipSource = クリップ側のミキサー
ikPlayable.SetInputWeight(0, 1f);
output.SetSourcePlayable(ikPlayable);

IKターゲットのような毎フレーム変わる入力はNativeArrayに置き、メインスレッドから書き込みます。

BurstはBurstCompile属性をクラスに付与することで適用されます。

実際にBurst Compilerによる最適化が効いているかはBurst Inspectorなどのツールを用いて確認することができます。

1.5.3 実測

300体、Renderer無効。1体あたりのIK解法回数を1 / 16 / 64と変えています。

表1.9: IK 解法回数ごとのフレーム時間(300 体・Renderer 無効)

実装1回16回64回
IKなし(基準)2.501 ms2.601 ms2.596 ms
LateUpdate2.699 ms4.400 ms9.094 ms
Animation Job2.411 ms2.496 ms2.795 ms
Animation Job + Burst2.494 ms2.496 ms2.792 ms

CPU時間の内訳(64回のとき):

表1.10: IK 実装ごとの CPU 時間の内訳(解法 64 回)

実装IKのCPU時間(全スレッド)うちメインスレッドフレーム時間への上乗せ
LateUpdate6.910 ms6.910 ms(100 %)+6.498 ms
Animation Job7.790 ms0.487 ms(6.3 %)+0.199 ms
Animation Job + Burst6.932 ms0.436 ms(6.3 %)+0.196 ms

1.5.4 Profiler Timelineで見る実行スレッドの違い

同じ演算がどのスレッドで実行されているかは、Unity EditorのProfilerに現れます。以下は解法64回・300体のキャプチャをProfilerウィンドウで開き、フレーム時間の中央値付近のフレームを選択したものです。Profiler接続時のキャプチャなので、上の表の数値とは完全には一致しません。

LateUpdate実装。フレーム時間8.698 msのうち<code class="tt">LateBehaviourUpdate</code>が6.63 msを占める。ワーカースレッド15本はフレームの大半(約6.8〜7.7 ms)が<code class="tt">Idle</code>のまま。

図1.7: LateUpdate実装。フレーム時間8.698 msのうちLateBehaviourUpdateが6.63 msを占める。ワーカースレッド15本はフレームの大半(約6.8〜7.7 ms)がIdleのまま。

Animation Job実装。同じ演算が<code class="tt">AnimationScriptPlayable.ProcessAnimation</code>としてワーカースレッド15本に分散し、1本あたり約0.5 msずつ実行されている。フレーム時間は2.796 ms。

図1.8: Animation Job実装。同じ演算がAnimationScriptPlayable.ProcessAnimationとしてワーカースレッド15本に分散し、1本あたり約0.5 msずつ実行されている。フレーム時間は2.796 ms。

LateUpdate実装では、300体分のATB.TwoBoneIk.LateUpdate(合計6.577 ms)がすべてメインスレッドのLateBehaviourUpdateの中にあります。このときワーカースレッドが担っているのはベースのアニメーション評価(Animator.ProcessGraphが1本あたり0.12〜0.29 ms)だけで、IKの計算は1つも載っていません。

Animation Job実装ではAnimationScriptPlayable.ProcessAnimationの全スレッド合計が7.891 msで、うちメインスレッドは0.498 msです。残りの7.393 msはワーカースレッド15本にほぼ均等(1本あたり0.47〜0.51 ms)に分散しています。総CPU時間はLateUpdate実装より多いものの、それが16本のスレッドに分かれるため、フレーム時間は8.698 → 2.796 msに収まります。

1.5.5 読み取れること

このケースの最適化について、最も効果的なことはBurstではなく、実行スレッドを移すことです。 LateUpdate実装はIKのCPU時間を丸ごとメインスレッドで払うため、64回ではフレーム時間が2.596 → 9.094 msに膨らみます。同じ演算をAnimation Jobに移すとメインスレッドの負担はほぼ消え、フレーム時間への上乗せで33倍の差がつきます。

一方で、総CPU時間ではLateUpdate版のほうが少なくなっています。Animation JobにはAnimationScriptPlayableノードごとの固定コストがあり、300体分では約2.16 msです。それでも並列化の利得がはるかに上回ります。

実運用に近い「1体1回」の条件では、3実装の差はほぼ測定ノイズの範囲です。300体程度のTwo-Bone IKなら、どの実装を選んでも体感差はありません。

1.5.6 Burstは属性を付けるだけでは効かない

[BurstCompile]を付ければSIMDになって数倍速くなる、という理解は誤りです。今回の実測では、Burstによる総CPU時間の削減は11.0 %です。ノード固定コストを差し引いた「解法1回あたりの限界コスト」で見ると、

  • Burstなし: (7.790 − 2.157) / 63 = 0.0894 ms
  • Burstあり: (6.932 − 2.167) / 63 = 0.0756 ms(−15.4 %)

Burstが行うのはLLVMによるネイティブコード生成で、SIMD命令が出るかどうかは書かれたコードの形で決まります。適切にSIMD化されるコードを実装しなければ、大きな恩恵は得られません。

Animation C# Jobsには、SIMD化を難しくする性質が2つあります。

AnimationStreamの読み書きが処理の中に頻繁に現れます。解法1回ごとにGetPosition・GetRotationでボーンを読み、SetRotationで書き戻します。反復のたびにストリームの状態が変わるため反復間に依存関係が残り、複数回分を束ねて1つのベクトル命令にはできません。さらにIAnimationJobが受け取るAnimationStreamは1体分なので、「4体分を同時に計算する」ためのデータがそもそもジョブの中にありません。

超越関数の利用が多発します。余弦定理から曲げ角を得るacos、quaternion.AxisAngleが内部で呼ぶ三角関数、math.lengthの平方根。これらはレーンをまたぐ計算やスカラーのライブラリ呼び出しになり、ベクトル化を止めます。

BurstのLLVMも、解法を繰り返し呼ぶループをベクトル化できないと明示的に報告します(最適化リマークloop not vectorized: instruction return type cannot be vectorized)。

1.5.7 Burstの恩恵をフル活用するための作戦

Burstの恩恵をフル活用したいなら、C# Job Systemを使ってAnimationStreamを介さずにTransformを読み書きする構成が候補になります。Transformの読み書きはTransformAccessArrayとIJobParallelForTransformでジョブ側にまとめられますし、ボーンの収集と書き戻しだけをAnimation Jobに任せ、重い計算はN体分をまとめたバッチジョブに切り出す形も取れます。どちらもキャラクター単位ではなくN体分のデータがひとつのジョブの中に揃うため、SIMD化の前提が成立します。

そのうえで、バッチジョブの中身は次のように整える必要があります。

  • 1成分1配列のSoA(Structure of Arrays)にして、キャラクターをメモリ上で連続させる
  • 分岐をmath.selectやmath.maxに置き換え、ループ本体を1本の直線にする
  • 超越関数をループから追い出す(恒等式で書き換える、テーブル化する、近似する)
  • float3ではなくfloat4で「4体分を1レーンずつ」書き、自動ベクトル化の判断に依存しない

代償は設計の複雑さです。IAnimationJobは、グラフに1ノード挿すだけで評価パイプラインの正しいタイミングでボーンの最新状態を触れる、という手軽さが最大の利点で、この作戦はそれを捨てることになります。本記事ではこの構成の実測を行っていないため、どれだけ速くなるかはここでは示しません。Burstに数倍の効果を期待するならこの方向の設計変更が前提になる、という提案として扱ってください。

1.5.8 この章の結論

  • Animation Jobの価値はAnimation Job Threadが活用できること
  • 演算量が少ないうちはAnimationScriptPlayableのノード固定コストのほうが大きい。素直にLateUpdateで書いたほうが総CPU時間は少ない
  • [BurstCompile]は付けるだけではSIMDにならない。SIMDが適用されることを見越したデータ・処理の設計を行う必要がある

1.6 アニメーションLODの実装

1.6.1 仕組みと効果的な場面

ゲーム仕様にもよりますが、基本的には遠くのキャラクターまで毎フレーム更新する必要はありません。Nフレームに1回だけ更新すれば、そのキャラクターの平均負荷は1/Nになります。これが「アニメーションLOD」です。メッシュのLODが描画コストを距離で段階制御するのと同じ発想を、アニメーションの更新頻度に適用します。

効果的なのは次のような場面です。

  • 画面内に数百体規模のキャラクターがいて、大半は遠景やモブである
  • 遠いキャラクターの30 fps / 15 fps相当の動きが体感上許容できる(カメラが遠いほど間引きは気づかれにくい)
  • アニメーション評価がフレーム時間の中で無視できない割合を占めている

逆に、近距離でカメラに大写しになるキャラクターに適用するとカクつきがすぐ分かります。距離やスクリーン占有率に応じて間引き段階を切り替えるのが想定される実運用でしょう。

1.6.2 シンプルな実装: Animator.Update()を自分で呼ぶ

animator.enabled = falseにしてUnityの自動更新を止め、必要なフレームだけAnimator.Update()を明示的に呼びます。飛ばしたフレーム分の時間は溜めて渡します。

この実装にはこれまで説明してきたAnimation Job Threadでの並列性を失うという副作用があります。

リスト1.9: Animator.Update() を自分で呼ぶ間引き実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
animator.enabled = false;   // 初期化時。Unity の自動更新を止める

void Update()
{
    foreach (var character in _characters)
    {
        character.PendingDeltaTime += Time.deltaTime;
        if (_frameIndex % interval != character.Slot) continue;   // Slot = index % interval

        character.Animator.Update(character.PendingDeltaTime);    // 溜めた時間で明示更新
        character.PendingDeltaTime = 0f;
    }
    _frameIndex++;
}

1.6.3 Playable APIを直接用いた実装例

手動更新(DirectorUpdateMode.Manual)のPlayable Graphなら、Evaluate()の呼び出しそのものを間引くだけです。

リスト1.10: 手動更新グラフの Evaluate() を間引く実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
graph.Play();

void Update()
{
    foreach (var character in _characters)
    {
        character.PendingDeltaTime += Time.deltaTime;
        if (_frameIndex % interval != character.Slot) continue;

        character.Graph.Evaluate(character.PendingDeltaTime);
        character.PendingDeltaTime = 0f;
    }
    _frameIndex++;
}

1つ注意があります。前章で推奨した「全キャラクターを1グラフに集約する」構成では、グラフ全体を1回のEvaluate()で評価するため、キャラクター単位の間引きが表現できません。上のコードのように1体1グラフにすれば間引けますが、そのぶん集約による並列化を失います。

どちらの実装でも重要なのがSlot(位相)です。全キャラクターを同じフレームに更新すると、そのフレームだけ負荷が集中します。index % intervalで位相をずらせば、毎フレーム1/N体ずつが更新される形になります。

1.6.4 実測

600体、Renderer無効。まずAnimatorでの実装です。

平均値は12秒間の総負荷を、中央値とp95の開きはフレーム時間の暴れ具合を表します。

表1.11: 間引きと位相ずらしのフレーム時間統計(Animator・600 体)

方式平均中央値p95p99最大標準偏差
毎フレーム更新(Animator自動)5.037 ms5.000 ms5.303 ms5.707 ms16.901 ms0.433
2フレームに1回・位相なし4.781 ms0.648 ms9.607 ms9.816 ms12.194 ms4.544
2フレームに1回・位相ずらし4.831 ms4.803 ms5.099 ms5.212 ms8.804 ms0.171
4フレームに1回・位相なし2.613 ms0.382 ms9.470 ms9.658 ms11.171 ms3.863
4フレームに1回・位相ずらし2.578 ms2.593 ms2.707 ms2.908 ms11.722 ms0.185

Playable API(1体1グラフ・手動更新)でも同じ傾向です。

表1.12: 間引きと位相ずらしのフレーム時間統計(Playable API・1 体 1 グラフ)

方式平均中央値p95標準偏差
毎フレーム更新8.496 ms8.493 ms9.001 ms0.337
4フレームに1回・位相なし2.438 ms0.369 ms8.855 ms3.570
4フレームに1回・位相ずらし2.486 ms2.495 ms2.613 ms0.173

1.6.5 位相ずらしは「負荷を減らす」のではなく「平らにする」

4分の1に間引いたときの平均値は位相の有無でほとんど変わらず、総負荷はほぼ同じです。位相ずらしは仕事を減らしているわけではありません。

一方で、フレーム時間の分布には次の違いが出ています。

  • 位相なし: 4フレームのうち3フレームは何もせず、1フレームで600体分をまとめて処理する。中央値は下がるがp95と標準偏差が跳ね上がる
  • 位相ずらし: 毎フレーム150体ずつ処理する。中央値とp95がほぼ一致し、ばらつきが小さい

中央値0.382 msという数字だけを見て「1/13になった」と喜ぶと、実際には9.5 msのスパイクが4フレームに1回刺さるゲームができあがります。位相ずらしは体感品質のための施策であり、標準偏差0.185は毎フレーム更新の0.433よりも安定しています。

この分布の違いはProfilerのCPUチャートにそのまま現れます(いずれもProfiler接続時のキャプチャなので、上表の非接続の数値より重めに出ています。またチャートの縦軸スケールは自動調整のため2枚で異なります)。

4フレームに1回・位相なし。CPUチャートが「ほぼ0 msの谷3フレーム + 約10 msのスパイク1フレーム」の櫛状になる。下のTimelineでは選択中の空フレーム(0.47 ms)の両隣に、600体分をまとめて処理する<code class="tt">ATB.ManualAnimatorUpdate</code>(10.37 ms)のフレームが写っている。

図1.9: 4フレームに1回・位相なし。CPUチャートが「ほぼ0 msの谷3フレーム + 約10 msのスパイク1フレーム」の櫛状になる。下のTimelineでは選択中の空フレーム(0.47 ms)の両隣に、600体分をまとめて処理するATB.ManualAnimatorUpdate(10.37 ms)のフレームが写っている。

4フレームに1回・位相ずらし。毎フレーム150体ずつ処理するため波形は平坦(約3.5 ms)で、スパイクが消えている。

図1.10: 4フレームに1回・位相ずらし。毎フレーム150体ずつ処理するため波形は平坦(約3.5 ms)で、スパイクが消えている。

1.6.6 素朴な実装は並列化を失う

2フレームに1回の間引きは、ほとんど何も削っていません。更新回数を半分にしたのに、平均フレーム時間の削減はわずか4.1 %です。

この理由は、Profilerマーカーの内訳から分かります。

表1.13: 素朴な間引き実装で失われる並列性

方式1フレームに更新する体数メインスレッドのアニメーション更新時間Animator.ProcessGraph全スレッド合計うちメインスレッド
毎フレーム更新(Unityのバッチ更新)600Animators.Update 4.046 ms20.104 ms1.257 ms
2フレームに1回・位相ずらし300ATB.ManualAnimatorUpdate 4.532 ms0.549 ms0.317 ms
4フレームに1回・位相ずらし150ATB.ManualAnimatorUpdate 2.316 ms0.278 ms0.180 ms

毎フレーム更新では、仕事のうちメインスレッドに残るのは6 %だけで、残りはワーカースレッドに散っています。一方Animator.Update()を明示的に呼ぶとその場で同期的に評価されるため、ワーカースレッドがほとんど使われません。Unityのバッチ更新が持っていた並列化を、間引きと引き換えに手放しています。

1体あたりのメインスレッドコストに直すと、

  • 毎フレーム更新: 4.046 ms / 600体 = 6.7 µs/体(並列)
  • 2フレームに1回: 4.532 ms / 300体 = 15.1 µs/体(直列)
  • 4フレームに1回: 2.316 ms / 150体 = 15.4 µs/体(直列)

直列化した瞬間に1体あたり約2.3倍になり、その後は間引き倍率によらず一定です。つまり2倍の間引きで得られる2倍の削減が、直列化による2.3倍の悪化でほぼ相殺されます。実測の4.1 %はその残差にすぎません。

素朴な実装のままでは、4倍間引き(−48.8 %)でようやく実質的な効果が出ます。ただし、これは避けられる損失です。

1.6.7 並列性を保ったまま間引く

Animator.Update()が同期的なのは避けられません。しかし「Unityのバッチ更新に参加させる体を毎フレーム選ぶ」という形にすれば、間引きながら並列性を保てます。2通りの実装があり、どちらもAnimator(とそのステートマシン)を使ったままです。

方法1: Animator.enabledを切り替える

Unityのアニメーション更新は、その時点で有効なAnimatorを集めて1つのバッチにし、Animation Job Threadに分散します。したがってAnimator.Update()を呼ぶ代わりに、そのフレームに更新したい体だけenabled = trueにしておけば済みます。評価はUnityが行うので並列化はそのまま残ります。

リスト1.11: Animator.enabled を切り替える間引き実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 初期化時。飛ばしたフレーム分は speed で取り戻す
foreach (var character in _characters)
{
    var animator = character.Animator;
    animator.keepAnimatorStateOnDisable = true;   // 無効化でステートを巻き戻さない(必須)
    animator.speed = interval;                    // interval フレームに 1 回しか更新されないぶんを補正
}

void Update()
{
    foreach (var character in _characters)
    {
        var scheduled = _frameIndex % interval == character.Slot;
        if (character.Animator.enabled != scheduled)
        {
            character.Animator.enabled = scheduled;   // Unity のバッチ更新に参加する体を選ぶだけ
        }
    }
    _frameIndex++;
}

keepAnimatorStateOnDisable = trueは忘れてはいけません。既定値のfalseではAnimatorを無効化するたびにコントローラのステートが巻き戻り、アニメーションが進まなくなります。

制約はAnimator.speedがレートであることです。Animator.Update(pendingDeltaTime)のように「溜めたΔtを正確に渡す」ことはできず、更新フレームのTime.deltaTime × intervalだけ進みます。フレーム時間が揺れる環境では進み方も揺れますが、平均の再生速度は保たれます。

方法2: 位相ごとにグラフを分ける

前章の結論は「N体を1つの手動グラフにまとめれば並列化される」でした。これを間引きに組み合わせます。位相の数だけグラフを作り、1フレームに1本だけEvaluate()するのです。1本のグラフにはN/interval体が入っているので、その1回のEvaluate()の中で並列化が効きます。

ステートマシンはAnimatorControllerPlayableとしてグラフに持たせれば、Animatorコンポーネントに割り当てたときと同じ評価が走ります。

リスト1.12: 位相ごとにグラフを分ける間引き実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 初期化時: 位相バケットごとに手動グラフを 1 本
for (var bucket = 0; bucket < interval; bucket++)
{
    var graph = PlayableGraph.Create($"Bucket_{bucket}");
    graph.SetTimeUpdateMode(DirectorUpdateMode.Manual);
    _bucketGraphs[bucket] = graph;
}

foreach (var character in _characters)
{
    var graph = _bucketGraphs[character.Slot];
    character.Animator.runtimeAnimatorController = null;   // 評価はグラフ側に任せる
    var output = AnimationPlayableOutput.Create(graph, "Animation", character.Animator);
    output.SetSourcePlayable(AnimatorControllerPlayable.Create(graph, controller));
}

foreach (var graph in _bucketGraphs) graph.Play();

void Update()
{
    for (var bucket = 0; bucket < interval; bucket++) _pendingDeltaTime[bucket] += Time.deltaTime;

    var scheduled = _frameIndex % interval;
    _bucketGraphs[scheduled].Evaluate(_pendingDeltaTime[scheduled]);   // 1 フレームに 1 バケットだけ
    _pendingDeltaTime[scheduled] = 0f;
    _frameIndex++;
}

こちらはEvaluate()に溜めたΔtをそのまま渡せるので、進み方が正確です。代わりにグラフを間引き段階数だけ持つことになり、LOD段階を動的に変えるにはキャラクターをグラフ間で移す(出力を作り直す)必要があります。

実測: 3つの実装の比較

600体、Renderer無効。平均フレーム時間と、毎フレーム更新(5.037 ms)に対する削減率です。

表1.14: 3 つの間引き実装のフレーム時間比較(600 体)

実装1/2間引き1/4間引き
Animator.Update()を明示呼び出し(位相ずらし)4.831 ms(−4.1 %)2.578 ms(−48.8 %)
Animator.enabledの切り替え3.350 ms(−33.5 %)1.835 ms(−63.6 %)
位相ごとの手動グラフ(AnimatorControllerPlayable)3.082 ms(−38.8 %)1.541 ms(−69.4 %)

素朴な実装がいちばん割に合いません。とくに1/2間引きでの差が大きく、−4.1 %が−33.5 % / −38.8 %になりました。「2倍の間引きは並列化の損失に食われる」という前節の結論は、実装を変えれば成立しません。

並列性が保たれていることはProfilerマーカーで確認できます。

表1.15: 3 つの間引き実装のスレッド別内訳

方式Animator.ProcessGraph全スレッド合計うちメインスレッドメインスレッド側の更新時間
毎フレーム更新(600体/フレーム)20.104 ms1.257 msAnimators.Update 4.046 ms
Animator.Update()・1/4(150体/フレーム)0.278 ms0.180 msATB.ManualAnimatorUpdate 2.316 ms
Animator.enabled・1/4(150体/フレーム)4.424 ms0.288 msAnimators.Update 1.104 ms + 切り替え0.125 ms
位相ごとの手動グラフ・1/4(150体/フレーム)4.253 ms0.279 msATB.BucketEvaluate 1.278 ms

新しい2実装では、Animator.ProcessGraphの仕事量が毎フレーム更新のちょうど1/4になり(1フレームに150体しか評価しないので当然です)、その仕事は依然としてワーカースレッドに散っています。一方Animator.Update()版はこのマーカーにほとんど計上されず、仕事の本体はメインスレッドのATB.ManualAnimatorUpdateの中にあります。

1体あたりのメインスレッドコストで並べると差がはっきりします。

  • 毎フレーム更新: 4.046 ms / 600体 = 6.7 µs/体
  • Animator.enabled切り替え・1/4: 1.229 ms / 150体 = 8.2 µs/体
  • 位相ごとの手動グラフ・1/4: 1.278 ms / 150体 = 8.5 µs/体
  • Animator.Update()・1/4: 2.316 ms / 150体 = 15.4 µs/体

新しい2実装は1体あたりのコストがほぼ毎フレーム更新のままです(差は切り替えやEvaluateの呼び出しオーバーヘッド)。そのため間引き倍率がそのまま削減率に近づきます。

再生速度が保たれていることも確認しました。最後にスポーンしたキャラクター(位相スロットが最大の体)の指先ボーンが1秒あたりに動く距離を測ると、毎フレーム更新0.509 m/sに対しAnimator.enabled切り替え0.496 m/s、位相ごとの手動グラフ0.517 m/sです。間引きで動きが直線的に近似されるぶんの誤差の範囲で、どちらも速度が変わっていません。

分布については1点注意があります。1/2間引きの手動グラフ版はp99が9.102 ms、標準偏差1.086と、enabled切り替え版(p99 4.100 ms、標準偏差0.562)より裾が重く出ました。1/4では逆に手動グラフ版のほうが安定します(標準偏差0.306対0.340)。グラフの本数と1本あたりの体数の兼ね合いで挙動が変わるので、採用するなら平均だけでなくp99も見てください。

どちらを選ぶかは要件次第です。Animatorのステートマシンとインスペクタのワークフローをそのまま使い続けたいならAnimator.enabledの切り替えが圧倒的に手軽です(数行で済み、既存コードへの影響もほぼありません)。進み方の正確さや、Animation Jobを差し込むといったグラフ側の制御が要るなら位相ごとの手動グラフです。どちらにしてもAnimator.Update()を自分で呼ぶ実装を選ぶ理由はありません。

1.6.8 この章の結論

  • アニメーションLODは「更新頻度の間引き + 位相ずらし」のセットで設計する
  • 位相ずらしは総負荷を減らさない(平均2.613対2.578 ms)。減らすのはフレーム時間のばらつき(標準偏差3.863 → 0.185)であり、これは体感品質のための施策
  • Animator.Update()を自分で呼ぶ実装は選ばない。その場で同期評価されるためジョブ並列化を失い、1体あたりのメインスレッドコストが6.7 → 15.4 µsと約2.3倍になる
  • 代わりにUnityのバッチ更新に参加する体を毎フレーム選ぶ。Animator.enabledの切り替え(keepAnimatorStateOnDisable = trueとAnimator.speedでの補正が必要)か、位相ごとに手動グラフを分けてAnimatorControllerPlayableを載せる形。1体あたり8.2〜8.5 µsに収まり、並列化も残る
  • 実装を変えると2倍の間引きでも効く: −4.1 %が−33.5 % / −38.8 %になった。4倍なら−63.6 % / −69.4 %
  • キャラクター単位の間引きは1本の集約グラフでは表現できない。ただし「位相の数だけグラフを分ける」なら並列化と更新頻度制御を両立できる
  • 中央値だけで判断しないこと。総負荷は平均で、体感はp95・p99と標準偏差で見る

1.7 まとめ

各章の実測結果です。

表1.16: 各章の実測結果まとめ

章施策条件結果
アセットの設定Optimize Game Objects600体・Renderer 1個6.797 → 5.991 ms(−11.9 %)
アセットの設定Optimize Hierarchy(ランタイム)600体・Renderer 1個6.797 → 6.009 ms(−11.6 %)
アセットの設定上記2手法300体・全Renderer18.205 → 18.3〜18.5 ms(改善なし・描画律速)
Job Thread手動更新グラフを1本に集約600体8.396 → 4.500 ms(−46.4 %)
Animation C# JobsTwo-Bone IKをAnimation Job化300体・64回解法9.094 → 2.795 ms(−69.3 %)
Animation C# JobsさらにBurstでコンパイル300体・64回解法2.795 → 2.792 ms(フレーム時間は差なし)
アニメーションLOD1/4間引き・Animator.Update()を明示呼び出し600体平均5.037 → 2.578 ms(−48.8 %)
アニメーションLOD1/4間引き・位相ごとの手動グラフ600体平均5.037 → 1.541 ms(−69.4 %)
アニメーションLOD1/2間引き・Animator.Update()を明示呼び出し600体平均5.037 → 4.831 ms(−4.1 %)
アニメーションLOD1/2間引き・位相ごとの手動グラフ600体平均5.037 → 3.082 ms(−38.8 %)

1.7.1 最終的な最適化アプローチ

この記事の実測を踏まえた、大量キャラクターのアニメーション最適化の進め方です。

1. アセット設定を最適化する

まずはモデル自体の頂点数や骨数の仕様を適切な値に定めましょう。基準となるサポート端末でベンチマークを計測し、適切なモデルレギュレーションを定めることがまずは理想です。そのうえでUnity側でOptimize Game Objectsの適用を検討しましょう。3Dタイトルにおいて、上流に位置するモデル制作を量産が進んだプロジェクト終盤で大きく修正していくことは難しいため、プロジェクト序盤に行えることが最適です。

2. Animation Job Threadでの処理の並列性を高める

Animatorと自動更新のPlayable GraphはUnityが自動で並列化しますが、手動更新はグラフ単位でしか並列化されません。キャラクター制御の設計を行う時点で「Animation Job Threadを有効活用できるか」を念頭に入れましょう。

3. Animation JobのBurst適用を含めた最適化を行う

IKなどの後処理はまずメインスレッドからAnimation Jobへ移します。

さらにパフォーマンスが必要とされる場合はSIMD化を適用できるデータ・処理設計を適用しましょう。

4. アニメーションLODなどのアプリケーション的な工夫を行う

アセット・エンジン設定・コード最適化を尽くしたら、最後は「そもそもアニメーションを処理しない」という工夫です。

プロジェクトの要件と相談になりますが、ゲーム体験的に許容できるラインを探りながら処理を抑える仕組みを検討しましょう。


1.8 脚注

1.8.1 Unity公式ドキュメント

1.8.2 使用アセット