コンテンツへスキップ

【Unity】LitMotion – データ指向設計による最速のトゥイーン実装

  • Unity

Magic Tweenに続く新しいトゥイーンライブラリ「LitMotion」を公開しました!今回もすでにGithubに上がってます。

パフォーマンスに関しては後述しますが、トゥイーンの作成/更新の双方においてUnity向けのトゥイーンライブラリの中で最も高速に動作します。フレームあたりの処理速度もDOTweenの5倍以上、MagicTweenと比較しても1.5倍ほどと極めて高速です。

「何でMagic Tweenがあるのにまた新しいトゥイーン作ってんの?」というと、正直現状のMagic Tweenの完成度が満足のいくものではなく、その原因が根本的な設計部分にあったからです。というのもMagic TweenはUnity ECSという基盤の上に乗っているトゥイーンライブラリなんですが、Unity ECSの構造上、それに乗っかっていてはどうしても最速の実装にはできないということに気付いてしまったんですね。

詳細の説明は省きますが、Unity ECSはManaged Componentを使うとガクッとパフォーマンスが落ちます。これはもうUnity ECSが汎用的なECSフレームワークである以上どうしようもない部分なんですが、それならそんな基盤を捨て去って特化した実装を突っ込んだ方が速いに決まってます。というかMagic Tweenもゆくゆくはそうしたいなーと思っているんですが、あまりにも機能を盛り込みすぎたせいで独自実装に切り替えるのは相当大変な状態になってしまっていました。

それなら最初から最速で動作する設計を考えて、それに特化したトゥイーンライブラリを作ってしまえばいいのでは?というコンセプトから開発したのが今回の「LitMotion」です。もちろんパフォーマンスだけでなく、機能面やAPI設計の面でもMagic Tweenの実装で得た経験をもとに実用的かつ扱いやすいライブラリに仕上げています。

また、Magic Tweenは現在v0.2という実験的な段階ですが、LitMotionは既にv1.0としてリリースされており実用に足る完成度になっています。そのためMagic Tweenほどの機能が不要で、かつ安定したトゥイーンライブラリが欲しい場合にはこちらを使用することを推奨します。

そして何と今回、Github Pages上にDocFXで構築されたドキュメントがあります。APIリファレンスも完備しているので、使い方についてはそちらを参照してください。

パフォーマンス

とりあえず余計な話は置いておいて、まずはパフォーマンスから見ていきましょう。ベンチマークのコードはTweenPerformanceという専用のリポジトリに置いてあります。

なんか前より比較対象のライブラリが増えてますが、まあ文句なく最速です。Magic Tweenのときにアニメーションの開始(Startup)がPrimeTweenに負けてたのがアレだったので、LitMotionではそちらも入念に最適化してしっかり追い抜きました。これで今度こそ真の最速を名乗れます。本当に限界までチューニングされてるので、これ以上の性能を叩き出すのは不可能でしょう、多分。

当然アニメーション開始時のGC Allocも一切ありません。完全にゼロアロケーションです。

データ指向設計

トゥイーンライブラリと一口には言ってもその設計は千差万別です。具体的に例を挙げながら、最終的にLitMotionが採用したデザインに至るまでを見ていきましょう。

Unity向けのトゥイーンとしてまず考えられるのは、トゥイーンごとにコンポーネントを挿す手法です。これはiTweenやUweenが採用しています。

// こんな感じでコンポーネントを作ってトゥイーンの対象に追加する
public class TweenBehaviour : MonoBehaviour
{
   ...
}

そしてベンチマークからもわかりますが、この方法はパフォーマンス的には最低の部類です。Unity Blogの有名な「Updateを10,000回呼ぶ」という記事がある通り、MonoBehaviourのUpdateはC++側からC#の関数を呼び出すコストがかかります。そのためMonoBehaviourは大量に呼ばれる箇所の実装に向いていません。(弾幕STGを作るときとかもUpdateで更新すると死ぬほど遅くなりますよね…?)

じゃあどうするか、というと上の記事でも言われている通りC#側でUpdate呼び出しを一元管理します。すなわち以下のような形です。

// MonoBehaviourではなく、純粋なC#のクラスとしてトゥイーンを定義
class Tween
{
   void Update() { ... } 
}

// Updateを呼び出すためのMonoBehaviourを作って...
public class TweenDispatcher : MonoBehaviour
{
   // 対象を配列に保存
   Tween[] tweens;

   // 1つのUpdate関数でまとめてUpdateする
   void Update()
   {
      foreach (var tween in tweens)
      {
          tween.Update();
      }
   }
}

