すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

【Moq】UnityでのMoq導入方法 と MoqのTips集を紹介!

Unity 2021以降の場合は以下をどうぞ! tsgcpp.hateblo.jp

概要

今回は Moq というC#向けモックライブラリをUnityに導入する方法と、Moqを使ったテクニックをいくつか紹介しようと思います。

UnitTestにおいてモック化はかなり重要な立ち位置になると思いますが、自作モックは何かと保守コストが高かったり機能面が微妙になったりと悩みどころがあります。
そこでオススメなのが今回紹介する Moq となります。

他の言語のモックライブラリ同様、モック化だけでなくSpy機能(メソッドがコールされたかの確認機能)も結構充実していますので、
ぜひUnity もしくは C# のUnitTestに活かしていきましょう!

今回の記事はある程度UnitTestを知っていることが前提となりますが、UnitTestのサンプルコードにもできる限り説明を入れています。
チートシートにもなっていると思いますのでよかったら参考にしてください。

Moqの導入

Github

※今回はソースコードを直接取り込みません

github.com

MoqのソースコードC# 8.0で書かれており、現在のLTSであるUnity 2019.4 ではコンパイルできません。

よって、後述するコンパイル済みのdllをダウンロードして使用することになります!

Moqの依存ライブラリとそのライセンスについて

使用する前に必ずライセンスを確認してください(特に業務で使用する場合は要注意です!)

上記4つを取り込む必要があります。

MoqをUnityに導入する方法

Moqおよび依存ライブラリのダウンロード

今回はMoqと依存ライブラリ3つをNugetからダウンロードしましょう。

以下の用に"Download package"からnupkgをダウンロード可能です。

www.nuget.org

www.nuget.org

www.nuget.org

www.nuget.org

nupkgを展開

nupkgの実体はzipファイルなので拡張子を ".zip" にするだけで大体のOSの標準で展開できると思います。

7-zipなどを使用すれば拡張子を変えなくても展開できると思います。

dllファイルの取り出し

展開したフォルダのうち、以下のパスにあるdllをUnityのAssets以下のフォルダにコピーしましょう。

Api Compatibility Level に従ってdll を選択してください。

.NET Standard 2.0

  • Moq: moq.4.X.X/lib/netstandard2.0/Moq.dll
  • Castle.Core: castle.core.4.X.X/lib/netstandard1.5/Castle.Core.dll
    • .Net Standard2.0向けがないためこちらで代用
  • System.Threading.Tasks.Extensions: system.threading.tasks.extensions.4.X.X/lib/netstandard2.0/System.Threading.Tasks.Extensions.dll
  • System.Runtime.CompilerServices.Unsafe: system.runtime.compilerservices.unsafe.5.0.0/lib/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dll

.NET 4.x or .NET Framework

  • Moq: moq.4.X.X/lib/net45/Moq.dll
  • Castle.Core: castle.core.4.X.X/lib/net45/Castle.Core.dll
  • System.Threading.Tasks.Extensions: system.threading.tasks.extensions.4.X.X/lib/net461/System.Threading.Tasks.Extensions.dll
  • System.Runtime.CompilerServices.Unsafe: system.runtime.compilerservices.unsafe.5.0.0/lib/net45/System.Runtime.CompilerServices.Unsafe.dll

Unityのプロジェクトへの配置は以下を参考にしてください

dllのプラットフォームの指定

Moqはあくまでテストのみに使用するため、ビルドする実行ファイルには含まれないように設定しておきましょう! 大抵のOSSは再頒布するとラインセス表記が必要なります。

特に MoqCastle.Coreは実行ファイルには不要なのでInclude PlatformsEditorのみにしておきましょう!

System.Threading.Tasks.Extensions と System.Runtime.CompilerServices.Unsafe のプラットフォーム指定について

System.Threading.Tasks.Extensions と System.Runtime.CompilerServices.Unsafe も不要であれば同様に設定してください。

ただ、ZLogger など一部メジャーなOSSも依存していたりするので、その場合は逆に設定しないように注意してください。

テスト用Assembly Definitionにdllを取り込み

以下を参考にテスト用Assembly Definitionの"Assembly References"に追加して"Apply"して下さい。

以上でMoqおよび依存ライブラリの取り込みは完了です!

Moqによるモック作成サンプル

せっかく取り込んでみたので、少し使い方を解説しようと思います。

モック化するinterface

// 戻り値有りのメソッドを持つinterface
public interface IGetter
{
    object Get();
}

// プロパティを持つinterface
public interface IHolder
{
    object Value { get; }
}

// 引数を持つinterface
public interface ILogger
{
    void Log(string message);
}

モック化の例

モックオブジェクトの作成

Mock<IGetter> mock = new Mock<IGetter>();

モックオブジェクトから元となる型を取り出し

var getterMock = new Mock<IGetter>();

// モックオブジェクトからIGetterインスタンスとして取り出し
IGetter mockAsGetter = getterMock.Object;

メソッドの戻り値を指定

var getterMock = new Mock<IGetter>();

// Getメソッドのモック化("Hello Moq!"を返すように仕込み)
getterMock.Setup(m => m.Get()).Returns("Hello Moq!");

プロパティの戻り値を指定

var holderMock = new Mock<IHolder>();

// Valueプロパティのモック化("Hi Moq!"を返すように仕込み)
holderMock.Setup(m => m.Value).Returns("Hi Moq!")

特定のメソッドがコールされたかの確認(Spyとしての利用)

メソッドがコールされたかを検知するモックオブジェクトをSpyと呼ぶことがあります。

MoqのMockクラスは生成するだけでSpyとしても使用することが可能です!

また、「特定の引数が渡されたこと」、「任意の引数でコールされたこと」なども確認することが可能です。

// ILoggerのモックオブジェクトの作成
var loggerMock = new Mock<ILogger>();
var mockAsLogger = loggerMock.Object;

// 複数回(3回)コール
mockAsLogger.Log("One");
mockAsLogger.Log("Two");
mockAsLogger.Log("Two");

// 3回コールされたことの確認
loggerMock.Verify(m => m.Log(It.IsAny<string>()), Times.Exactly(3));

// 複数回(1回以上)コールされたことの確認
loggerMock.Verify(m => m.Log(It.IsAny<string>()), Times.AtLeastOnce());

// "One"でコールは1回であることの確認
loggerMock.Verify(m => m.Log("One"), Times.Once());

// "Two"でコールは2回であることの確認
loggerMock.Verify(m => m.Log("Two"), Times.Exactly(2));

Moqを使用したサンプルの全体

Moqの使用例の全体は以下のテストクラスで確認できます!

