コンテンツへスキップ

【Unity】ECSで実装されたトゥイーンライブラリ「Magic Tween」を公開しました

  • Unity

ECSで実装されたハイパフォーマンスなトゥイーンライブラリ「Magic Tween」を公開しました!

現在はv0.1.0ということで実験的なライブラリ、という位置付けではありますが、機能面に関してはGameObject/ECSの両方をサポートし、Sequenceの機能やTextMesh Proのサポートなど、非常に豊富な機能を取り揃えています。また、性能に関してもUnity向けのトゥイーンライブラリとしては最速クラス(というか圧倒的に最速)になっています。そのため、実用性に関しては今の時点でも十分即戦力になり得るライブラリになっているのではないでしょうか。

ただ、諸事情により公開を前倒ししたので、内部の設計がちょっと、というかそれなりにヤバめな雰囲気を醸し出しています。というか、うん、ヤバい。

そんな感じなので、ECSのサンプルとして中身覗くのはお勧めしません。この辺は流石に放置できないので、正式リリースまでに少しずつ改善していく予定です。まあ現時点でもちゃんと動くし速いからいいでしょ別に。(よくない)

基本的な使い方

いつも通りREADMEがほぼ全てなのでそちらを見ていただければいいんですが、全く説明なしだと記事が書けないので簡単に。

// 現在の位置から(1, 2, 3)の位置に5秒かけて移動する
transform.TweenPosition(
    new Vector3(1f, 2f, 3f), // 終了値
    5f // 値の変更にかかる時間 
);

// (0, 0, 0)の位置から(1, 2, 3)の位置に5秒かけて移動する
transform.TweenPosition(
    new Vector3(0f, 0f, 0f), // 開始値
    new Vector3(1f, 2f, 3f), // 終了値
    5f // 値の変更にかかる時間 
);

見ての通り、拡張メソッドを用いた非常にシンプルなAPIです。終了値(+開始値)を指定して、そこに向かって指定の秒数で動かすといった感じ。

transform.TweenLocalScale(Vector3.one * 2f, 5f)
    .SetEase(Ease.OutSine) // イージング関数をOutSineに設定
    .SetLoops(3, LoopType.Restart) // 3回繰り返す
    .SetDelay(1f); // 開始時に1秒間遅延させる

設定やコールバック等、色々付け加えたい時はメソッドチェーンを用いて追加していけばOKです。

パフォーマンス

最速のトゥイーンライブラリ、ということでまずはパフォーマンスから見ていきましょう。

比較対象として、とりあえず見つけたトゥイーンライブラリは片っ端から放り込みました。これらのライブラリは全て、任意の値をトゥイーンする機能(DOTween.To()みたいなやつ)が揃っています。そうじゃないと比較ができないので…

さて、MagicTweenのパフォーマンスですが、わざわざEntitiesパッケージという依存を増やしてまでECSを導入しただけあって非常に高速です。ほんとECS強い。適当に書いても速くてすごい。

とはいってもECSだから速いというだけではなくて、ECSの設計に合わせてしっかりチューニングしてます。そもそもTween.To(getter, setter, …)みたいなラムダ式を使おうとするとdelegateが必須なので、HPC# (Job System + Burst)の恩恵を得られなくなるんですよね。その辺もしっかり最適化しているので、ここまでの速度が実現できています。もちろんECS向けの実装であれば全てstructで実装可能なので、そちらを使えばさらに高速になります。

またGC Allocについてですが、そもそもECSはGCの管理下にメモリを確保しない(C++側のアンマネージド領域にUnsafeUtility.Mallocで確保する)ためGCフリーです。
Magic Tweenにおいてはdelegateを保持するComponentのみclassで作成されているため、Tweenの作成時にごく僅かなアロケーションがあります。が、ラムダ式自体のアロケーションは最適化によって消されているので、DOTweenなどと比較するとGC Allocは遥かに少なくなっています。

唯一stringのTweenのみ、更新時にGC Allocが発生します。こればっかりはstringがclassである以上避けられない問題でしょう…
ただし、Magic Tweenでは文字列をUTF-8 bytesとして直接扱うため、生成されるstring以外のGC Allocは一切ありません。もちろんRichTextを含む文字列にも対応しているため、RichTextを使ったテキスト送りにも問題なく使用できます。

一方、トゥイーンの作成速度については最速には一歩届きませんでした。うーむ。

ただ、この辺は改善の余地がめちゃくちゃあるので、今後のアップデートで高速化されていくと思います。まあ別に遅いわけではないし、そもそも起動時の一回だけなので大した問題ではないでしょう。多分。

拡張メソッド vs staticメソッド

続いてMagic TweenのAPI設計についてですが、基本的にはDOTweenのような拡張メソッド+メソッドチェーン形式を採用しています。

transform.TweenLocalScale(Vector3.one * 2f, 5f)
    .SetEase(Ease.OutSine)
    .SetLoops(3, LoopType.Restart)
    .SetDelay(1f);