ベンチマークにある大半のトゥイーンライブラリはこのような手法で実装されています。(LitMotionも稼働にはシングルトンなMonoBehaviourを使用しています)
できる限りC#内で完結させたほうが速くなる、というわけですね。

しかしまだ問題はあります。Tweenがclassである以上、newした際のGC Allocationから逃れることはできません。ましてトゥイーンのデータは様々なパラメータを保持する必要があるので、アロケーションは大きくなりがちです。

// GC.Alloc!
var tween = new Tween();

これを避けるための方法としてまず考えられるのは、内部のインスタンスをオブジェクトプールを用いて使い回すことです。この手法はPrimeTweenやFastTweenerなどで採用されており、実際PrimeTweenは全てのインスタンスを徹底的にプールすることでゼロアロケーションを実現しています。

トゥイーンではありませんが、仕組みとしては.NETのValueTaskとIValueTaskSourceに近いかもしれません。

// 実際のデータはクラスに保存し、オブジェクトプールで再利用する
class TweenSource
{
   いろんなデータ
   Ease ease;
   float t;
   ...

   void Update() { ... } 
}

// 外部で扱うのは内部のインスタンスをstructでラップしたもの
// こちらをnewする分にはアロケーションは発生しない
struct Tween
{
   TweenSource source;
}

この方法はなかなか悪くないように思われます。しかし、最速を目指したいならまだ不十分です。

C#において最速の列挙はunmanagedな構造体の配列をLengthとforループで回すことです。C#のunmanaged型の配列はメモリが直線的に並ぶため、キャッシュヒット率が上がり列挙が高速化します。また、forループをarray.Lengthで回すことで境界チェックを外すことが可能であるため、さらにパフォーマンスが向上します。

要するに何が言いたいかというと、上の実装はデータがクラスに詰め込まれているということそのものがボトルネックだということです。つまり「高いパフォーマンスを目指すなら効率的にメモリの読み書きを行えるようにデータを配置しよう」となるわけで、これはデータ指向設計の基本的な考え方と一致します。

というわけでLitMotionにおけるトゥイーン(LitMotionではモーションと呼びますが)を表す型の定義はこうなります。

// unmanaged構造体
[StructLayout(LayoutKind.Sequential)]
public struct MotionData<TValue, TOptions>
    where TValue : unmanaged
    where TOptions : unmanaged, IMotionOptions
{
    ...
}

オブジェクト指向を捨て去ったunmanagedな構造体です。この辺はもう完全にデータ指向ですね。

ただし、トゥイーンには計算した値を反映するためのデリゲートも必要です。こちらをunmanagedで扱うのは厳しいため、別の構造体にコールバックの情報を詰め込んで保存しておきます。

// こちらは内部に参照を含むmanagedな構造体 
public struct MotionCallbackData { ... }

これらを配列に保存して列挙することでトゥイーンアニメーションを実現しています。

Job SystemとBurstの活用

データ指向の設計を採用する理由はもう一つあって、UnityのDOTS(Data-Oriented Technology Stack)の最適化の恩恵を最大限に受けることができるためです。

DOTSはその名の通りデータ指向に基づいた技術で、Unityの新しい基盤となるべく開発が進められています。DOTSはC# Job System、Burst Compiler、Entity Component System (ECS)の3つの要素で構成され、これらの組み合わせで圧倒的なパフォーマンスを発揮します。

LitMotionはこのうちのJob SystemとBurstを活用してデータの列挙と更新を爆速化します。

// モーションのデータの更新を行うJob(IJobParallelFor)を定義
[BurstCompile] // Burstで高速化
public unsafe struct MotionUpdateJob<TValue, TOptions, TAdapter> : IJobParallelFor
    where TValue : unmanaged
    where TOptions : unmanaged, IMotionOptions
    where TAdapter : unmanaged, IMotionAdapter<TValue, TOptions>
{
    // 安全性チェックなど不要である(どうせ全部手動で管理する)ので、NativeArrayでラップせずにポインタを直接放り込む
    [NativeDisableUnsafePtrRestriction] public MotionData<TValue, TOptions>* DataPtr;
    ...

    // Execute内で更新を行う
    public void Execute(int index)
    {
        ...
    }
}

データの更新プロセスは独立しているため、Job Systemを用いて並列に処理を行います。また[BurstCompile]属性を付加することでBurstがSIMDを活用した強力な最適化を適用し、通常のC#では不可能なレベルの高いパフォーマンスを実現します。

ただしMotionCallbackDataの列挙(デリゲートの呼び出し)はメインスレッドで行う必要があります。こればかりはどうしようもないですが、ここもforループ+Lengthで可能な限り高速に列挙しています。

var span = storage.callbacksArray.AsSpan();
for (int i = 0; i < span.Length; i++)
{
   ...
}