[https://github.com/tsgcpp/UnityMoqSample/blob/master/Assets/Tests/MoqExamples/TestMoqSample.cs

モック化するメソッド、プロパティの注意点

モック化するメソッドやプロパティはオーバーライド可能である必要があります
オーバーライド不可の場合はモック化することはできません。。。

以下のMockTargetクラス(非interface)の場合、FuncVirtualFuncAbstractはモック化できますがFuncはオーバーライド不可のためモック化できません(例外System.NotSupportedExceptionが飛びます)。

public abstract class MockTarget
{
    public object Func()
    {
        return 1;
    }

    public virtual object FuncVirtual()
    {
        return 2;
    }

    public abstract object FuncAbstract();
}

Moqに限らずモックライブラリ全般に言えますが、モック化の対象は基本的にinterfaceを使用することをオススメします!

https://github.com/tsgcpp/UnityMoqSample/blob/master/Assets/Tests/MoqExamples/TestMoqException.cs

Moqを使ったTips集

in, ref指定の構造体を引数として持つメソッドの任意の引数でのVerify

以下のように引数にinが指定されたメソッドのことです。

public interface IInCaller
{
    void Call(in Parameter parameter);
}

ちなみにParameterは以下です。

public struct Parameter
{
    public int x;
}

さて、このinが指定されたメソッドのVerifyですが、なんとIt.IsAny<T>()だとSpyが機能しなくなります。。。

target.Verify(m => m.Call(It.IsAny<Parameter>()), Times.Once());

inrefが指定されている場合は以下の It.Ref<T>.IsAny を使用しましょう!

target.Verify(m => m.Call(It.Ref<Parameter>.IsAny), Times.Once());

Moq自体の仕様なのでご注意ください。

細かい使用確認は以下のテストコードで実施しています。 よかったらこちらもどうぞ!

https://github.com/tsgcpp/UnityMoqSample/blob/master/Assets/Tests/MoqExamples/TestMoqRefVerify.cs

in, refどちらも指定されていない場合

It.IsAny<T>()で確認しましょう。
こちらは逆にIt.Ref<T>.IsAnyが機能しなくなります。

指定ミスの対策

コンパイルエラーにならない関係でヒューマンエラーが起きそうですよね?

特にTimes.Never()を使用する場合、コールなし or 指定ミス どちらなのか分からなくなってしまいます。

target.Verify(m => m.Call(It.Ref<Parameter>.IsAny), Times.Never());

オススメなのはIt.IsAny<T>() もしくは It.Ref<T>.IsAnyを用いるテストを実施する場合は、 正常系のテストに以下のTimes.Once()など "1回以上コールされたことの検証を混ぜ込むことです。

target.Verify(m => m.Call(It.Ref<Parameter>.IsAny), Times.Once());

Times.Once()Times.Exactly(N)でテストが通る = 機能している ということになりますので!

Callbackの活用例

Callbackの紹介

Moqにはモックオブジェクトのメソッドがコールされたときにコールバック(イベント)を仕込むことが可能です。

// コールバック時のパラメータ格納用List
var messageList = new List<string>();

// ILoggerのモックオブジェクトの作成
var loggerMock = new Mock<ILogger>();

// モックオブジェクトにコールバックを仕込む
loggerMock
    .Setup(m => m.Log(It.IsAny<string>()))
    .Callback<string>(message => messageList.Add(message));

上記のCallbackLogメソッドに渡された引数をコールバックにより取り出すことが可能になります。

MoqのCallbackでUnityのExecutionOrderとUniTaskの実行順序の検証

さてコードを書いていると時々「絶対この順番でメソッドがコールされていることを保証したい」ということ有りませんか?

そんなときにCallbackを活用するとテストにコール順の検証を組み込むことができます(Moqを使用するので各メソッドがモック化可能なことが前提となります)。

今回はCallbackを活用して、UnityのExecutionOrderとUniTaskを混合した実行順序のテスト化をやってみたいと思います。

具体的にはDefaultExecutionOrderを指定したコンポーネント, UniTask.Yieldに指定したPlayerLoopTimingそれぞれが同時に存在する場合に実行順序を確認するテストを作成します。

確認用コンポーネントとテストコードについて

IRunner型のインスタンスを渡し、Run()メソッドをコールさせる構成を取ります。

DefaultExecutionOrderを指定したコンポーネント

DefaultExecutionOrderを指定する以外は共通のためベースクラスを作成し、それを継承したクラスを用意しました。

以下は int.MaxValueを指定した場合の例です。

using UnityEngine;

/// <summary>
/// MonoBehaviourによるRunner実行 (order: int.MaxValue)
/// </summary>
[DefaultExecutionOrder(int.MaxValue)]
public class ProcessorOrderMaxValue : BaseProcessOrder
{
}

/// <summary>
/// MonoBehaviourによるRunner実行のベース
/// </summary>
public abstract class BaseProcessOrder : MonoBehaviour, IProcessor
{
    public IRunner Runner { get; set; } = null;

    public IRunner LateRunner { get; set; } = null;

    protected virtual void Update()
    {
        Runner?.Run();
    }

    protected virtual void LateUpdate()
    {
        LateRunner?.Run();
    }
}

UniTaskを使用したクラス

using UnityEngine;
using Cysharp.Threading.Tasks;

/// <summary>
/// UniTaskによるRunner実行
/// </summary>
public class ProcessorUniTask : IProcessor
{
    public IRunner Runner { get; set; } = null;

    // 未使用
    public IRunner LateRunner { get; set; } = null;

    public PlayerLoopTiming Timing { get; set; } = PlayerLoopTiming.Update;

    /// <summary>
    /// Runnerの非同期実行
    /// </summary>
    public void Process()
    {
        ProcessAsync().Forget();
    }

    private async UniTask ProcessAsync()
    {
        await UniTask.Yield(Timing);
        Runner?.Run();
    }
}

テストコード

結構コードが長くなってしまったので全体は以下のGithubのコードをご確認ください。

https://github.com/tsgcpp/UnityMoqSample/blob/main/Assets/Tests.UniTask/ExecutionOrder/TestExecutionOrder.cs

大まかに言うとIRunnerのモックオブジェクトを各コンポーネントごとに作成し、対象のコンポーネントごとにコールバック時のメッセージを仕込みます。
コンポーネント内のUpdate(), LateUpdate() および UniTaskのawait後にIRunner.Run()がコールされると順番にリスト_callbackMessageListにメッセージが追加されていきます。

var runnerMock = new Mock<IRunner>();

// MockのIRunner.Run()がコールされた際にメッセージを登録するように仕込む(いわゆるSpy)
runnerMock
    .Setup(mock => mock.Run())
    .Callback(() => _callbackMessageList.Add(message))

最後に1フレーム動かして、_callbackMessageListにAddされたメッセージの順番を確認することで実行順序を確認します。

yield return new WaitForEndOfFrame()
Assert.AreEqual(18, _callbackMessageList.Count);

Assert.AreEqual("UniTask PlayerLoopTiming.PreUpdate", _callbackMessageList[0]);
Assert.AreEqual("UniTask PlayerLoopTiming.LastPreUpdate", _callbackMessageList[1]);
Assert.AreEqual("UniTask PlayerLoopTiming.Update", _callbackMessageList[2]);
Assert.AreEqual("ProcessorOrderMinValue Update", _callbackMessageList[3]);
Assert.AreEqual("ProcessorOrderM1 Update", _callbackMessageList[4]);

// 以下略

TestRunnerを実行してみました。

問題なさそうですね。

このテストを組み込んでおくことでUnityやUniTaskの更新で実行順序が変わった場合に検知することができたりはするかなと思います。

「Debug.Logで良くない?」と言われるとその通りなんですけどねw

個人的に詳細な実行順序を調べたかったというのもあったので、ついでにCallbackに使用する題材にしてみました。

サンプルプロジェクト

github.com

雑感

1ヶ月半ぶりの投稿です。

ネタはいっぱいあるんですが、今やってることに熱中しちゃってなかなかブログを書けませんでしたw。

次こそは3DCG系のネタをやりたいかなと思います。

なんかブログを出すこと自体が目的になってるようなとこがあるので、焦らずやっていこうかなと思います。

それでは~

【Extenject】AsTransient, AsCached, AsSingle を使い分けよう!

f:id:tsgcpp:20201010091122j:plain

概要

今回はZenjectのインスタンス生成の Scope である AsTransient, AsCached, AsSingle の使い分けについて解説します。

この3つは生成されるインスタンスに明確なルール付けをすることができるので、上手に利用してあげてください!

例によって記事の最後にサンプルプロジェクトを置いておきます。

動作環境

  • Unity 2019.4.12f1
  • Extenject 9.2.0
    • 例によって現在保守されているExtenjectの方を使用

関連

以前の記事です。 よかったらどうぞ!
インストール方法やテストについて紹介しています。

tsgcpp.hateblo.jp

前知識

ContractType と ResultType について

Zenjectには ContractType と ResultType という概念があります。

  • ContractType は Bind<>に指定する型
  • ResultType は To<> に指定する型

以下を例にすると IRandomizer がContractType, FixedRandomizerがResultTypeです。

Container
    .Bind<IRandomizer>()
    .To<FixedRandomizer>()

Scopeを考える上でこの ContractType と ResultType は重要なので意識しておいてください!

Zenjectによるインスタンス生成

Containerにバインドすると基本的にZenjectはResultTypeのインスタンスを必要なときに生成してくれます。

public interface IGreeter
{
    string Greeting { get; }
}
public class ResultTypeGreeter : IGreeter
{
    private string _greeting;

    public ResultTypeGreeter(string message, string target)
    {
        _greeting = string.Format("{0} {1}!", message, target);
    }

    public string Greeting => _greeting;
}
Container
    .Bind<IGreeter>()
    .To<ResultTypeGreeter>()
    .AsTransient()
    .WithArguments("Hey", "Guys");

この例の場合、ResultTypeGreeter のコンストラクタの第1引数に"Hey"、第2引数に"Guys"が渡されてインスタンス生成されます。
結果、Zenjectによって生成されたResultTypeGreeterのプロパティ Greeting は "Hey Guys!" を返します。

FromInstanceで直接インスタンスを渡したりすることもできますが、Scopeのメリットが弱くなってしまいます(理由は後述)。

Scopeを使用する場合において、このインスタンス生成してくれる機能は非常に重要な意味を持ちます。

コンストラクタが複数ある場合

[Inject]で使用するコンストラクタをZenjectに明示してください。

コンストラクタが1つの場合は不要です。

ResultTypeGreeter.cs

以下のテストコードで動作確認してます。

ResultTypeGreeterZenjectConstructionTest.cs

ResultType に複数のコンストラクタがある場合はいずれかのコンストラクタ(大抵は一番上に宣言したコンストラクタ)が使用されます。
予期せぬ動作になるかもしれないのでコンストラクタが複数ある場合は[Inject]で明示したほうが良いと思います。

また、この[Inject]ですが複数のコンストラクターに指定した場合はエラーになります。

つまり、Zenjectがインスタンス生成に利用するコンストラクタは1つ ということになります。

WithArguments の引数の数について

基本的に使用するコンストラクタの引数と同じ数を指定する必要があります。

    public ResultTypeGreeter(string message, string target)
    {
        _greeting = string.Format("{0} {1}!", message, target);
    }

以下のようにコンストラクタの引数とWithArgumentsの数が一致していないとエラーになります。

    // エラー
    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsTransient();
        // WithArgumentsなし
    Container.Inject(this)

    // エラー
    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsTransient()
        .WithArguments("Hi");  // 引数1つ指定
    Container.Inject(this)

    // 正常
    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsTransient()
        .WithArguments("Hey", "Guys");  // 2つ指定
    Container.Inject(this)

ただし、デフォルト引数が指定されている場合はエラーにならないようです。

    [Inject]
    public ResultTypeGreeter(string message = "Hello", string target = "Everybody")
    {
        _greeting = string.Format("{0} {1}!", message, target);
    }

ご参考までに。

Scope (AsTransient, AsCached, AsSingle)

いよいよ本題です! ZenjectのScopeについて見ていきましょう。

Scope(スコープ)と聞くとやはりプログラミングにおけるスコープが思い当たりますが、Zenjectのスコープもほぼ同じ意味です。
インスタンスのスコープ、つまり参照される範囲を指定 することになります。

Scopeの違い

AsTransient AsCached AsSingle 備考
Context毎のインスタンス DI先のクラスごと BindしたContractType(WithId指定有りの場合はさらにID毎)に1つ Context毎に1つ Contextごとに管理

それぞれ掘り下げて見ましょう。

AsTransient

AsTransient は DI先のクラス毎に異なるインスタンスとして渡されます。
渡されたインスタンスはすべて別物なので好き勝手に変更しても、他のDI先には影響しないことになります。

AsTransient確認用UnitTest

以下のような用途に使えます。

  • メモリ使用量を気にしないからとりあえずDIで挙動を変更したい
  • DI先ごとに別のインスタンスとして持たせたい(他に影響させたくない)

AsTransient の 利用例

DI先にはICollectionとして使用してもらい、バインドでListLinkedListなどを切り替えるなどの使用方法が考えられます。

    // ListをDIしたい場合
    Container
        .Bind<ICollection<int>>()
        .To<List<int>>()
        .AsTransient();
    Container.Inject(this);
    // LinkedListをDIしたい場合
    Container
        .Bind<ICollection<int>>()
        .To<LinkedList<int>>()
        .AsTransient();
    Container.Inject(this);
    // SortedListをDIしたい場合
    Container
        .Bind<ICollection<int>>()
        .To<SortedList<int>>()
        .AsTransient();
    Container.Inject(this);

DI先のコードを変更することなく性能(LinkedList)や機能(SortedList)を変えることができます。

ICollectionを使用したAsTransientサンプル

AsCached

AsTransient は DI先のクラス毎に同一のインスタンスとして渡されます。
WithIdを指定している場合はIDごとに異なるインスタンスとなります。

AsCached確認用UnitTest

インスタンスの単位は細かくは以下のようになります。

  • ContractType毎に1つ
  • ID指定無しは「ID指定無しのグループ」として1つ生成
  • ID指定有りの場合は、IDごとに1つ生成
    // ID指定無し向けのインスタンス
    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsCached();

    // ID_01向けのインスタンス
    Container
        .Bind<IGreeter>()
        .WithId("ID_01")
        .To<ResultTypeGreeter>()
        .AsCached();

    // ID_02向けのインスタンス
    Container
        .Bind<IGreeter>()
        .WithId("ID_02")
        .To<ResultTypeGreeter>()
        .AsCached();

以下のような用途に使えます。

  • ResultTypeは同じだけど、DI先のグループごとに異なるインスタンスにしたい
    • 後述しますが、AsSingleの場合はResultTypeは1つしか作成されません

AsCachedの使用例

今回はとあるメッセージキューをグループに分けてインジェクションさせてみましょう。

    Container
        .Bind<Queue<string>>()  // Queueは残念ながら専用interfaceなし
        .WithId(Group01)
        // .To<Queue<string>>()  // ContractTypeとResultTypeが同じならToは不要
        .AsCached();

    Container
        .Bind<Queue<string>>()
        .WithId(Group02)
        .AsCached();

インスタンスを受け取る先は引数にId を指定

    public class DITarget01
    {
        private Queue<string> _queue;

        // MonoBehaviourでない場合はIdは引数で指定
        public DITarget01([Inject(Id = Group01)] Queue<string> queue)
        {
            _queue = queue;
        }

        public void Enqueue()
        {
            _queue.Enqueue("Hello");
        }
    }

メッセージキューをグループで別インスタンスとして管理することが可能になります。

Queueを使用したAsCachedサンプル

AsSingle

AsSingleResultTypeのインスタンスが単一であることを保証するScopeです。
Singleという単語がついている通りですね。

どう保証するのかというと、同じResultTypeがバインドされたときに例外(ZenjectException)を出すようになっています。

    Container
        .Bind<IGreeter>()
        .To<ResultTypeGreeter>()
        .AsSingle();

    // IDを指定しようが、AsTransientを指定しようが例外を出す
    Container  
        .Bind<IGreeter>()
        .WithId(Identifier01)
        .To<ResultTypeGreeter>()
        .AsTransient();

以下のような用途に使うと良いと思います。

  • 複数のインスタンスを作成したくないResultTypeのバインド
    • 複数インスタンスが存在するとおかしくなる(通信に関わる部分など)
    • メモリを無駄に消費したくない
  • 複数のインスタンスを作る必要がないもの
    • 引数に対して必ず同じ結果を返す(副作用のない)メソッド(関数)を持つResultType
    • Strategyパターン、Factoryパターンのクラスなど
  • Singletonパターンからの移行(理由は後述)

ちなみにですが、FromInstanceを使用するとAsSingleの恩恵がなくなってしまうので、
インスタンスの単一性を保証したいのであればTo<>を使用して、Zenjectにインスタンスを管理させましょう。

個人的には疎結合化がZenjectの主な使用目的のため、AsSingleを一番使用しています。

AsSingleの使用例

いろいろな使用例があるのですが、今回はシンプルに疎結合化の実現で使ってみましょう。

前回の記事IRandomizerのようにインスタンスは全体で1つのみでいいようなResultTypeに使用すると良いと思います。

余談:Singletonパターンからの移行

AsSingleを見て「Singletonパターンで良くない?」って思った方もいるかもしれません。
DIにすることで様々な恩恵があると考えていますので取り上げたいと思います。

Singletonパターンのデメリット

  • 参照先のクラスと密結合になりテスタビリティが損なわれる
    • 参照先の挙動をUnitTest時に変更できない
    • 例: StaticClass.Instance.Method()の場合はStaticClassと密結合
  • Unityで同一シーンの読み込み毎に前回の状態に引きずられてしまう
    • 1回目と2回目以降で動作が変わるリスクが生まれる
    • 実装が簡単だが、シーンの切り替え毎にリセット処理など注意する点が多い
  • 生成したインスタンスがいつまでも残り続ける(static変数として使用する場合)
    • staticオブジェクトが解放されない
    • ヒープ領域の整理の際に障害になり得る(極稀な話だと思いますが)
  • インスタンス生成のタイミングが不定(個人的に嫌いな性質)
    • 大抵は初回アクセス時にインスタンス生成
      • if (_instance == null) _instance = new StaticClass();

色々と書きましたが「実装は簡単だけど、注意がかなり必要」なデザインパターンだと考えています。

特にUnityやiOS, Androidのネイティブアプリのように "ライフサイクル" を持つアプリケーション開発においては特に注意が必要です。

Zenject + AsSingleでSingletonの問題を解決

さて、このSingletonパターンですが大体はDIによる移行が可能だと考えています

Singletonパターンを使う理由の大体は「同一のインスタンスにアクセスしたい」だと思います。

Zenjectを使っているならResultTypeをAsSingleでバインドしてDIすることで、上記デメリットを解消した上で同一インスタンスにアクセスするという要望を満たせると思います。

どうしてもSingletonでないといけないパターンがあるかもしれませんが、
Singletonを使用する理由が「簡単だから」であれば、一度DIの使用を検討してみてください。

プロジェクトが大きくなると密結合のSingletonパターンは。。。

AsSingleの注意点

ExtenjectのREADMEにも記載されていますが、AsSingleは同一のContainer(Context)毎にインスタンスが管理されます。

ProjectContext と SceneContext両方に同じResultTypeをバインドしても例外はでないのでご留意ください。

サンプルシーン Assets/Scenes/GreeterDISample.unity に ProjectContext と SceneContext両方でGreeterをAsSingleでバインドしています。
よかったら動かしてみてください。

簡単なまとめ

に使うといい気がします(超大雑把)

サンプルプロジェクト

github.com

雑感

久々の記事投稿です。
ポートフォリオに集中してたんですが、さすがにネタが溜まりすぎて来たので簡単なやつを1つでも解消したいと思い記事にしました。

Zenjectは使ってて本当に気持ちいいですね~。

Zenjectを使ってて思ったメリットとして 「UnityでDIが実現できる」だけじゃなくて「UnityでMonoBehaviour以外のクラスが使いやすくなる」っていうのもあると思います! 疎結合にできるライブラリですが、自分自身はZenjectに密結合になっちゃってますね~。

それでは良いUnityライフを!

【Unity】Shader Graphで走査線っぽいもの

f:id:tsgcpp:20200908230132j:plain

概要

今回は Shader Graph で走査線っぽいもの作り方を紹介します。 併せてシェーダーを作るときの考え方も解説したいと思います!

動作環境

  • Unity 2020.1.4f1
    • Universal RP 8.2.0

今回作るもの

以下の動画の様にスクロールする走査線を作ります。

www.youtube.com

実装機能

せっかくのシェーダなので、パラメータで以下のものを調整できるようにしましょう。

  • 走査線の色
  • 走査線の幅
  • 走査線の速度
  • 走査線の出現頻度
  • UV座標のV方向に走査線が移動(理由は後述)

シェーダーテクニック

シェーダーを実装する前にいくつかテクニックを紹介します。

UVノード と 分離(Split)

UVノード は文字通りUV座標を取り出すことができます。
R要素にはU座標、G要素にはV座標が含まれています。

UV座標は[0, 1]の範囲のため、Splitノードで要素をU, Vに分離することでグレースケールのグラデーションを作ることができます。

f:id:tsgcpp:20200909001555p:plain

V要素に関して言えば下方(0の方)が黒、上方(1の方)が白というグレースケールを取り出すことができます。

Fraction

Fractionノード というものがあります。
「入力数値の小数点を取り出す」ノードとなります。

グラフで表すと以下のような一次関数を並べた感じになります。

f:id:tsgcpp:20200909003158j:plain
Fractionのグラフ(横軸: 入力、縦軸: 出力)

簡単に言うと 0 から 1 (厳密には0.999...) を繰り返すノードを作成することができます。

www.youtube.com

Time + UV + Split + Fraction

www.youtube.com

www.youtube.com

所謂「UVスクロール」です。

UVをSplitノードで分離することでU or V方向にスクロールするノードとなります。
また、AddノードとSubtractノードでスクロール方向が反転する性質があります。

三角波

www.youtube.com

所謂「三角波ノード」です。

このノード、見た目はシンプルですが 「走査線の幅」の調整とかなり相性が良かったりします

f:id:tsgcpp:20200909014633p:plain
三角波のイメージ

Fractionを[0, 1] → [-1, 1]に変換して絶対値を取ると、0 → 1 → 0...を繰り返す三角波を実現できます。

三角波 + Step

先程作成した三角波ノードをStepに渡すと以下のようなノードになります。

www.youtube.com

何が言いたいかというと「波の幅をパラメータで設定しやすい」ということです!

三角波 + Smoothstep

今回の主役ノードです!

www.youtube.com

三角波はSmoothstepとも相性が良いです。
グラデーション付きの波をシームレスに作ることができます。

余談:Time + Fraction + Smoothstep

stepをであれば"Time + fraction"で十分ですが、 Smoothstepの場合は残念ながらシームレスになりません。
三角波を作った理由は「シームレスな波が必要」だったからなんですね。

波の出現の周期を変える

www.youtube.com

これはUVスクロールの値を周期数で割り、StepのEdgeも同様に周期数で割れば実現できます。

※UVスクロールの値だけだと走査線になる部分の幅が広がってしまいますので、幅も割って調整します。

UVスクロールのスピードを遅らせるということですね。

完成形

上記のテクニックを組み合わせて、プロパティで調整可能にしてアルファ(不透明度)に使用したのが今回のシェーダになります!

www.youtube.com

Githubのプロジェクトに完成形を置いておきますので、良かったらどうぞ。

サンプルプロジェクト

github.com

雑感

今回はShader Graphを題材にしてみました。
記事を書いていて思ったのは「これ、音声解説 + 動画のほうが絶対わかりやすいだろ」でした。。。

別にAviutl使えないわけじゃないんですがちょっとトークに自信がないです。。。
ただ動画のほうが情報をたくさん詰め込めますし、僕みたいに字だとわからなくても動画だと理解しやすい人のほうが多いと思いますのでいつか挑戦してみたいです。

次回ですが、そろそろ以前から考えていたポートフォリオに本格的に集中しようと思いますので、 しばらくは投稿頻度は下がると思います。

それでは~。

【C#】え、Generic Interfaceでメソッド引数を設定すれば構造体のBoxingを回避できるの?

どうも、最近GC Allocにおびえているすぎしーです。
今日はUnityじゃなくてC#がメインの話題です。

概要

今回はGeneric Interfaceと値型のBoxing回避についてお話します!
Boxingは余計なGC Allocが発生してしまいますが、回避が難しいパターンも存在します。

ただ、一見変わった方法でそのBoxingを回避する方法があったので紹介したいと思います!

Constraints on type parameters について

要するにGenericで指定される型に制約を付けられるC#の機能のことです。

docs.microsoft.com

C#では、where句を使うことでGenericの型に制約を付けることができます。

例:structで制約を付けた場合

例えば、以下のようにwhere T : structとつけると、Tの指定をstruct(構造体)のみに制限させることができます。

public class GenericData<T> where T : struct
{
    T Value { get; }
}

以下のようなコードで確認することができます。

// エラーなし
GenericData<StructSample> structData;

public struct StructSample
{
}
// エラー: The type 'ClassSample' must be a non-nullable value type in order to use it as parameter 'T' in the generic type or method 'GenericData<T>'
GenericData<ClassSample> classData;

public class ClassSample
{
}
// エラー: The type 'IInterfaceSample' must be a non-nullable value type in order to use it as parameter 'T' in the generic type or method 'GenericData<T>'
GenericData<IInterfaceSample> interfaceData

public interface IInterfaceSample
{
}

interfaceを継承した構造体のboxing

C#の構造体はinterfaceを継承することができます。

// ILoggerを継承した構造体
public struct Data : ILogger
{
    public int value;

    public void Log()
    {
        Debug.Log("I am `Data`");
    }
}

public interface ILogger
{
    void Log();
}

さて、このinterfaceを継承した構造体ですが意図しないBoxingがあっさり発生します。。。

var data = new Data { value = 0, };

// ILogger型の変数に構造体Dataを渡す
ILogger logger = data;

intefaceを引数としたメソッドに渡した場合のBoxing

先述したBoxingですが、引数の型がinterfaceのメソッドに引数として渡しても発生します。

var data = new Data { value = 0 };

Func(data);  // 引数として渡す

// 引数の型がinterfaceのメソッド
public void Func(ILogger logger)
{
    // 何もしない
}

なぜBoxingが発生したのか

そもそも「Boxingは参照型の変数に値型のデータを渡すことで発生」します。

var data = new Data { value = 0 };

// 参照型変数のloggerに値型を渡すためBoxingが発生
ILogger logger = data

interfaceの変数もclass, delegate同様参照型として扱われます。

docs.microsoft.com

interfaceを継承している構造体であって、参照型にするにはBoxingを行って参照型に変換してやる必要があります。

※「なぜBoxingが必要なのか」を後述しています。

Generic Interfaceによる構造体のBoxingの回避

さて、いよいよ本題です!

先述の「intefaceを引数としたメソッドに渡した場合のBoxing」に限っては回避方法があります!
それはFunc(ILogger logger)メソッドを以下のように書き換えるだけです。

public void Func<T>(T logger) where T : ILogger
{
    // 何もしない
}

ポイントは where T : ILogger の部分で"Generic Interface"と呼ばれるものです。

Generic Interfaceとは

Generic InterfaceはGenericの制約にinterfaceを指定したもののことです。

docs.microsoft.com

上記参考ページでも以下のような記述があり、Generic Interfaceを使うことでBoxingの回避が可能なことがわかります。

The preference for generic classes is to use generic interfaces, such as IComparable<T> rather than IComparable, in order to avoid boxing and unboxing operations on value types.

なぜBoxingが回避されたのか

Generic型の引数は渡されたデータの型になります。

where T : ILoggerとinterfaceの制約が付いたとしてもFunc<T>(T logger)Tはあくまで引数の型、つまり構造体Dataになります。

つまり、「引数Tが値型となり、参照型への変換がそもそも不要のため、Boxingも発生しなかった」ということになります。

逆にFunc(ILogger logger)の場合は引数は参照型で固定のため、値型を渡してしまうとBoxingが発生してしまったということだったんですね。

Generic InterfaceでのBoxing回避はメソッド限定 

この回避方法はメソッドのみ限定で可能です。
後述していますが「メソッドのコールスタック」と「メソッドの引数」は基本的にスタック領域に置かれます。

Generic型に値型を指定した場合、Tは参照型に変換されることなくそのままスタック領域の格納され、 メソッドからはその領域のデータにアクセスされます。
メソッドもその引数もスタック領域に置かれているため、実現できた回避方法と言えそうです。

余談: OpCodes.Constrained Field

さらに細かく話すとGeneric Interfaceの場合、以下の特殊なオペコードが使われるようになりスタック領域のデータが参照されるようです。
気になる方はどうぞ~。

docs.microsoft.com stackoverflow.com

余談:なぜBoxingが必要なのか

Boxingは値型のデータを参照型として扱うために必要な機能と言えます(意図しないBoxingの場合は別ですが)。

参照型(objectなど)と値型(struct)の大きな違いとして、その「データを格納する領域」に違いがあります。

データの格納場所
値型(Value Type) スタック領域(一時領域)、ヒープ領域(永続領域)の両方
参照型(Reference Type) 基本的にヒープ領域(永続領域)のみ
関数のコールタック(余談) スタック領域(一時領域)

スタック領域は一時的、ヒープ領域は永続的にデータを保持します。
そして、Boxingは「一時的なスタック領域にある値型」を「ヒープ領域に移して参照型にする」ために行います。

コードとイメージは以下のような感じです。

// ヒープ領域に確保
var loggerHolder = new LoggerHolder();

// ローカル変数のためスタック領域
var data = new Data() { a = 1f, b = 2f, c = 3f, d = 4f, e = 5f };

// dataのBoxingを行い、ヒープ領域確保、データをコピーして永続化
loggerHolder.logger = data

f:id:tsgcpp:20200822142943j:plain

参照型はいつ参照しても期待するデータにアクセスできないといけません。
interfaceの変数は参照型のため、構造体が対象のinterfaceを継承していたとしても必ずヒープ領域に持っていって参照型にしておく必要があります。

これがBoxingが発生する主理由の1つと言えます。 (他にもスタック領域は急に領域を確保できないからヒープ領域を使うなど有りますが、細かくは割愛しますw)。

参考: Boxing and Unboxing

雑感

1つのGC Allocの回避を紹介するのに、ずいぶんと長文になってしまいました。。。

記事を1週間以上投稿しなかったのは、夏バテが原因だった気がします(言い訳)。

まあ、使命感でやってるわけではないので気長にやっていきます。

皆さんのC#の知識向上につながれば幸いですー。 それでは~。

【Unity】指のポーズをブレンドしてハンドジェスチャーを作ろう

f:id:tsgcpp:20200810193210p:plain

概要

今回は手の指を曲げるアニメーションを組み合わせてハンドジェスチャーの作ってみたいと思います。

UnityにはBlendTreeというアニメーションをブレンドする機能があります。
今回はそのBlendTreeと指のアニメーションを組み合わせることでハンドジェスチャーを実現したいと思います。

動作環境

動作確認可能なプロジェクトを記事の最後に記載します。

使用アセット

ポーズの用意

Blenderでモデルを読み込んで、以下のポーズ(BlenderではActions)を作成。

1ポーズに左右の手両方のキーフレーム登録をオススメします。
後述するAvatar Maskで左右の手のアニメーションを分離できます。

※ポーズの作り方については後述

ポーズの条件

  • 0フレーム目は デフォルトの状態
    • 0フレーム目は必ずデフォルト(曲げる前)の状態のキーフレームを登録すること
  • 1フレーム目は 対象の指を曲げた状態
    • 例えば "IndexFingerClosed" であれば、人差し指のみ曲げた状態

用意するポーズ

  • HandOpened (いわゆるパーのポーズ、デフォルトのポーズ)
  • ThumbClosed (親指のみ閉じたポーズ)
  • IndexFingerClosed (人差し指のみ閉じたポーズ)
  • MiddleFingerClosed (中指のみ閉じたポーズ)
  • RingFingerClosed (薬指のみ閉じたポーズ)
  • PinkyFingerClosed (子指のみ閉じたポーズ)

HandOpened

手がパーのポーズ、いわゆるデフォルトポーズ

f:id:tsgcpp:20200810155530p:plain

ThumbClosed, FingerClosed

5本の指それぞれを個別に閉じている状態のポーズを用意。

f:id:tsgcpp:20200810155651p:plain

※親指、中指、薬指、小指は省略

ポーズの簡単な作り方

Blenderをある程度触ったことがある人向けです。

以下の流れで、それぞれの指のポーズを作成できます。

  • 初めにグーのポーズを作る
  • グーのポーズから対象の指以外をデフォルトに戻す

グーから作ることによって、すべての指のポーズをブレンドした時にきれいなグーになると思います。

パーからグーを作る方法

Blender グーから一部の指をデフォルトに戻す

アニメーションをExport

以下の手順でBlenderからFbxを算出

  • Export対象のモデルを選択
  • File -> Export -> FBX(.fbx)
  • "Selected Objects"を有効にし、"Object Types"を"Armature"のみする
    • 今回はスケルトンポーズ(アニメーション)のみを出力したいため、メッシュは除外
  • Export FBX

f:id:tsgcpp:20200810135627j:plain

UnityでFBXをImport

HumanoidとしてImportして、AnimationをHumanoid向けに変換しましょう。

  • "Animation Type" を "Humanoid"に変更
  • "Apply"

f:id:tsgcpp:20200810143446j:plain

非ループ化

Loop Timeなどは無効のままにしてください。
ブレンド中におかしくなってしまいます。

Animation Clipから不要なキーフレームを削除

必須ではありませんが、HumanoidでImportするとすべてのMuscleのキーフレームが登録されてしまいます。
Animation Clipのサイズが削減できるので不要なキーフレームは削除しておきましょう。

  • HandOpened は指以外のキーフレームを削除
  • 各FingerClosedは対象の指以外のキーフレームを削除

f:id:tsgcpp:20200810174348j:plain
HandOpened のキーフレーム(修正後)

f:id:tsgcpp:20200810174438j:plain
IndexFingerClosed のキーフレーム(修正後)

Avatar Mask の作成

UnityではAvatar Maskを使ってアニメーションの適用範囲をマスクで制限することができます。

  • "Project"上で右クリック -> "Create" -> "Avatar Mask"
  • アニメーションはループさせない(Loop Time チェックボックスを オフ)
    • プレイ中に大変なことになります
  • 左右の手それぞれの Avatar Mask を作成

f:id:tsgcpp:20200810144122j:plain
左手用 Avatar Mask
f:id:tsgcpp:20200810144148j:plain
右手用 Avatar Mask

AnimatorControllerを作成

前置きが長かったですがいよいよ本題です。
これまで用意したAnimation(ポーズ)をブレンドするAnimationControllerを用意して、ハンドジェスチャーを作っていきます。

ブレンドの方針

  • AnimatorControllerのレイヤーを使用
  • "Blending" を "Additive" にし、ベースポーズからの加算によってポーズの組み合わせを実現

f:id:tsgcpp:20200810150053j:plain

簡単に言えば"パー" から"指を曲げるポーズ"を加算することで、ジェスチャーを実現する方針になります。

ベースポーズ用レイヤーを作成

  • レイヤーを追加
  • Weightを1, Maskに左手用Avatar Mask, BlendingをOverrideに設定
  • DefaultStateのアニメーションに "HandOpened" を指定

これでBase Layerの左手のアニメーションが常にパーに上書きされる形になります。

f:id:tsgcpp:20200810150734j:plain

加算用レイヤーを作成

レイヤーの作成

  • レイヤーを追加
  • Weightを1, Maskに左手用Avatar Mask, BlendingをAdditiveに設定
  • DefaultStateにBlendTreeを指定

f:id:tsgcpp:20200810151509j:plain

BlendTreeの設定

  • ParametersにCloseThumb_L, CloseIndex_L... の様に左手の指ごとのパラメータをfloatで用意
    • 右手の指の場合はCloseThumb_R, CloseIndex_R...
  • "BlendType" に "Direct" を指定
  • Motionを5つ追加し、各指のアニメーションとパラメータを指定

f:id:tsgcpp:20200810152457j:plain

動作確認

それでは作ったAnimatorControllerをユニティちゃんのAnimatorに入れて動作確認してみましょう!

www.youtube.com

うまくいきました!

Additive Reference Pose について

Blenderでアニメーションを作る際、「0フレーム目は デフォルトの状態」という条件を加えました。 実はこれ、"Additive Reference Pose"というものが関係しています。

Additive Reference Pose とは

平たく言えば「Additive(レイヤー)で使用する際にベースとなるポーズ」のことです。

残念ながら"Additive Reference Pose"の明確なドキュメントは見つかりませんでした。。。(知っている方いらっしゃいましたら教えてください!!!)

デフォルトのAdditive Reference Pose

AnimationUtility.SetAdditiveReferencePoseのページにこんな説明があります。

By default any animation clip used in an additive layer use the pose at time 0 to define the reference pose

つまり、"Additive Reference Pose"はデフォルトではそのAnimation Clipの0フレーム目のポーズになるということです。

これが 「0フレーム目は デフォルトの状態」という条件にした最大の理由になります。

余談:Additive Reference Pose を明示的に指定する方法

一応、Additive Reference Pose は明示的に指定する方法があります。

ただ、どれも扱いやすいとは言えないと個人的には思います。。。
ブレンド前提のAnimation Clip は素直にベースのポーズを0フレーム目に指定したほうが良いと思います。

FBXのImport時に指定

以下の方法を使用すると、同一Animation Clip内で0フレーム目以外を"Additive Reference Pose"として指定できます。

  • FBXをInspectorで確認し、"Animation"タブを開く
  • 下部に"Additive Reference Pose" のチェックボックスをオンにする
  • Pose Frameを指定

f:id:tsgcpp:20200810165228j:plain

Debug Inspectorで指定

実はDebug Inspectorにすると、Animation ClipのInspectorに"Additive Reference Pose"を指定する欄が表示されます。

  • InspectorをDebug化
  • "Additive Reference Pose Clip" と "Additive Reference Pose Time" を指定
  • "Has Additive Reference Pose"のチェックボックスをオン
    • オンにしていないと"Additive Reference Pose"が有効になりません

f:id:tsgcpp:20200810170930j:plain

AnimationUtility.SetAdditiveReferencePoseを使って指定

※個人的には非推奨

AnimationUtility.SetAdditiveReferencePose を使うことで指定できます。
別のAnimation Clipも指定可能です。

AnimationUtility.SetAdditiveReferencePose(targetClip, referenceClip, poseTime);

メソッド呼び出しの結果を確認したい場合は、前述したDebug Inspectorで確認して下さい。

また、以下のようなツールも作ってみました(プロジェクトにいれています)。
複数のAnimationClipでまとめて"Additive Reference Pose"を指定できます。

f:id:tsgcpp:20200810165859j:plain

ツールの注意点

Set/Reset を繰り返すと以下のようなエラーを吐くことがあります。
こうなるとUnityを再起動しないとうまく動作しません。。。

f:id:tsgcpp:20200810170254j:plain

原因不明なので何か知っている人は良かったら教えてください。

サンプルプロジェクト

github.com

雑感

今回はBlender, Animator, Animation Clip, Additive Reference Poseと詰め込みすぎ感がありますねw。
有料アセット使えばUnity内で完結できたと思うんですが、無料でできるようにしたかったのでBlenderも入れてみました。

今回の記事の経緯は手のアニメーション作ってるときに「あれ、これアニメーションの組み合わせでできんじゃね?」と思ったことですね。

Animationの用意やAnimatorの調整など、面倒な部分もありますがモデルに適したAnimation Clipを使えることがメリットでしょうか。
AnimatorControllerOverrideを使えばモデルごとにAnimationClipを設定することも可能です。

"Additive Reference Pose"についてはいいドキュメントが見つからなかったので調べるのがちょっと大変でした。

今のところ手のジェスチャー以外にいい組み合わせは思いつかないですが、 何かよさげな応用例があれば教えてください!

それでは~。

【UniRx】UniRxのDelayFrameが初回だけ1フレーム余計に遅延する問題と解決方法

概要

UniRxでストリームを遅延させるDelayFrameを使用した場合に初回だけOnNextが1フレーム余計に遅延する現象がありました。
今回はその現象と解決方法を紹介したいと思います。

UniRxについて

github.com

Unityで使用可能なReactive Extensionsライブラリ。

Reactive Extensionsについては今回は詳しく触れませんが、
独特のクセがあり慣れない人には最初戸惑うと思いますが、使えるといろいろと捗ります!!!

DelayFrameの問題について

現象を詳しく説明すると

  • Subject.DelayFrame(N)によりNフレーム遅延するSubject(Observable)を生成
  • 上記SubjectをSubscribeする
  • Subject.OnNext(...)により値を流す
  • 初回のOnNextのみ「N+1」フレーム遅延して値が流れてくる
    • 2回目以降のOnNextは指定通り「N」フレーム遅延

現象の確認方法

以下のコードを使用してSubscribe, OnNextを意図的に発生させて確認

DelayFrame(1)で1フレーム遅延を指定しています。

f:id:tsgcpp:20200806215443j:plain

上記コードをA -> Z -> Z と入力した時の結果です。
Z入力が1021、OnNextがコールされたのが1023 と2フレームの差がついています。

問題の原因

簡単に言うと

  • UniRxは裏側でUniRx専用のDispatcher(スレッドの処理を管理するオブジェクト)を使用
  • そのDispatcher生成は遅延処理などが必要になったときに実施
  • Dispatcherの仕事は生成したフレームの次フレームから行われる

簡単に言えばこのような動きになるため、初回のみ1フレームずれます

詳細に言うと

  • 初めてDelayFrameなどの遅延処理が発生した時にMainThreadDispatcherというオブジェクトが生成される
    • Hierarchyで確認可能(DontDestroyOnLoad以下に存在)
    • アプリケーション終了まで存続
  • DelayFrameなどの遅延処理が初めて発生した時にMainThreadDispatcher.StartCoroutineがコールされる
    • UniRxのDispatcherはMonoBehaviourのコルーチンを使用して実現している
  • コルーチンはwhile(true)yield return nullRun()のループ処理を行う
    • Run()内で遅延処理、および遅延後のOnNextをコールする
    • yield return null -> Run() の順番でループする(コードは こちら)

StartCoroutineした直後にyield return nullがあり、
次のフレームから遅延処理が始まるため、
初回のみ1フレーム遅れる現象が起きます。

バグではないの?

バグではあると思います。

ですが、個人的には
「制限事項 」としてとらえたほうが良いと思います。

制限事項とする理由

これはUniRxなりの気遣いの結果、生まれてしまった問題だと思ったからです。
コードを読むと「遅延処理を必要としないならDispatcherは生成しない」という設計が見て取れます。

また、「初回だけなら気にしない」もしくは「1フレームぐらいずれてもいい」という場合にも問題にはなりません。

そして、後述しますが問題の回避が簡単です。

余談:修正できないの?

難しいと思います。

というのもUniRxという使われる側からすると初回のDelayFrameなどが「どこからコールされるかわからない」からです。

コルーチンを使っている関係で、FixedUpdate, Update, LateUPdateなど、 どこから呼ばれるかによって初回の処理のみ差異が生まれてしまいます。

だったら初回のみ特別扱いせず、1フレーム後からRun()を開始したほうがよほど合理的だと思います。

問題の回避

この問題、結構簡単に解決できます。

Dispatcherの生成を真っ先に行えば良いだけです。
以下に紹介するMonoBehaviourをオブジェクトにアタッチして初期シーンに配置すれば問題を回避できます。

方法1. MainThreadDispatcher.Initialize()をコール

見た目の通りMainThreadのDispatcherを初期化します。
コール時にStartCoroutineも実施されるため、DelayFrameする前に初期化を完了できます。

using UnityEngine;
using UniRx;

public class UniRxThreadStarter : MonoBehaviour
{
    void Start()
    {
        MainThreadDispatcher.Initialize();
    }
}

方法2. Scheduler.SetDefaultForUnity()をコール

※個人的には非推奨

これも結果としてMainThreadのDispatcherを初期化することになります。
こちらはもっと広い範囲でスレッド周りの初期化を行います。
主にUnitTestで使われているようです。

ただ、注意点としてWebGLだとおかしくなるかもしれません。
こちらのような記述があるのですが、上記メソッドだとそれに反するスレッドが使用されてしまいます。

using UnityEngine;
using UniRx;

public class UniRxThreadStarter : MonoBehaviour
{
    void Start()
    {
        Scheduler.SetDefaultForUnity();
    }
}

方法3. Scheduler, Dispatcherが初期化されるPropertyを呼び出し

以下のPropertyは初回呼び出し時にScheduler, Dispatcherが初期化されるようになっています。
予めいろいろな初期化を済ませておきたい人にはオススメのやり方かなと思います。

using UnityEngine;
using UniRx;

public class UniRxThreadStarter : MonoBehaviour
{
    void Start()
    {
        // Propertyの初期呼び出しで初期化が実施される
        var immediate = Scheduler.Immediate;
        var currentThread = Scheduler.CurrentThread;
        var mainThread = Scheduler.MainThread;
        var threadPool = Scheduler.ThreadPool;
    }
}

回避策の結果

f:id:tsgcpp:20200807210550j:plain

初回のOnNextも2回目以降と同様に指定通りの遅延になりました。

余談2:起動したコルーチンはどうなるの?

少なくともUniRx v7.1.0 時点ではアプリケーションが終了するまで残り続けます。
StopCoroutineなどコルーチンを止める処理も見当たりませんでした。

再起動がないなら割り切ってさっさと起動させてしまうのも手かなと思います。

雑感

今回はUniRxを取り上げてみました。

個人的には厳密なフレーム単位の遅延を考えるのが好きだったりするので、今回の現象は興味深くもありすごく気になる部分でもありました。

初期化処理はなんだかんだ特殊な処理が生まれるので、
UniRxにも諸々の初期化を実施してくれるメソッドがあってもいいかなと思ったりしました。

何にしろこれからもお世話になるライブラリかなと思います!(最近更新されてないのが気になりますが)

それでは~

【Unity】Physics.Raycast のパフォーマンスはシーンに存在するColliderの数に影響するのか

f:id:tsgcpp:20200801155055j:plain

概要

Physics.Raycast のパフォーマンスはシーンに存在するColliderの数に影響するのかを調べてみました。

経緯はRaycastを使用するスクリプトを書いているときに、ふと気になったからです。

動作環境

  • Unity 2020.1.0f1
  • Windows 10 (PC)
    • 同一マシンにて検証
    • CPU + GPUで検証 (スペック詳細は割愛)

Raycastについて

Raycastのアルゴリズムは「Rayの直線とColliderが交わるかを調べる」、そして「Rayの開始位置に近いColliderを選ぶ」ことです。
逆に言えばRayとすべてのColliderを検証する必要があるため、Colliderの数が多ければ多いほどRaycastのパフォーマンスは悪化すると考えられます。

検証の集計に関して

今回は簡易的に調査結果を記載します。

本格的な調査としてのデータが欲しい場合は検証対象を複数回行い、その平均を取ることが望ましいです。

検証

検証方法

  • Profilerを使用してPhysics.Raycastメソッドの時間を計測
  • Physics.Raycastは1サンプリングに10000回行う
    • 1サンプリングに1回だと差異が小さすぎるため
  • Build and Runより検証
    • Playだと同一シーンでも差が出てしまったため

以下はコードのイメージ

Profiler.BeginSample(samplingName);
for (int i = 0; i < 10000; ++i) {
    Physics.Raycast(...);
}
Profiler.EndSample();

f:id:tsgcpp:20200801164632j:plain

使用するCollider

  • CapsuleColliderを使用
  • Rigidbodyを付与、かつuseGravity=false
    • 動的Colliderとして設定

f:id:tsgcpp:20200731224835j:plain

検証1. ボックス状にコライダーを複数配置し、Raycastの時間を検証

以下のようにコライダーとRaycastを配置し、Raycastの時間を検証

  • ボックス状にコライダーを配置
  • 中心付近と8隅のColliderに真っ先に当たるRaycastを用意
    • Colliderの検証順番がワールド座標でソートされている可能性を考慮
  • どのColliderにも当たらないRaycastを用意
  • RaycastのMaxDistance: 10000で固定

f:id:tsgcpp:20200731222025j:plain
ボックス上にColliderを配置

f:id:tsgcpp:20200731224222j:plain
中心付近のColliderへのRaycast

f:id:tsgcpp:20200731224242j:plain
どのColliderにも当たらないRaycast

2 x 2 x 2 合計1000の結果

f:id:tsgcpp:20200801155206j:plain

f:id:tsgcpp:20200801160845j:plain

10 x 10 x 10 合計1000の結果

f:id:tsgcpp:20200801155224j:plain

f:id:tsgcpp:20200801160852j:plain

40 x 40 x 40 合計8000の結果

f:id:tsgcpp:20200801155246j:plain

f:id:tsgcpp:20200801160901j:plain

検証1での考察

40x40x40のパターンは10x10x10の64倍のCollider数の差があるにも関わらず、Timeの増加はそれほど大きくありませんね。 また、特徴的な点として「どのColliderにも当たらないRaycast (Raycast to Empty)」は、他のRaycastと比べるとTimeが短いですね。

Physics.Raycastはoriginに最も近いColliderを返すため、距離でのソート処理が発生すると考えられます。
Rayと交わるColliderが1つもない場合はソート処理が発生しないのでTimeも短くなるのかもしれません。

よって「Raycastでソート処理対象のColliderの数」は「シーンにあるColliderの数」よりさらにパフォーマンスに大きく影響するのかもしれません。

GPU Usageの確認

今回の時間の差異の様子からシングルスレッドではなくマルチスレッドで処理されていると予想しGPU Usageを確認してみました。

f:id:tsgcpp:20200801161050j:plain

Colliderの数の差がはっきりと現れていますね。
GPU側で各ColliderごとにRaycastの並列処理を行っていると思われます。

検証2. ソート対象になるようにColliderを配置し、Raycastの時間を検証

  • Rayの直線状に並ぶColliderの数を変える

f:id:tsgcpp:20200801152218j:plain
検証イメージ

Colliderの数(ソート対象)が100の結果

f:id:tsgcpp:20200801174513j:plain

Colliderの数(ソート対象)が1000の結果

f:id:tsgcpp:20200801174525j:plain

Colliderの数(ソート対象)が10000の結果

f:id:tsgcpp:20200801174533j:plain

検証2の考察

検証結果からRaycast時のソート対象が多いほど、Physics.Raycastにかかる時間が増える傾向にあると思われます。

検証3. MaxDistanceでソート対象が絞られるようにRaycastを設定し、Raycastの時間を検証

  • MaxDistanceを変える
  • Rayの直線状に並ぶColliderの数は一定
    • 必ずMaxDistanceの範囲を超えて生成されるように配置
    • マージン: 5, Collider数: 10000

f:id:tsgcpp:20200801161436j:plain
検証イメージ

MaxDistance: 100(ソート対象は約20)

f:id:tsgcpp:20200801173053j:plain

MaxDistance: 1000(ソート対象は約200)

f:id:tsgcpp:20200801173502j:plain

MaxDistance: 10000(ソート対象は約2000)

f:id:tsgcpp:20200801173112j:plain

検証3の考察

ソート対象の数に比例してTimeの増加しているため、やはりソート対象が多いとPhysics.Raycastにかかる時間が増えると思われます。

調査結果のまとめ考察

※PCの場合での考察

  • Physics.Raycastの時間は以下のような傾向がある
    • シーンに存在するColliderの数に少し依存する
    • originに最も近いColliderを算出する際のソート対象数に大きく依存する
    • ソート対象なしの場合、早く終了する
  • Physics.Raycast1回のコストはそれほど大きくない
    • 今回の検証が10000回の合計であることを考えると、1回あたりの時間は大きくないと考える
  • Physics.RaycastGPUのコストはシーンに存在するCollider数に依存

余談:謎の高負荷が起きる現象

以下のようにソート対象がないにも関わらず高負荷になるパターンがありました。
調査してみましたが残念ながら原因は不明です。。。

現象が起きるパターンは以下の画像のようにRaycastをする場合です。
Profilerでは「Raycaster maxDistance 1000.0 Ex」という名前で表示されています。

f:id:tsgcpp:20200801181433j:plain

f:id:tsgcpp:20200801181447j:plain

うまく説明はできませんが、発生する条件は以下のような感じです。

  • Collider群のうち、X座標がColliderたちの中で端よりの内側
    • 逆に外側だとソート対象なしと同様になります。。。
  • ColliderとColliderの間にRayの直線が通るようにRaycast
  • 1つでもColliderにぶつかるようにすると解消される
    • 「Raycaster maxDistance 1000.0 Ex」の目の前にColliderを配置するなど

バグなのかどうかは不明ですが、判明したことがあれば共有します。

今後やってみたいこと

  • 上記謎現象の原因解明
  • TerrainなどのMeshColliderの場合での検証
  • プラットフォーム毎のPhysics.Raycastの差異

今回使用したプロジェクト

github.com

雑談

Raycastのパフォーマンスが気になって調査してみましたが、よく言われているみたいに時間的には低コストみたいで安心しました。
RaycastのアルゴリズムGPU向け処理などの最適化を裏側でやってくれているので、Physics.Raycastのありがたさを改めて実感した気がします。

ただ、Oculus Questなどのモバイル端末だと変わってきそうなので、日を見て調査してみようと思います。

また、まだ謎な部分もあるのでもし何かご存知の方がいらっしゃいましたら共有いただけると嬉しいです!

それでは~