本章では「Unityでの3Dキャラクターアニメーション機能の最適化手法」について、アセット設定や実装の複数の観点で紹介します。
本章の実装・計測は以下の環境で行いました。
表1.1: 計測環境
| 項目 | 値 |
|---|---|
| Unity | 6000.5.7f1 |
| プラットフォーム | macOS Standalone (StandaloneOSX) |
| Scripting Backend | IL2CPP / IL2CPP Compiler Configuration = Release / Code Generation = OptimizeSpeed |
| Graphics API | Metal / GPU Skinning = 有効 |
| Quality Level | PC / Skin Weights = Four Bones |
計測に使うキャラクターはunity-chanをベースに、Genericリグとして再インポートしたものです。 後述するOptimize Game Objectsの適用前後で以下のようなスペックとなっています。
表1.2: 計測に使うプレハブの Transform 数と頂点数
| プレハブ | Transform数 | 頂点数 |
|---|---|---|
| Base | 166 | 13,391 |
| Optimized(Optimize Game Objects) | 24 | 13,391 |
まず、Unityでアニメーションを制御する3つの手法を整理します。
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手法は、この内部グラフを自分の手で組む方法にあたります。
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コンポーネントと同じタイミングで自動的に評価されます。呼び出し側で毎フレーム何かをする必要はありません。
同じグラフに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()を呼んだとき |
コード側の最適化を紹介する前に、アセット側の設定で適用できる最適化から見ていきます。この節のテーマはボーンのTransform階層をなくすことです。同じ結果を得る手段が2つあります。
FBXインポート設定(Rigタブ)のOptimize Game Objectsを有効にすると、実行時にボーンのGameObject / Transformが丸ごと生成されなくなり、Animatorが内部スケルトンだけを持つようになります。今回のキャラクターではTransformが166個から24個(ルート + Renderer 23個)に減りました。
※ボーンがGameObjectとして存在しないため、階層をたどってボーンを触るコードは動かなくなります。武器のアタッチポイントなど特定のボーンだけが必要な場合は、Extra Transforms to Exposeで個別に残します。
同じことを実行時に行う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を適用したプレハブと完全に同じ構造になりました。
図1.1: 最適化前のHierarchy。Character1_Hips以下にボーンのTransform階層が166個続く。
図1.2: AnimatorUtility.OptimizeTransformHierarchy適用後。ボーン階層が消え、ルート直下にSkinnedMeshRenderer 23個が並ぶだけになる(Transform 24個)。
描画負荷を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体・全Renderer | Base | 18.59 ms | 6.23 ms | 36.633 ms | 5.33 ms |
| 300体・全Renderer | Optimize Game Objects | 18.497 ms (±0) | 1.537 ms (−75.7 %) | 0 ms | 10.255 ms |
| 300体・全Renderer | Optimize Hierarchy | 18.307 ms (±0) | 1.504 ms (−76.2 %) | 0 ms | 9.977 ms |
| 600体・1 Renderer | Base | 6.797 ms | 0.735 ms | 4.385 ms | 5.741 ms |
| 600体・1 Renderer | Optimize Game Objects | 5.991 ms (−11.9 %) | 0.207 ms | 0 ms | 4.901 ms |
| 600体・1 Renderer | Optimize Hierarchy | 6.009 ms (−11.6 %) | 0.208 ms | 0 ms | 5.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、フレーム時間の中央値付近のフレームです。
図1.3: Base(Transform 166個)。フレーム時間18.59 msのうち、メインスレッドでMeshSkinning.Updateが6.23 msを占め、Gfx.WaitForPresentOnGfxThread / Semaphore.WaitForSignalが5.33 ms。
図1.4: AnimatorUtility.OptimizeTransformHierarchy適用後。MeshSkinning.Updateのブロックはラベルが読めない細さまで縮む一方、Semaphore.WaitForSignalが5.33 → 10.44 msに伸び、PlayerLoopは18.58 → 18.91 msとほぼ変わっていない。減らしたCPU時間が待ち時間に置き換わっている。
描画とスキニングを完全に外し、アニメーション評価そのものへの寄与を見ます。300体です。
表1.5: Renderer を全無効にしたときのフレーム時間
| バリアント | フレーム時間 |
|---|---|
| Base | 2.702 ms |
| Optimize Game Objects | 2.687 ms (−0.6 %) |
| Optimize Hierarchy | 2.496 ms (−7.6 %) |
クリップのサンプリングやブレンドといった評価処理はTransform階層の有無に関係なく走るため、こちらへの寄与はほぼないと言えます。このことから判明するのは、この最適化の目的は主にスキニング行列の計算とTransformへの書き戻しに対するもので、アニメーション評価そのものではない、ということです。
MeshSkinning.CalcMatrices)の削減に繋がるAnimatorUtility.OptimizeTransformHierarchy)で効果は同等Animatorコンポーネント、および自動更新のPlayable Graphは、Unityがすべてのグラフをまとめてスケジュールし、Animation Job Threadに分散します。
問題は手動更新です。PlayableGraph.Evaluate()はそのグラフ内部の評価処理を並列化するもので、グラフをまたいで並列化はしません。したがって「1キャラクター = 1 Playable Graph」という直感的な構成でN体を制御すると、Evaluate()をN回呼ぶことになり、N個のグラフが直列に処理されます。
これはUnityのPlayable Graphの避けられない仕様なのですが、対策はシンプルで、N体を1つのグラフにまとめることです。
Rendererは全無効にして、アニメーション評価のコストだけを取り出しています。フレーム時間の中央値です。
表1.6: 制御手法とキャラクター数ごとのフレーム時間
| 制御方法 | 100体 | 300体 | 600体 |
|---|---|---|---|
| Animatorコンポーネント | 0.899 ms | 2.413 ms | 5.004 ms |
| Playable API + 自動更新 | 0.899 ms | 2.498 ms | 5.093 ms |
| Playable API + 手動更新(1体1グラフ) | 1.395 ms | 3.925 ms | 8.396 ms |
| Playable API + 手動更新(全体で1グラフ) | 0.800 ms | 2.115 ms | 4.500 ms |
AnimatorとPlayable自動更新はほぼ同じコストです。 「Playable APIのほうが軽い」という言説は、少なくとも「シンプルなアニメーション制御」の条件では成立しません。Animatorのステートマシン評価(AnimatorControllerPlayable.PrepareFrame)は600体でも全スレッド合計2.624 ms、うちメインスレッドは0.161 msしかなく、この処理がボトルネックになる可能性は極めて低いと言えます。(ただし、2 msを大きい/小さいどちらと捉えるかはゲーム次第だと考えます。)
手動更新はグラフの構造で1.87倍変わります。同じ600体でも、グラフのまとめ方しだいで自動更新より速くも遅くもなります。
600体、Renderer無効。Unity Profilerのキャプチャからスレッド別に集計した結果です。
表1.7: グラフ粒度によるスレッドの使われ方の違い(600 体)
| 1体1グラフ(600グラフ) | 600体1グラフ | |
|---|---|---|
| フレーム時間 | 9.490 ms | 4.928 ms |
メインスレッドのEvaluate | 9.028 ms | 4.556 ms |
| アニメーション処理を担ったワーカースレッド数 | 5 | 15 |
Animator.ProcessGraph全スレッド合計 | 0.920 ms | 19.034 ms |
ワーカー1本あたりのAnimator.ProcessGraph | 0.32 / 0.15 / 0.03 / 0.02 / 0.002 ms | 各約1.19 ms(15本ほぼ均等) |
1体1グラフのとき、アニメーション処理はほぼ全部メインスレッド上にあります。600回のEvaluate()が順番に走っているだけで、ワーカースレッドはほとんど働いていません。
600体1グラフのとき、15本すべてのワーカースレッドがほぼ均等にAnimator.ProcessGraphを実行しています。
図1.5: 1体1グラフ(600グラフ)のTimeline。メインスレッドのATB.ManualEvaluateが約8.6 msを占め、Jobワーカーの大半が「Idle」のまま。
図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.8: 600 体でのフレーム時間まとめ
| 構成 | 600体のフレーム時間 |
|---|---|
| 1体1グラフ + 手動更新 | 8.396 ms |
| Animator(自動) | 5.004 ms |
| 600体1グラフ + 手動更新 | 4.500 ms |
「プロシージャルにアニメーションを制御したい」というニーズはゲーム開発中に必ず発生します。例えば「キャラクターの足を地面の形状に接地するように動かしたい」などの場合です。
この場合とりうる手段としてTwo-Bone IKという手法があります。
実際にTwo-Bone IKのようなプロシージャルアニメーション制御を実装するとき、選択肢は3つあります。
LateUpdateでTransformを直接書き換えるAnimationScriptPlayableにIAnimationJobを差し込む今回はこれらの手法のパフォーマンスに着目します。
3つとも完全に同一の演算を行うように実装しました。解法は1つのstaticメソッドに切り出し、LateUpdate版・Job版・Burst版がそれを共有しているため、違うのは実行される場所だけです。
Animation C# Jobsは、AnimationScriptPlayableとIAnimationJobによってアニメーション評価パイプラインの中に自前の処理を差し込む仕組みです。ジョブはクリップの評価やブレンドと同じ流れの中で呼ばれ、ボーンにはAnimationStreamとTransformStreamHandleを通してアクセスします。
LateUpdateとの違いは、コードが動く場所です。LateUpdateはメインスレッド上で、アニメーション評価が終わったあとのTransformを直接書き換えます。処理はキャラクター数分メインスレッドに直列に積み上がります。Animation Jobは評価パイプラインの内部(Animation Job Thread)で呼ばれるため、Unityは他のアニメーション評価と同じようにワーカースレッドへ分散できます。同じ演算でも、メインスレッドの負担になるかどうかが変わります。
AnimationStream経由というアクセス制約はその代償です。ジョブから見えるのは事前に束ねたハンドルのぶんだけで、任意のTransformやGameObjectは触れません。
ジョブ本体は、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などのツールを用いて確認することができます。
300体、Renderer無効。1体あたりのIK解法回数を1 / 16 / 64と変えています。
表1.9: IK 解法回数ごとのフレーム時間(300 体・Renderer 無効)
| 実装 | 1回 | 16回 | 64回 |
|---|---|---|---|
| IKなし(基準) | 2.501 ms | 2.601 ms | 2.596 ms |
| LateUpdate | 2.699 ms | 4.400 ms | 9.094 ms |
| Animation Job | 2.411 ms | 2.496 ms | 2.795 ms |
| Animation Job + Burst | 2.494 ms | 2.496 ms | 2.792 ms |
CPU時間の内訳(64回のとき):
表1.10: IK 実装ごとの CPU 時間の内訳(解法 64 回)
| 実装 | IKのCPU時間(全スレッド) | うちメインスレッド | フレーム時間への上乗せ |
|---|---|---|---|
| LateUpdate | 6.910 ms | 6.910 ms(100 %) | +6.498 ms |
| Animation Job | 7.790 ms | 0.487 ms(6.3 %) | +0.199 ms |
| Animation Job + Burst | 6.932 ms | 0.436 ms(6.3 %) | +0.196 ms |
同じ演算がどのスレッドで実行されているかは、Unity EditorのProfilerに現れます。以下は解法64回・300体のキャプチャをProfilerウィンドウで開き、フレーム時間の中央値付近のフレームを選択したものです。Profiler接続時のキャプチャなので、上の表の数値とは完全には一致しません。
図1.7: LateUpdate実装。フレーム時間8.698 msのうちLateBehaviourUpdateが6.63 msを占める。ワーカースレッド15本はフレームの大半(約6.8〜7.7 ms)がIdleのまま。
図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に収まります。
このケースの最適化について、最も効果的なことは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なら、どの実装を選んでも体感差はありません。
[BurstCompile]を付ければSIMDになって数倍速くなる、という理解は誤りです。今回の実測では、Burstによる総CPU時間の削減は11.0 %です。ノード固定コストを差し引いた「解法1回あたりの限界コスト」で見ると、
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)。
Burstの恩恵をフル活用したいなら、C# Job Systemを使ってAnimationStreamを介さずにTransformを読み書きする構成が候補になります。Transformの読み書きはTransformAccessArrayとIJobParallelForTransformでジョブ側にまとめられますし、ボーンの収集と書き戻しだけをAnimation Jobに任せ、重い計算はN体分をまとめたバッチジョブに切り出す形も取れます。どちらもキャラクター単位ではなくN体分のデータがひとつのジョブの中に揃うため、SIMD化の前提が成立します。
そのうえで、バッチジョブの中身は次のように整える必要があります。
math.selectやmath.maxに置き換え、ループ本体を1本の直線にするfloat3ではなくfloat4で「4体分を1レーンずつ」書き、自動ベクトル化の判断に依存しない代償は設計の複雑さです。IAnimationJobは、グラフに1ノード挿すだけで評価パイプラインの正しいタイミングでボーンの最新状態を触れる、という手軽さが最大の利点で、この作戦はそれを捨てることになります。本記事ではこの構成の実測を行っていないため、どれだけ速くなるかはここでは示しません。Burstに数倍の効果を期待するならこの方向の設計変更が前提になる、という提案として扱ってください。
AnimationScriptPlayableのノード固定コストのほうが大きい。素直にLateUpdateで書いたほうが総CPU時間は少ない[BurstCompile]は付けるだけではSIMDにならない。SIMDが適用されることを見越したデータ・処理の設計を行う必要があるゲーム仕様にもよりますが、基本的には遠くのキャラクターまで毎フレーム更新する必要はありません。Nフレームに1回だけ更新すれば、そのキャラクターの平均負荷は1/Nになります。これが「アニメーションLOD」です。メッシュのLODが描画コストを距離で段階制御するのと同じ発想を、アニメーションの更新頻度に適用します。
効果的なのは次のような場面です。
逆に、近距離でカメラに大写しになるキャラクターに適用するとカクつきがすぐ分かります。距離やスクリーン占有率に応じて間引き段階を切り替えるのが想定される実運用でしょう。
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++; } |
手動更新(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体ずつが更新される形になります。
600体、Renderer無効。まずAnimatorでの実装です。
平均値は12秒間の総負荷を、中央値とp95の開きはフレーム時間の暴れ具合を表します。
表1.11: 間引きと位相ずらしのフレーム時間統計(Animator・600 体)
| 方式 | 平均 | 中央値 | p95 | p99 | 最大 | 標準偏差 |
|---|---|---|---|---|---|---|
| 毎フレーム更新(Animator自動) | 5.037 ms | 5.000 ms | 5.303 ms | 5.707 ms | 16.901 ms | 0.433 |
| 2フレームに1回・位相なし | 4.781 ms | 0.648 ms | 9.607 ms | 9.816 ms | 12.194 ms | 4.544 |
| 2フレームに1回・位相ずらし | 4.831 ms | 4.803 ms | 5.099 ms | 5.212 ms | 8.804 ms | 0.171 |
| 4フレームに1回・位相なし | 2.613 ms | 0.382 ms | 9.470 ms | 9.658 ms | 11.171 ms | 3.863 |
| 4フレームに1回・位相ずらし | 2.578 ms | 2.593 ms | 2.707 ms | 2.908 ms | 11.722 ms | 0.185 |
Playable API(1体1グラフ・手動更新)でも同じ傾向です。
表1.12: 間引きと位相ずらしのフレーム時間統計(Playable API・1 体 1 グラフ)
| 方式 | 平均 | 中央値 | p95 | 標準偏差 |
|---|---|---|---|---|
| 毎フレーム更新 | 8.496 ms | 8.493 ms | 9.001 ms | 0.337 |
| 4フレームに1回・位相なし | 2.438 ms | 0.369 ms | 8.855 ms | 3.570 |
| 4フレームに1回・位相ずらし | 2.486 ms | 2.495 ms | 2.613 ms | 0.173 |
4分の1に間引いたときの平均値は位相の有無でほとんど変わらず、総負荷はほぼ同じです。位相ずらしは仕事を減らしているわけではありません。
一方で、フレーム時間の分布には次の違いが出ています。
中央値0.382 msという数字だけを見て「1/13になった」と喜ぶと、実際には9.5 msのスパイクが4フレームに1回刺さるゲームができあがります。位相ずらしは体感品質のための施策であり、標準偏差0.185は毎フレーム更新の0.433よりも安定しています。
この分布の違いはProfilerのCPUチャートにそのまま現れます(いずれもProfiler接続時のキャプチャなので、上表の非接続の数値より重めに出ています。またチャートの縦軸スケールは自動調整のため2枚で異なります)。
図1.9: 4フレームに1回・位相なし。CPUチャートが「ほぼ0 msの谷3フレーム + 約10 msのスパイク1フレーム」の櫛状になる。下のTimelineでは選択中の空フレーム(0.47 ms)の両隣に、600体分をまとめて処理するATB.ManualAnimatorUpdate(10.37 ms)のフレームが写っている。
図1.10: 4フレームに1回・位相ずらし。毎フレーム150体ずつ処理するため波形は平坦(約3.5 ms)で、スパイクが消えている。
2フレームに1回の間引きは、ほとんど何も削っていません。更新回数を半分にしたのに、平均フレーム時間の削減はわずか4.1 %です。
この理由は、Profilerマーカーの内訳から分かります。
表1.13: 素朴な間引き実装で失われる並列性
| 方式 | 1フレームに更新する体数 | メインスレッドのアニメーション更新時間 | Animator.ProcessGraph全スレッド合計 | うちメインスレッド |
|---|---|---|---|---|
| 毎フレーム更新(Unityのバッチ更新) | 600 | Animators.Update 4.046 ms | 20.104 ms | 1.257 ms |
| 2フレームに1回・位相ずらし | 300 | ATB.ManualAnimatorUpdate 4.532 ms | 0.549 ms | 0.317 ms |
| 4フレームに1回・位相ずらし | 150 | ATB.ManualAnimatorUpdate 2.316 ms | 0.278 ms | 0.180 ms |
毎フレーム更新では、仕事のうちメインスレッドに残るのは6 %だけで、残りはワーカースレッドに散っています。一方Animator.Update()を明示的に呼ぶとその場で同期的に評価されるため、ワーカースレッドがほとんど使われません。Unityのバッチ更新が持っていた並列化を、間引きと引き換えに手放しています。
1体あたりのメインスレッドコストに直すと、
直列化した瞬間に1体あたり約2.3倍になり、その後は間引き倍率によらず一定です。つまり2倍の間引きで得られる2倍の削減が、直列化による2.3倍の悪化でほぼ相殺されます。実測の4.1 %はその残差にすぎません。
素朴な実装のままでは、4倍間引き(−48.8 %)でようやく実質的な効果が出ます。ただし、これは避けられる損失です。
Animator.Update()が同期的なのは避けられません。しかし「Unityのバッチ更新に参加させる体を毎フレーム選ぶ」という形にすれば、間引きながら並列性を保てます。2通りの実装があり、どちらもAnimator(とそのステートマシン)を使ったままです。
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だけ進みます。フレーム時間が揺れる環境では進み方も揺れますが、平均の再生速度は保たれます。
前章の結論は「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段階を動的に変えるにはキャラクターをグラフ間で移す(出力を作り直す)必要があります。
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 ms | 1.257 ms | Animators.Update 4.046 ms |
Animator.Update()・1/4(150体/フレーム) | 0.278 ms | 0.180 ms | ATB.ManualAnimatorUpdate 2.316 ms |
Animator.enabled・1/4(150体/フレーム) | 4.424 ms | 0.288 ms | Animators.Update 1.104 ms + 切り替え0.125 ms |
| 位相ごとの手動グラフ・1/4(150体/フレーム) | 4.253 ms | 0.279 ms | ATB.BucketEvaluate 1.278 ms |
新しい2実装では、Animator.ProcessGraphの仕事量が毎フレーム更新のちょうど1/4になり(1フレームに150体しか評価しないので当然です)、その仕事は依然としてワーカースレッドに散っています。一方Animator.Update()版はこのマーカーにほとんど計上されず、仕事の本体はメインスレッドのATB.ManualAnimatorUpdateの中にあります。
1体あたりのメインスレッドコストで並べると差がはっきりします。
Animator.enabled切り替え・1/4: 1.229 ms / 150体 = 8.2 µ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()を自分で呼ぶ実装を選ぶ理由はありません。
Animator.Update()を自分で呼ぶ実装は選ばない。その場で同期評価されるためジョブ並列化を失い、1体あたりのメインスレッドコストが6.7 → 15.4 µsと約2.3倍になるAnimator.enabledの切り替え(keepAnimatorStateOnDisable = trueとAnimator.speedでの補正が必要)か、位相ごとに手動グラフを分けてAnimatorControllerPlayableを載せる形。1体あたり8.2〜8.5 µsに収まり、並列化も残る各章の実測結果です。
表1.16: 各章の実測結果まとめ
| 章 | 施策 | 条件 | 結果 |
|---|---|---|---|
| アセットの設定 | Optimize Game Objects | 600体・Renderer 1個 | 6.797 → 5.991 ms(−11.9 %) |
| アセットの設定 | Optimize Hierarchy(ランタイム) | 600体・Renderer 1個 | 6.797 → 6.009 ms(−11.6 %) |
| アセットの設定 | 上記2手法 | 300体・全Renderer | 18.205 → 18.3〜18.5 ms(改善なし・描画律速) |
| Job Thread | 手動更新グラフを1本に集約 | 600体 | 8.396 → 4.500 ms(−46.4 %) |
| Animation C# Jobs | Two-Bone IKをAnimation Job化 | 300体・64回解法 | 9.094 → 2.795 ms(−69.3 %) |
| Animation C# Jobs | さらにBurstでコンパイル | 300体・64回解法 | 2.795 → 2.792 ms(フレーム時間は差なし) |
| アニメーションLOD | 1/4間引き・Animator.Update()を明示呼び出し | 600体 | 平均5.037 → 2.578 ms(−48.8 %) |
| アニメーションLOD | 1/4間引き・位相ごとの手動グラフ | 600体 | 平均5.037 → 1.541 ms(−69.4 %) |
| アニメーションLOD | 1/2間引き・Animator.Update()を明示呼び出し | 600体 | 平均5.037 → 4.831 ms(−4.1 %) |
| アニメーションLOD | 1/2間引き・位相ごとの手動グラフ | 600体 | 平均5.037 → 3.082 ms(−38.8 %) |
この記事の実測を踏まえた、大量キャラクターのアニメーション最適化の進め方です。
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などのアプリケーション的な工夫を行う
アセット・エンジン設定・コード最適化を尽くしたら、最後は「そもそもアニメーションを処理しない」という工夫です。
プロジェクトの要件と相談になりますが、ゲーム体験的に許容できるラインを探りながら処理を抑える仕組みを検討しましょう。
DirectorUpdateModeAnimationScriptPlayableに差し込むジョブAnimationStreamを介さずに並列処理を書くための仕組み