SparseSetによるデータ管理

と、ここまではデータ指向のパフォーマンス的な(良い)側面だけを見てきましたが、実際はそう簡単には行きません。ハードウェアに優しい設計は基本的に人間には優しくないわけで、クラスや参照などの便利な機能に頼れない以上、データを適切に扱う仕組みを自前で用意する必要が生じてきます。

これだけでは分かりづらいので例を挙げましょう。例えばLitMotionのモーションはMotionHandleを介して完了やキャンセルの処理を行うことができます。

var handle = LMotion.Create(0f, 10f, 2f).RunWithoutBinding();
handle.Complete();

MotionHandleの中身は実際には配列のIndexです。(他にも型のIDやVersionなどの情報もありますが)
Indexを返すことで配列のどの位置を指しているかを監視し、特定のデータにランダムアクセスすることを可能にしています。

で、これを愚直に実装すると以下のような構造になります。

あとはデータの削除などのタイミングで世代をインクリメントする処理を加えれば、古いMotionHandleは自動で無効になるため、一応動作はします。

この実装の問題は追加/削除の際に空洞が生じることです。ハンドルは特定のインデックスを指しているため、内部のデータの位置を移動することは許されません。そのためデータの削除を繰り返すと内部配列がスカスカになっていくことになります。余計な隙間があるとその分列挙の効率が落ちるため、この構造はあまり良くありません。

これを解決するために「SparseSet」と呼ばれるデータ構造を導入します。これはEnTTなどのECSで採用されているデータ構造で、インデックスで直接アクセスする代わりに間接層を追加し、それを介してアクセスさせます。

先ほどとは異なりHandleが指すインデックスは上の層を指しており、その中に実際のデータを持つ配列を指すインデックスが入っています。

このように間接層を加えることで、データが削除された際に要素を入れ替えて隙間を詰めることが可能になります。例えば上のインデックス[1]のデータが削除された場合には、[3]の要素を[1]につめて間接層のインデックスを更新すればいい、ということになります。

実際にデータを列挙する際は下の配列だけを見ればいいので、上がスカスカであることは列挙の性能には全く影響しません。疎なインデックスの間接層を挟むことでデータ配列を密に保つことができる、というのがSparseSetの特長であり、その特性からECSと相性が良く幾つかのECSフレームワークで採用されています。(Unity ECSはArchetypeベースでありこの実装とは異なります)

また、LitMotionの実装では間接層の空きスロットは連結リストを用いて管理されています。

空きスロットは次の空きスロットのインデックスを持ちます。これをルートから順に辿っていくだけで空いている場所を探すことができるため、毎回確保のために探索するコストを完全に回避できます。

削除時にはルートの位置を差し替え、新たな空きスロットが前のルートを指すようにすればOKです。

この辺りのアイデアはこちらの「Entity-Component-System 実装!」というZennの書籍と、EnTTの実装者による記事のシリーズ「ECS back and forth」を元に実装されています。データ指向の設計は何もECSに限った話ではなく、これらのパラダイムをライブラリの最適化に生かすことも可能である、ということですね。

API設計

ここまではパフォーマンスとそれを実現するための設計についての話でしたが、ついでにLitMotionのAPI設計についても触れておきましょう。

Magic Tweenの記事でも触れましたが、トゥイーンライブラリのAPIはコンポーネントに対する拡張メソッドがあるものとそうでないものがあります。DOTweenやMagic Tweenは前者、LitMotionは後者です。

// MagicTween
transform.TweenPosition(Vector3.zero, Vector3.one, 2f);

// LitMotion
LMotion.Create(Vector3.zero, Vector3.one, 2f).BindToPosition(transform);

また、設定の追加などは全てメソッドチェーンで行います。この辺はDOTweenやMagic Tweenと変わりませんね。

LMotion.Create(Vector3.zero, Vector3.one, 2f)
    .WithEase(Ease.OutQuad)
    .WithScheduler(MotionScheduler.LateUpdate)
    .BindToPosition(transform);

この形式を採用した理由は可読性です。拡張メソッドも楽だし便利でいいし、実際Magic Tweenも拡張メソッドを採用していますが、オリジナルのメソッドと区別がつかず混乱を招く面もあります。また他のトゥイーンライブラリを併用するというケースもあり得なくはないわけで、それらが全部拡張メソッドを生やしていく形式だと、TweenFooみたいなメソッドが多すぎて訳わからん、という事態に陥ることも十分考えられます。

そのためLitMotionではLMotionクラスという特徴的なエントリーポイントに限定することで、どこでLitMotionが利用されているかが一目でわかるような設計にしました。メソッドチェーンがCreate-With-Bindという綺麗な流れになるので、IntelliSenseフレンドリーで書きやすく読みやすい、いい感じのAPIになっているのではないでしょうか。