さっきも出しましたがこういうやつです。

また、DOTweenでは作成時の引数にいくつかの設定項目を含むものもありますが、Magic Tweenでは引数を(endValue, duration)または(startValue, endValue, duration)に限定し、オプショナルな項目は全てSet…メソッドを用いて追加する形式をとっています。

TMP_Text target;

// DOTween
target.DOText("abcdefgh", 2f, scrambleMode:ScrambleMode.Custom, scrambleChars:"123456789")
    .SetEase(Ease.Linear)
    .SetDelay(1f);

// Magic Tween
target.TweenText("abcdefgh", 2f)
    .SetScrambleMode("123456789")
    .SetEase(Ease.Linear)
    .SetDelay(1f);

トゥイーンライブラリのAPIは雑に分類すると2つの派閥…というか形式がありまして、こちらのように拡張メソッドを使うパターンと、もう一方がstaticメソッドを使用する形式です。

// LeanTween
// 作成はstaticメソッド、メソッドチェーンで設定を追加
LeanTween.move(this.gameObject, new Vector3(1f, 2f, 3f), 5f)
    setEaseOutCubic();

// PrimeTween
// 作成はstaticメソッド、設定も同時に引数で指定
Tween.PositionY(transform, 10f, 1f, Ease.InOutSine);

LeanTweenの形式は読みやすいといえば読みやすいんですが、メソッドの命名がCemel形式なのがC#的じゃなくてちょっと…

PrimeTweenについては、作者自身が拡張メソッドを使用しない理由について説明しています。

Why PrimeTween doesn’t use extension methods, like DOTween?
https://github.com/KyryloKuzyk/PrimeTween/discussions/3

まあ、うん。言いたいことはわかります。ただ、ただですね、これだけは言わせてください。

「引数9個もあるメソッドが読みやすいわけないだろ!!!」(個人の感想です)

Physics.Raycast()を例に挙げてますが、そもそもあれだってオーバーロード多すぎて書きづらいし。Inlay Hintsがあるとか必須なパラメータは3つだけとか、そういう主張もわからなくはないんですが、それにしたって引数9個はダメでしょ…めっちゃ書きづらいし読みにくいやん…

と、色々言いましたが、前半の内容であるDOTweenの拡張メソッドに関する指摘については完全に同意します。DOMoveやDOKillという命名は、拡張メソッドとしては誤解を招く可能性があるのは間違いないでしょう。From(startValue)が拡張メソッドなのも微妙だし。

そのため、Magic Tweenでは拡張メソッドの命名規則を完全に統一し、Tween + [操作するプロパティの名前]としています。また、DOKillに相当する拡張メソッドはそもそも存在しません。(代替手段はいくらでもあるので)

// transform.eulerAnglesをトゥイーン
transform.TweenEulerAngles(new Vector3(0f, 0f, 90f), 5f);

// transform.localScaleをトゥイーン
transform.TweenLocalScale(Vector3.one * 2f, 5f);

// spriteRenderer.colorをトゥイーン
spriteRenderer.TweenColor(Color.white, Color.red, 3f);

若干記述量は増えますが、IntelliSense前提であれば実際に書く量はほとんど変わりません。

また、開始値に関しては作成時に同時に指定できるようになっています。SetInvertという開始値と終了値を入れ替えるオプションもあるので、DOTweenで言うところのFromのような使い方(終了値→現在値へのトゥイーン)も可能になっています。

staticメソッドでもいいんですが、個人的には拡張メソッドの方が好みです。IntelliSenseによって対応したメソッドのみが出てくると言う点でもそうだし、こちらの方が書き心地としては自然な気がします。とはいえこの辺は好みの問題なので、使いやすいライブラリを使えばいいんじゃないかと。

機能について

性能面も重要ですが、実際利用する上で必要となるのは機能でしょう。機能面についても、Magic Tweenはあらゆるユースケースをカバーするだけの非常に豊富な機能を用意しています。

基本的には、ほとんどの組み込みコンポーネントに対して拡張メソッドを用意しています。具体的にはTransform、Camera、Material、RectTransform、Image、Text、SpriteRenderer、PostProcessing、TextMeshPro、VFXGragh、etc…

そのため、ほとんどの場合は拡張メソッド一つで事足ります。ラムダ式書くの面倒だし、用意しておくに越したことはないでしょう。

とはいえ、自身で定義したプロパティをトゥイーンさせたい場面はあります。その場合にはTween.ToやTween.FromToを使ってください。

float foo = 0f;

// 10fまで5秒で移動
Tween.To(() => foo, x => foo = x, 10f, 5f);

// 0fから10fまで5秒で移動
Tween.FromTo(x => foo = x, 0f, 10f, 5f);

ただし、上の例はラムダ式内で外部の変数をキャプチャするため、余計なアロケーションが発生します。対象がクラスのメンバーである場合は、第一引数にオブジェクトを渡すことでアロケーションを回避することが可能です。

public class Foo
{
    public float value;
}

