コンテンツへスキップ

MagicTween v0.2の新機能/DOTSを活用した最適化手法について

  • Unity

Magic Tweenのv0.2をリリースしました!ちょっと機能追加するだけのつもりが、内部を取っ替えるレベルまで作り直してしまった…すぐ終わると思ってたのに…

今回はv0.2ということで、v0.1から破壊的変更も結構あります。とはいえDOTweenとかの代替でGameObject向けに使っている分にはほとんど影響はないんじゃないかと思います。ECSの方はAPIが結構変わってるので、その辺はmigration.mdを参考にしていただけると。

v0.2からの機能もいくつかありますが、最大の改善点はパフォーマンスです。後ほど細かく説明しますが、様々な最適化技法を投入したので既に最速クラスだったv0.1と比べても更に高速化しています。ただ、大部分がかなりトリッキーな、というかハック的な最適化なので参考になるかと言われたらアレですが…

v0.1.0当初はかなり雑だった設計が概ね改善されたので、細かい部分のカスタマイズが可能になったというのも大きい進歩です。設計面が良くなったおかげで部分的な最適化を差し込む余地が生まれたので、そういった意味でも設計は大事ですね…本当に…

GC-freeの実現

まずはアロケーション周りの改善から。v0.1の際にはトゥイーンの作成に僅かな割り当てがありましたが、v0.2よりこれがゼロになりました。GC Allocは一切発生せず、完全にGCフリーです。(ただし、stringなど一部のトゥイーンを除きます)

そもそも何故アロケーションが発生していたのかというと、Entityにつけるための幾つかのComponentがclassであったからなんですね。たとえばこういうやつ。

// 作成時に渡されたdelegateを保持するComponent
class TweenDelegates<T> : IComponentData
{
    public TweenGetter<T> getter;
    public TweenSetter<T> setter;
    ...
}

Unity ECSの制約上、参照型のフィールドを保持するにはComponentをclassとして定義する必要があります。(そもそもclassを使うこと自体があまり良くないので、実際にECSでコードを書く時は可能な限りunmanagedなstructを使用します。)

これを作成毎にnew()していたので、そこでアロケーションが発生していたんですね。

v0.2からはこれらのインスタンスを徹底的にプールして使い回すようにしています。そのため作成時における新規割り当ては発生しません。

ただし、実際にこれを実装しようとすると「どこでインスタンスをプールに返すのか」という問題が発生します。ECSにおいてコールバックの概念は存在しないため、まともに実装しようとすると追加でSystemの処理を挿入する必要があります。これは非常に面倒な上、マネージドなComponentの列挙はBurstの最適化が効かないためパフォーマンス面においても致命的です。

幸いにも、Unity ECSではIDisposableを利用してComponentが削除された時の処理を追加できるようになっています。

基本的にはリソースの解放に使用するための機能で、ドキュメントの例ではComponentの抱えるGameObjectをDestroyする処理を記述するために使用されています。(そもそもIDisposable自体がリソース解放のためのインターフェースですから当然ですが)

が、今回は「Componentが削除された時に処理を挟める」という性質を利用します。というわけで、Dispose関数内にプールに自身を返す処理を追加しましょう。

// 作成時に渡されたdelegateを保持するComponent
class TweenDelegates<T> : IComponentData, IDisposable
{
    public TweenGetter<T> getter;
    public TweenSetter<T> setter;

    public void Dispose()
    {
        // Dispose時にPoolにインスタンスを返却
        TweenDelegatesPool<T>.Return(this);
    }
}

内部の実装は概ねこのような感じです。こうしておくことでEntityの削除時に自動でプールに返ってくれるので、アロケーションなしでComponentを再利用できます。

Transformに特化した最適化

続いてはTransform周りの最適化について。

v0.1の性能面の不満として、Transformのトゥイーンがそんなに速くなかったという点があったんですね。一応ベンチマークではギリギリ最速を保っていたものの、50,000個ぐらいになるとほとんど誤差レベルという状況で、最速とは言い難い結果でした。