Rx, async/await vs Sequence

LitMotionは他の多くのトゥイーンライブラリとは異なりSequenceの機能を持ちません。その代わりとして、UniRx/UniTaskと連携して非同期的なアニメーションを行う機能が提供されています。

// モーションをObservableとして作成する
var observable = LMotion.Create(-5f, 5f, 2f)
    .ToObservable();

// モーションをawaitで待機する
await LMotion.Create(-5f, 5f, 2f).BindToUnityLogger();

DOTweenの便利機能としてSeqneuceが紹介されることが多々ありますが、個人的にはもはや不要な機能であるとさえ考えています。DOTweenが出た当時はアニメーションを組み合わせるための貴重な手段だったかもしれませんが、現代のUnityには非同期処理を扱うためのUniRx/UniTaskという強力な道具が揃っているため、わざわざSequenceを使う理由はないと言っていいでしょう。

また、DOTweenのSequenceには「無限ループするTweenを追加できない」「一部の設定が無視される」など、コードだけでは把握しきれない複雑な仕様があります。コードのシンプルさを損なうという面でもSequenceは使わない方がいいんじゃないかな、と。

文句だけ言っててもアレなので、実際にコードで比べてみましょう。例えばDOTweenとSequenceで以下のようなアニメーションを作成した場合。

// 2回繰り返すSequenceを作成
var sequence1 = DOTween.Sequence();
sequence1.Append(target.DOMoveY(4f, 2f))
    .Append(target.DOMoveX(2f, 2f))
    .AppendCallback(() => Debug.Log("Callback1"))
    .SetLoops(2);

// 先ほど作成したSequenceをネストしたものを作成
var sequence2 = DOTween.Sequence();
sequence2.Append(sequence1)
    .AppendInterval(1f)
    .Append(target.DOMove(Vector3.zero, 2f))
    .AppendCallback(() => Debug.Log("Callback2"));

これはUniTaskとasync/awaitを使って以下のように書き直せます。

for (int i = 0; i < 2; i++)
{
    await LMotion.Create(0f, 4f, 2f).BindToPositionY(target);
    await LMotion.Create(0f, 2f, 2f).BindToPositionX(target);

    Debug.Log("Callback1");
}

await UniTask.WaitForSeconds(1f);
await LMotion.Create(target.position, Vector3.zero, 2f).BindToPosition(target);

Debug.Log("Callback2");

async/awaitを使えば手続き的に非同期処理を記述できます。こちらの方がはるかに可読性も高く直感的です。

またパフォーマンスは多少落ちますが、より表現力の高いRxを併用することでSequenceの機能はいくらでも代替できます。

var x = LMotion.Create(-5f, 5f, 2f).ToObservable();
var y = LMotion.Create(0f, 3f, 2f).ToObservable();
var z = LMotion.Create(-1f, 1f, 2f).ToObservable();

Observable.CombineLatest(x, y, z, (x, y, z) => new Vector3(x, y, z))
	.Subscribe(x => transform.position = x);

ライブラリの実装側としてもSequenceは複雑すぎる仕様故にバグの温床だし(MagicTweenのSequenceも相当テストしたはずなのに全然バグるし…)、どうせ使う頻度も少ないならいっそなくしてしまえばいいじゃない、ということでLitMotionではサポートを切り捨てました。async/awaitとRxが扱える方であれば、不自由は特にないはずです。またコルーチンのサポートもあるので、なんらかの事情でUniTaskが使えない場合などにはそちらを使うと良いでしょう。

まとめ

だいぶ長い解説になりましたが今回の記事は以上です。色々な最適化技法が入っていましたが、一番の飛躍はデータ指向を中心に据えて設計したことでしょう。時代はやはりDOTS….

出来に関しても、Magic Tweenに続いて短期間で2つ目を作ったというのもあってかなりいい感じです。何よりこっちはv1.0リリースなので自信を持って勧められます。またEntitiesみたいな巨大なパッケージに依存してないので、最低限のトゥイーンの機能だけ欲しい!みたいなシチュエーションでは確実にLitMotionの方が向いてます。ただ文字列のトゥイーンとかはないので、その辺が欲しければ適宜拡張書くなりMagic Tweenと併用するなりしてください。

※追記
v1.2.0より文字列のトゥイーンが追加されました!内部的にはFixedStringのトゥイーンなので事前に長さに応じてメソッドを選択する必要がありますが、TMP_Textと組み合わせることで完全にゼロアロケーションでテキストをアニメーションできます!

というわけで今回のLitMotion、まずは是非是非使ってみてください!PRやissueもお待ちしてます…!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です