var foo = new Foo();
Tween.To(foo, target => target.value, (target, x) => target.value = x, 10f, 5f);

複雑なアニメーションを行いたい場合にはSequenceが使えます。Append、Insert、Join、Prependなどを使ってトゥイーンを追加できます。

var sequence = Sequence.Create();

sequence.Append(transform.TweenPosition(new Vector3(1f, 0f, 0f), 2f))
    .Append(transform.TweenPosition(new Vector3(1f, 3f, 0f), 2f));

また、Magic TweenはDOTween Pro同様、TextMesh Proの文字をトゥイーンさせることが可能です。便利。

TMP_Text tmp;

for (int i = 0; i < tmp.GetCharCount(); i++)
{
    tmp.TweenCharScale(i, Vector3.zero).SetInvert().SetDelay(i * 0.07f);
}

UniRx & UniTaskとの統合

UniRx、UniTaskと合わせた利用方法についても書いておきます。

TweenのコールバックはOn…メソッドで追加できますが、UniRxを導入することでこれをObservableに変換できます。

float foo;

Tween.To(() => foo, x => foo = x, 10f, 10f)
    .OnUpdateAsObservable()
    .Subscribe(_ =>
    {
        Debug.Log("update!");
    });

さらに、TweenはUpdate時に値を発行し、Kill時に完了するイベントとみなすことが可能です。故に、TweenそのものをObservableに変換できます。

Tween.FromTo(0f, 10f, 10f, null)
    .ToObservable()
    .Where(x => x >= 5f)
    .Subscribe(x =>
    {
        Debug.Log("current value: " + x);
    });

TweenとRxの組み合わせはなかなか強力だと思っています。実際、パフォーマンス比較でも挙げたAnimeRxというライブラリではトゥイーンのような機能をUniRxを用いて実現していて、Rx的な手触りでアニメーションが書けるようになっていて面白いのでおすすめです。

ただし、このObservableへの変換はTweenを新たなObservableでラップするので、パフォーマンスに関してはあまり優れていません。まあ、わざわざ大量にTween作ってOnservableに変換することもないとは思いますが。

さらに、UniTaskを導入することでasync/awaitにも対応できます。

await transform.TweenPosition(Vector3.up. 2f);

CancellationTokenを渡したり、Kill以外のタイミングを待機することも可能です。

var cts = new CancellationTokenSource();

await transform.TweenPosition(Vector3.up. 2f)
    .AwaitForKill(cancellationToken: cts.Token);

await transform.TweenPosition(Vector3.down. 2f)
    .AwaitForComplete(cancellationToken: cts.Token);

ECS向けの実装

Magic TweenはECSで実装されていますから、当然ECSのコンポーネントをトゥイーンさせる機能も搭載しています。

例えばLocalTransformをトゥイーンさせる場合、以下を最初のOnUpdateなどで呼んでおけばきっちりトゥイーンされます。

Tween.Entity.To<LocalTransformPositionTranslator>(entity, new float3(1f, 2f, 3f), 10f)
     .SetEase(Ease.OutQuad);

書き方は通常のTween.Toとあまり変わりませんが、型引数にTranslator(Tweenの値を反映させる専用コンポーネント)を指定する必要があります。このTranslatorは自身で定義する必要がありますが、LocalTransformやEntities Graphicsのコンポーネントについては組み込みのTranslatorが用意されています。

また、Tweenの作成はEntityの作成を伴うため、メインスレッド上でしか動きません。これはJob内でTweenのAPIが使用できないことを意味します。

これは少々不便なので、EntityCommandBufferのような形でTweenの作成をコマンドとして保存できる機能を作成する予定です。正式リリースまでにはできると良いな…

まとめ

実はECSとトゥイーンの組み合わせというのは前例がないわけではなく、Githubを探すといくつかECS向けのトゥイーンライブラリは見つかります。しかし、どれも実験段階のもので、到底実用的とはいえないものばかりでした。そもそもECS自体がまだ普及していない/機能が出揃っていないということもあり、ECS専用のトゥイーンを作ったところで使われることはほとんどないでしょう。

今回開発したMagic Tweenは、ECSを活用して高いパフォーマンスを実現しつつ、従来のコンポーネントにも対応できるような機能を用意しています。現状DOTS/ECSがやや機能不足であることを考えると、このくらいの塩梅でECSを使うのがちょうど良いのではないでしょうか。

ただ、今回実装してて思ったこととしては、ECS、めちゃくちゃ良い感じです。少々ハードルが高い部分は否定できませんが、案外書きやすいし、何より超絶速い。そして機能不足とは言っていますが、徐々に対応したパッケージも登場しつつあり、あと1〜2年もすれば十分実用的な機能は出揃うでしょう。世はまさにECS時代。

というわけで、ECS時代の幕開けに先駆けて開発したMagic Tween、是非是非使ってみてください!
まだまだ開発を続けていくので、PRやIssueもお待ちしてます〜!

タグ:

コメントを残す

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