【Moq】UnityでのMoq導入方法 と MoqのTips集を紹介!
Unity 2021以降の場合は以下をどうぞ! tsgcpp.hateblo.jp
概要
今回は Moq というC#向けモックライブラリをUnityに導入する方法と、Moqを使ったテクニックをいくつか紹介しようと思います。
UnitTestにおいてモック化はかなり重要な立ち位置になると思いますが、自作モックは何かと保守コストが高かったり機能面が微妙になったりと悩みどころがあります。
そこでオススメなのが今回紹介する Moq となります。
他の言語のモックライブラリ同様、モック化だけでなくSpy機能(メソッドがコールされたかの確認機能)も結構充実していますので、
ぜひUnity もしくは C# のUnitTestに活かしていきましょう!
今回の記事はある程度UnitTestを知っていることが前提となりますが、UnitTestのサンプルコードにもできる限り説明を入れています。
チートシートにもなっていると思いますのでよかったら参考にしてください。
Moqの導入
Github
※今回はソースコードを直接取り込みません
MoqのソースコードはC# 8.0で書かれており、現在のLTSであるUnity 2019.4 ではコンパイルできません。
よって、後述するコンパイル済みのdllをダウンロードして使用することになります!
Moqの依存ライブラリとそのライセンスについて
使用する前に必ずライセンスを確認してください(特に業務で使用する場合は要注意です!)
- Moq: BSD 3-Clause License
- Castle.Core: Apache License Version 2.0
- Moqが依存するライブラリ
- System.Threading.Tasks.Extensions: MIT
- Moqが依存するライブラリ (Microsoft 提供)
- System.Runtime.CompilerServices.Unsafe: MIT
- System.Threading.Tasks.Extensionsが依存するライブラリ
上記4つを取り込む必要があります。
MoqをUnityに導入する方法
Moqおよび依存ライブラリのダウンロード
今回はMoqと依存ライブラリ3つをNugetからダウンロードしましょう。
以下の用に"Download package"からnupkgをダウンロード可能です。
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は再頒布するとラインセス表記が必要なります。
特に Moq
と Castle.Core
は実行ファイルには不要なのでInclude Platforms
をEditor
のみにしておきましょう!
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)の場合、FuncVirtual
とFuncAbstract
はモック化できますが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());
in
やref
が指定されている場合は以下の 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));
上記のCallback
はLog
メソッドに渡された引数をコールバックにより取り出すことが可能になります。
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のコードをご確認ください。
大まかに言うと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に使用する題材にしてみました。
サンプルプロジェクト
雑感
1ヶ月半ぶりの投稿です。
ネタはいっぱいあるんですが、今やってることに熱中しちゃってなかなかブログを書けませんでしたw。
次こそは3DCG系のネタをやりたいかなと思います。
なんかブログを出すこと自体が目的になってるようなとこがあるので、焦らずやっていこうかなと思います。
それでは~
【Extenject】AsTransient, AsCached, AsSingle を使い分けよう!
概要
今回はZenjectのインスタンス生成の Scope である AsTransient
, AsCached
, AsSingle
の使い分けについて解説します。
この3つは生成されるインスタンスに明確なルール付けをすることができるので、上手に利用してあげてください!
例によって記事の最後にサンプルプロジェクトを置いておきます。
動作環境
- Unity 2019.4.12f1
- Extenject 9.2.0
- 例によって現在保守されているExtenjectの方を使用
関連
以前の記事です。 よかったらどうぞ!
インストール方法やテストについて紹介しています。
前知識
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つの場合は不要です。
以下のテストコードで動作確認してます。
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先には影響しないことになります。
以下のような用途に使えます。
- メモリ使用量を気にしないからとりあえずDIで挙動を変更したい
- DI先ごとに別のインスタンスとして持たせたい(他に影響させたくない)
AsTransient の 利用例
DI先にはICollection
として使用してもらい、バインドでList
や LinkedList
などを切り替えるなどの使用方法が考えられます。
// 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ごとに異なるインスタンスとなります。
インスタンスの単位は細かくは以下のようになります。
- 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"); } }
メッセージキューをグループで別インスタンスとして管理することが可能になります。
AsSingle
AsSingle
は ResultTypeのインスタンスが単一であることを保証する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でバインドしています。
よかったら動かしてみてください。
簡単なまとめ
AsTransient
は挙動は変えたいけど、インスタンスは共有したくないときAsCached
はIDグループ毎にインスタンスを共有したいときAsSingle
はResultTypeのインスタンスを必ず1つだけにしたいとき
に使うといい気がします(超大雑把)
サンプルプロジェクト
雑感
久々の記事投稿です。
ポートフォリオに集中してたんですが、さすがにネタが溜まりすぎて来たので簡単なやつを1つでも解消したいと思い記事にしました。
Zenjectは使ってて本当に気持ちいいですね~。
Zenjectを使ってて思ったメリットとして 「UnityでDIが実現できる」だけじゃなくて「UnityでMonoBehaviour以外のクラスが使いやすくなる」っていうのもあると思います! 疎結合にできるライブラリですが、自分自身はZenjectに密結合になっちゃってますね~。
それでは良いUnityライフを!
【Unity】Shader Graphで走査線っぽいもの
概要
今回は Shader Graph で走査線っぽいもの作り方を紹介します。 併せてシェーダーを作るときの考え方も解説したいと思います!
動作環境
- Unity 2020.1.4f1
- Universal RP 8.2.0
今回作るもの
以下の動画の様にスクロールする走査線を作ります。
実装機能
せっかくのシェーダなので、パラメータで以下のものを調整できるようにしましょう。
- 走査線の色
- 走査線の幅
- 走査線の速度
- 走査線の出現頻度
- UV座標のV方向に走査線が移動(理由は後述)
シェーダーテクニック
シェーダーを実装する前にいくつかテクニックを紹介します。
UVノード と 分離(Split)
UVノード は文字通りUV座標を取り出すことができます。
R要素にはU座標、G要素にはV座標が含まれています。
UV座標は[0, 1]の範囲のため、Splitノードで要素をU, Vに分離することでグレースケールのグラデーションを作ることができます。
V要素に関して言えば下方(0の方)が黒、上方(1の方)が白というグレースケールを取り出すことができます。
Fraction
Fractionノード というものがあります。
「入力数値の小数点を取り出す」ノードとなります。
グラフで表すと以下のような一次関数を並べた感じになります。
簡単に言うと 0 から 1 (厳密には0.999...) を繰り返すノードを作成することができます。
Time + UV + Split + Fraction
所謂「UVスクロール」です。
UVをSplitノードで分離することでU or V方向にスクロールするノードとなります。
また、AddノードとSubtractノードでスクロール方向が反転する性質があります。
三角波
所謂「三角波ノード」です。
このノード、見た目はシンプルですが 「走査線の幅」の調整とかなり相性が良かったりします。
Fractionを[0, 1] → [-1, 1]に変換して絶対値を取ると、0 → 1 → 0...を繰り返す三角波を実現できます。
三角波 + Step
先程作成した三角波ノードをStepに渡すと以下のようなノードになります。
何が言いたいかというと「波の幅をパラメータで設定しやすい」ということです!
三角波 + Smoothstep
今回の主役ノードです!
三角波はSmoothstepとも相性が良いです。
グラデーション付きの波をシームレスに作ることができます。
余談:Time + Fraction + Smoothstep
stepをであれば"Time + fraction"で十分ですが、 Smoothstepの場合は残念ながらシームレスになりません。
三角波を作った理由は「シームレスな波が必要」だったからなんですね。
波の出現の周期を変える
これはUVスクロールの値を周期数で割り、StepのEdgeも同様に周期数で割れば実現できます。
※UVスクロールの値だけだと走査線になる部分の幅が広がってしまいますので、幅も割って調整します。
UVスクロールのスピードを遅らせるということですね。
完成形
上記のテクニックを組み合わせて、プロパティで調整可能にしてアルファ(不透明度)に使用したのが今回のシェーダになります!
Githubのプロジェクトに完成形を置いておきますので、良かったらどうぞ。
サンプルプロジェクト
雑感
今回はShader Graphを題材にしてみました。
記事を書いていて思ったのは「これ、音声解説 + 動画のほうが絶対わかりやすいだろ」でした。。。
別にAviutl使えないわけじゃないんですがちょっとトークに自信がないです。。。
ただ動画のほうが情報をたくさん詰め込めますし、僕みたいに字だとわからなくても動画だと理解しやすい人のほうが多いと思いますのでいつか挑戦してみたいです。
次回ですが、そろそろ以前から考えていたポートフォリオに本格的に集中しようと思いますので、 しばらくは投稿頻度は下がると思います。
それでは~。
【C#】え、Generic Interfaceでメソッド引数を設定すれば構造体のBoxingを回避できるの?
- 概要
- Constraints on type parameters について
- interfaceを継承した構造体のboxing
- Generic Interfaceによる構造体のBoxingの回避
- 余談:なぜBoxingが必要なのか
- 雑感
どうも、最近GC Allocにおびえているすぎしーです。
今日はUnityじゃなくてC#がメインの話題です。
概要
今回はGeneric Interfaceと値型のBoxing回避についてお話します!
Boxingは余計なGC Allocが発生してしまいますが、回避が難しいパターンも存在します。
ただ、一見変わった方法でそのBoxingを回避する方法があったので紹介したいと思います!
Constraints on type parameters について
要するにGenericで指定される型に制約を付けられるC#の機能のことです。
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同様参照型として扱われます。
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を指定したもののことです。
上記参考ページでも以下のような記述があり、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
参照型はいつ参照しても期待するデータにアクセスできないといけません。
interfaceの変数は参照型のため、構造体が対象のinterfaceを継承していたとしても必ずヒープ領域に持っていって参照型にしておく必要があります。
これがBoxingが発生する主理由の1つと言えます。 (他にもスタック領域は急に領域を確保できないからヒープ領域を使うなど有りますが、細かくは割愛しますw)。
雑感
1つのGC Allocの回避を紹介するのに、ずいぶんと長文になってしまいました。。。
記事を1週間以上投稿しなかったのは、夏バテが原因だった気がします(言い訳)。
まあ、使命感でやってるわけではないので気長にやっていきます。
皆さんのC#の知識向上につながれば幸いですー。 それでは~。
【Unity】指のポーズをブレンドしてハンドジェスチャーを作ろう
- 概要
- 動作環境
- 使用アセット
- ポーズの用意
- ポーズの簡単な作り方
- UnityでFBXをImport
- Avatar Mask の作成
- AnimatorControllerを作成
- 動作確認
- Additive Reference Pose について
- サンプルプロジェクト
- 雑感
概要
今回は手の指を曲げるアニメーションを組み合わせてハンドジェスチャーの作ってみたいと思います。
UnityにはBlendTreeというアニメーションをブレンドする機能があります。
今回はそのBlendTreeと指のアニメーションを組み合わせることでハンドジェスチャーを実現したいと思います。
動作環境
- Unity 2020.1.1f1
- Blender 2.83
動作確認可能なプロジェクトを記事の最後に記載します。
使用アセット
ポーズの用意
Blenderでモデルを読み込んで、以下のポーズ(BlenderではActions)を作成。
1ポーズに左右の手両方のキーフレーム登録をオススメします。。
後述するAvatar Maskで左右の手のアニメーションを分離できます。
※ポーズの作り方については後述
ポーズの条件
- 0フレーム目は デフォルトの状態
- 0フレーム目は必ずデフォルト(曲げる前)の状態のキーフレームを登録すること
- 1フレーム目は 対象の指を曲げた状態
- 例えば "IndexFingerClosed" であれば、人差し指のみ曲げた状態
用意するポーズ
- HandOpened (いわゆるパーのポーズ、デフォルトのポーズ)
- ThumbClosed (親指のみ閉じたポーズ)
- IndexFingerClosed (人差し指のみ閉じたポーズ)
- MiddleFingerClosed (中指のみ閉じたポーズ)
- RingFingerClosed (薬指のみ閉じたポーズ)
- PinkyFingerClosed (子指のみ閉じたポーズ)
HandOpened
手がパーのポーズ、いわゆるデフォルトポーズ
ThumbClosed, FingerClosed
5本の指それぞれを個別に閉じている状態のポーズを用意。
※親指、中指、薬指、小指は省略
ポーズの簡単な作り方
※Blenderをある程度触ったことがある人向けです。
以下の流れで、それぞれの指のポーズを作成できます。
- 初めにグーのポーズを作る
- グーのポーズから対象の指以外をデフォルトに戻す
グーから作ることによって、すべての指のポーズをブレンドした時にきれいなグーになると思います。
パーからグーを作る方法
Blender グーから一部の指をデフォルトに戻す
アニメーションをExport
以下の手順でBlenderからFbxを算出
- Export対象のモデルを選択
- File -> Export -> FBX(.fbx)
- "Selected Objects"を有効にし、"Object Types"を"Armature"のみする
- 今回はスケルトンポーズ(アニメーション)のみを出力したいため、メッシュは除外
- Export FBX
UnityでFBXをImport
HumanoidとしてImportして、AnimationをHumanoid向けに変換しましょう。
- "Animation Type" を "Humanoid"に変更
- "Apply"
非ループ化
Loop Timeなどは無効のままにしてください。
ブレンド中におかしくなってしまいます。
Animation Clipから不要なキーフレームを削除
必須ではありませんが、HumanoidでImportするとすべてのMuscleのキーフレームが登録されてしまいます。
Animation Clipのサイズが削減できるので不要なキーフレームは削除しておきましょう。
- HandOpened は指以外のキーフレームを削除
- 各FingerClosedは対象の指以外のキーフレームを削除
Avatar Mask の作成
UnityではAvatar Maskを使ってアニメーションの適用範囲をマスクで制限することができます。
- "Project"上で右クリック -> "Create" -> "Avatar Mask"
- アニメーションはループさせない(Loop Time チェックボックスを オフ)
- プレイ中に大変なことになります
- 左右の手それぞれの Avatar Mask を作成
AnimatorControllerを作成
前置きが長かったですがいよいよ本題です。
これまで用意したAnimation(ポーズ)をブレンドするAnimationControllerを用意して、ハンドジェスチャーを作っていきます。
ブレンドの方針
- AnimatorControllerのレイヤーを使用
- "Blending" を "Additive" にし、ベースポーズからの加算によってポーズの組み合わせを実現
簡単に言えば"パー" から"指を曲げるポーズ"を加算することで、ジェスチャーを実現する方針になります。
ベースポーズ用レイヤーを作成
- レイヤーを追加
- Weightを1, Maskに左手用Avatar Mask, BlendingをOverrideに設定
- DefaultStateのアニメーションに "HandOpened" を指定
これでBase Layerの左手のアニメーションが常にパーに上書きされる形になります。
加算用レイヤーを作成
レイヤーの作成
- レイヤーを追加
- Weightを1, Maskに左手用Avatar Mask, BlendingをAdditiveに設定
- DefaultStateにBlendTreeを指定
BlendTreeの設定
- Parametersに
CloseThumb_L
,CloseIndex_L
... の様に左手の指ごとのパラメータをfloatで用意- 右手の指の場合は
CloseThumb_R
,CloseIndex_R
...
- 右手の指の場合は
- "BlendType" に "Direct" を指定
- Motionを5つ追加し、各指のアニメーションとパラメータを指定
動作確認
それでは作ったAnimatorControllerをユニティちゃんのAnimatorに入れて動作確認してみましょう!
うまくいきました!
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を指定
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"が有効になりません
AnimationUtility.SetAdditiveReferencePoseを使って指定
※個人的には非推奨
AnimationUtility.SetAdditiveReferencePose を使うことで指定できます。
別のAnimation Clipも指定可能です。
AnimationUtility.SetAdditiveReferencePose(targetClip, referenceClip, poseTime);
メソッド呼び出しの結果を確認したい場合は、前述したDebug Inspectorで確認して下さい。
また、以下のようなツールも作ってみました(プロジェクトにいれています)。
複数のAnimationClipでまとめて"Additive Reference Pose"を指定できます。
ツールの注意点
Set/Reset を繰り返すと以下のようなエラーを吐くことがあります。
こうなるとUnityを再起動しないとうまく動作しません。。。
原因不明なので何か知っている人は良かったら教えてください。
サンプルプロジェクト
雑感
今回は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について
Unityで使用可能なReactive Extensionsライブラリ。
Reactive Extensionsについては今回は詳しく触れませんが、
独特のクセがあり慣れない人には最初戸惑うと思いますが、使えるといろいろと捗ります!!!
DelayFrameの問題について
現象を詳しく説明すると
Subject.DelayFrame(N)
によりNフレーム遅延するSubject(Observable)を生成- 上記SubjectをSubscribeする
Subject.OnNext(...)
により値を流す- 初回の
OnNext
のみ「N+1」フレーム遅延して値が流れてくる- 2回目以降の
OnNext
は指定通り「N」フレーム遅延
- 2回目以降の
現象の確認方法
以下のコードを使用してSubscribe, OnNextを意図的に発生させて確認
DelayFrame(1)
で1フレーム遅延を指定しています。
上記コードをA -> Z -> Z と入力した時の結果です。
Z入力が1021、OnNextがコールされたのが1023 と2フレームの差がついています。
問題の原因
簡単に言うと
- UniRxは裏側でUniRx専用のDispatcher(スレッドの処理を管理するオブジェクト)を使用
- そのDispatcher生成は遅延処理などが必要になったときに実施
- Dispatcherの仕事は生成したフレームの次フレームから行われる
簡単に言えばこのような動きになるため、初回のみ1フレームずれます
詳細に言うと
- 初めて
DelayFrame
などの遅延処理が発生した時にMainThreadDispatcher
というオブジェクトが生成される- Hierarchyで確認可能(DontDestroyOnLoad以下に存在)
- アプリケーション終了まで存続
DelayFrame
などの遅延処理が初めて発生した時にMainThreadDispatcher.StartCoroutine
がコールされる- UniRxのDispatcherは
MonoBehaviour
のコルーチンを使用して実現している
- UniRxのDispatcherは
- コルーチンは
while(true)
とyield return null
でRun()
のループ処理を行う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; } }
回避策の結果
初回のOnNext
も2回目以降と同様に指定通りの遅延になりました。
余談2:起動したコルーチンはどうなるの?
少なくともUniRx v7.1.0 時点ではアプリケーションが終了するまで残り続けます。
StopCoroutine
などコルーチンを止める処理も見当たりませんでした。
再起動がないなら割り切ってさっさと起動させてしまうのも手かなと思います。
雑感
今回はUniRxを取り上げてみました。
個人的には厳密なフレーム単位の遅延を考えるのが好きだったりするので、今回の現象は興味深くもありすごく気になる部分でもありました。
初期化処理はなんだかんだ特殊な処理が生まれるので、
UniRxにも諸々の初期化を実施してくれるメソッドがあってもいいかなと思ったりしました。
何にしろこれからもお世話になるライブラリかなと思います!(最近更新されてないのが気になりますが)
それでは~
【Unity】Physics.Raycast のパフォーマンスはシーンに存在するColliderの数に影響するのか
- 概要
- 動作環境
- Raycastについて
- 検証の集計に関して
- 検証
- 調査結果のまとめ考察
- 余談:謎の高負荷が起きる現象
- 今後やってみたいこと
- 今回使用したプロジェクト
- 雑談
概要
Physics.Raycast のパフォーマンスはシーンに存在するColliderの数に影響するのかを調べてみました。
経緯はRaycastを使用するスクリプトを書いているときに、ふと気になったからです。
動作環境
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();
使用するCollider
CapsuleCollider
を使用Rigidbody
を付与、かつuseGravity=false
- 動的Colliderとして設定
検証1. ボックス状にコライダーを複数配置し、Raycastの時間を検証
以下のようにコライダーとRaycastを配置し、Raycastの時間を検証
- ボックス状にコライダーを配置
- 中心付近と8隅のColliderに真っ先に当たるRaycastを用意
- Colliderの検証順番がワールド座標でソートされている可能性を考慮
- どのColliderにも当たらないRaycastを用意
- RaycastのMaxDistance: 10000で固定
2 x 2 x 2 合計1000の結果
10 x 10 x 10 合計1000の結果
40 x 40 x 40 合計8000の結果
検証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を確認してみました。
Colliderの数の差がはっきりと現れていますね。
GPU側で各ColliderごとにRaycastの並列処理を行っていると思われます。
検証2. ソート対象になるようにColliderを配置し、Raycastの時間を検証
- Rayの直線状に並ぶColliderの数を変える
Colliderの数(ソート対象)が100の結果
Colliderの数(ソート対象)が1000の結果
Colliderの数(ソート対象)が10000の結果
検証2の考察
検証結果からRaycast時のソート対象が多いほど、Physics.Raycast
にかかる時間が増える傾向にあると思われます。
検証3. MaxDistanceでソート対象が絞られるようにRaycastを設定し、Raycastの時間を検証
- MaxDistanceを変える
- Rayの直線状に並ぶColliderの数は一定
- 必ずMaxDistanceの範囲を超えて生成されるように配置
- マージン: 5, Collider数: 10000
MaxDistance: 100(ソート対象は約20)
MaxDistance: 1000(ソート対象は約200)
MaxDistance: 10000(ソート対象は約2000)
検証3の考察
ソート対象の数に比例してTimeの増加しているため、やはりソート対象が多いとPhysics.Raycast
にかかる時間が増えると思われます。
調査結果のまとめ考察
※PCの場合での考察
Physics.Raycast
の時間は以下のような傾向がある- シーンに存在するColliderの数に少し依存する
- originに最も近いColliderを算出する際のソート対象数に大きく依存する
- ソート対象なしの場合、早く終了する
Physics.Raycast
1回のコストはそれほど大きくない- 今回の検証が10000回の合計であることを考えると、1回あたりの時間は大きくないと考える
Physics.Raycast
のGPUのコストはシーンに存在するCollider数に依存
余談:謎の高負荷が起きる現象
以下のようにソート対象がないにも関わらず高負荷になるパターンがありました。
調査してみましたが残念ながら原因は不明です。。。
現象が起きるパターンは以下の画像のようにRaycastをする場合です。
Profilerでは「Raycaster maxDistance 1000.0 Ex」という名前で表示されています。
うまく説明はできませんが、発生する条件は以下のような感じです。
- Collider群のうち、X座標がColliderたちの中で端よりの内側
- 逆に外側だとソート対象なしと同様になります。。。
- ColliderとColliderの間にRayの直線が通るようにRaycast
- 1つでもColliderにぶつかるようにすると解消される
- 「Raycaster maxDistance 1000.0 Ex」の目の前にColliderを配置するなど
バグなのかどうかは不明ですが、判明したことがあれば共有します。
今後やってみたいこと
- 上記謎現象の原因解明
- TerrainなどのMeshColliderの場合での検証
- プラットフォーム毎の
Physics.Raycast
の差異- 特にOculus Quest (Android)
今回使用したプロジェクト
雑談
Raycastのパフォーマンスが気になって調査してみましたが、よく言われているみたいに時間的には低コストみたいで安心しました。
RaycastのアルゴリズムやGPU向け処理などの最適化を裏側でやってくれているので、Physics.Raycast
のありがたさを改めて実感した気がします。
ただ、Oculus Questなどのモバイル端末だと変わってきそうなので、日を見て調査してみようと思います。
また、まだ謎な部分もあるのでもし何かご存知の方がいらっしゃいましたら共有いただけると嬉しいです!
それでは~