じゃあTransformのトゥイーンの何がそんなに遅いのか。というと、原因はこれです。

Vector3 currentValue;
transform.position = currentValue;

「…あれ?positionに代入してるだけじゃん?」

そう、positionに代入してるだけです。代入しているだけなんですが、これが遅いんですね。

transform.positionは単にフィールドに代入するわけではなく、プロパティを通じて内部のC++側のオブジェクトの値を更新する処理になっています。どうしてもC#/C++間の境界越えのコストはあるわけで、多少遅くなってしまうのは仕方ない。(とはいえ、普通に使う分には全く気にならない程度ですが)

他にも遅い理由はなくもないんですが、値の計算自体は相当頑張って最適化してる上、positionの反映に比べるとそこまでの負荷というわけでもありません。上位のトゥイーンライブラリの性能が拮抗している理由は多分これで、Transformを使う上ではこの辺りが限界ということでしょう。

しかし、諦めるにはまだ早い。最新のUnityのAPIにはこれを解決する手段が用意されています。そう、IJobParallelForTransformです。

IJobParallelForTransformを使用することで複数のTransformの読み書きをJobで行うことができます。これをトゥイーンのプロセスに組み込むことによって劇的なパフォーマンスの向上を果たしました。

グラフの通りおよそ1.7倍ほどの性能改善です。Transformの階層構造によって速度はある程度上下しますが、並列処理による最適化なので数が多ければ多いほどパワーを発揮します。

ただし、この最適化はデフォルトでは無効化されています。これはコード量がガッツリ増えるのでビルドサイズに響くのではという懸念があったのと、トゥイーンの作成コストが若干上がるからです。(これはTweenをキャッシュすることで最適化できます)
あとまあ、こんな速度が必要になること自体そうそうないし。

これを有効化するのは簡単で、Scripting Define SymbolsにMAGICTWEEN_ENABLE_TRANSFORM_JOBSを追加してやればOKです。あとは普通にtransform.TweenPosition()などを呼んであげるだけでJobによる爆速化が適用されます。

Burst Compilerのサポート

もう一つパフォーマンス上の改善点として、Tween.Entity系のメソッドがBurstに対応しました。これによりECSでMagic Tweenを使用する際に、Tweenの作成にかかる時間を削減できます。

こちらが今までBurstに非対応だった理由は、EntityArchetypeのキャッシュをTypeをキーにしたDictionaryで行っていたからです。TypeもDictionaryもclassなので、HPC#においては使用不可です。Dictionaryはいいとしても、typeof(T)すら許されないっていうのがなかなか厳しい…

じゃあどうすりゃええんか、というとStatic Type Cachingを使います。これはC#の最適化における有用な手法の一つで、staticなGenericクラスを一つ作ってその中でキャッシュを行います。

static class Cache<T>
{
    public static SomeClass<T> cache;
}

要するにこういうやつ。C#においてTypeをキーにした辞書が欲しい場合にはこれが圧倒的に最速で、.NETのライブラリでも時々見かける実装です。

さらにこの手法はBurstと互換性があります。中身をSharedStatic<T>にしておくことで、TypeをキーにしたBurst互換な辞書が完成します。

static class Cache<T>
{
    public static SharedStatic<int> shared = SharedStatic<int>.GetOrCreate<T>();
}

これをうまく利用することで、Tweenの作成をBurstに対応させることに成功しました。v0.1と比較するとだいぶ高速化されたので良い感じです。

TweenPluginによる拡張の追加

ここまではパフォーマンス面の話でしたが、v0.2から追加された機能もいくつかあります。最も大きな機能追加は、TweenPluginによって独自の型の拡張を差し込めるようになったことです。

v0.1でTween可能なのはMagic Tweenがサポートしている型のみでしたが、これの追加によっていくらでも拡張が効くようになりました。例えばMagicTween.SamplesプロジェクトにはRect型をトゥイーンさせるためのTweenPluginの実装が含まれています。

using Unity.Mathematics;
using UnityEngine;
using MagicTween;

// TweenPlugin属性を追加。これによってSourceGeneratorが必要なコードを生成する
[TweenPlugin]
// ICustomTweenPluginを実装した構造体を定義
// 型引数にはトゥイーンさせる値の型と、追加のオプション(必要がなければNoOptions)を指定
public readonly struct RectTweenPlugin : ICustomTweenPlugin<Rect, NoOptions>
{
    // 値の計算処理をEvaluate内に記述する
    public Rect Evaluate(in Rect startValue, in Rect endValue, in NoOptions options, in TweenEvaluationContext context)
    {
        // SetRelativeが設定されている場合には終了値を開始値からの相対値に設定
        var resolvedEndValue = context.IsRelative ? 
            new Rect(startValue.x + endValue.x, startValue.y + endValue.y, startValue.width + endValue.width, startValue.height + endValue.height) :
            endValue;

        // SetInvertが設定されている場合には開始値と終了値を入れ替え
        var rectA = context.IsInverted ? resolvedEndValue : startValue;
        var rectB = context.IsInverted ? startValue : resolvedEndValue;

        // トゥイーンの進行状況(0〜1)を取得
        var t = context.Progress;

        // 線形補間で値を計算
        var x = math.lerp(rectA.x, rectB.x, t);
        var y = math.lerp(rectA.y, rectB.y, t);
        var width = math.lerp(rectA.width, rectB.width, t);
        var height = math.lerp(rectA.height, rectB.height, t);

        // 計算結果を返す
        return new Rect(x, y, width, height);
    }
}

こんな感じでEvaluateだけ実装した構造体を作っておけば、細かいコードの部分はSourceGeneratorが自動で生成してくれます。

Rect current = Rect.zero;
Tween.FromTo<Rect, NoOptions, RectTweenPlugin>(x => current = x, startValue, endValue, duration);

あとはTween.To()かTween.FromTo()の型引数に作成したTweenPluginを設定してあげればOKです。かなり簡単に書けて良いのではないでしょうか。

注意点としては、TweenPlugin自体は状態をもたせないようにしてください。Tweenに特別な値を持たせたい場合にはITweenOptionsを実装した構造体を作成し、それを型引数に渡せばOKです。

コルーチンに対応

他の追加要素としては、v0.1では「なんか動かなかったから」という理由で導入を見送ったコルーチンによる待機の機能を追加しました。v0.2ではちゃんと動くので一安心。

IEnumerator ExampleCoroutine()
{
    // Tweenの完了まで待機する
    yield return Tween.Empty(3f).WaitForComplete();

    // 1回のループが終了するタイミングまで待機する
    yield return transform.TweenPosition(Vector3.one, 1f)
        .SetLoops(3)
        .WaitForStepComplete();
}

こんな感じで簡単にTweenの待機を行うことができます。これ以外にもWaitForPlayやらWairForKillやら、任意のタイミングまで待機が可能です。

ただ個人的にはUniTaskを導入してasync/awaitを使用する方をお勧めします。コルーチンもまあ悪くないっちゃ悪くないんですが、async/awaitの方がやれることが圧倒的に多いし、パフォーマンス的にもUniTaskはとても良いので。

まとめ

以上、MagicTweenのv0.2出しましたという話でした。v0.1の中身はなかなかに酷いことになってたので、それが改善されたのが一番大きな一歩だったと思います。その結果としてパフォーマンス面もさらに磨きがかかった形ですし。

ただv0.2では追加できなかった機能もあるので、その辺は次のバージョンでやっていけたらいいな〜という感じです。しばらくは手が回らなそうなのでバグ修正のみになるかもしれないですが、そのうち時間が空いた時に一気に進めていこうかな、と。

というわけでバージョンが上がって新しくなったMagic Tween、是非使ってみてください!まだまだ開発は進めていくので、引き続きIssueやPRもお待ちしてます!

コメントを残す

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