すぎしーのXRと3DCG

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

【Unity】Boltなどのビジュアルスクリプティングで気づきにくいミスパターン

f:id:tsgcpp:20200723161652j:plain

概要

Boltが正式に無償化したということで、今回はBoltなどのビジュアルスクリプティングで気を付けたいことを1つ共有したいと思います。
この話はBoltに限らずUE4のBluePrintなどでも当てはまると思います!

Boltについて

Bolt

Boltの参考動画

以下の動画が参考になると思います!

www.youtube.com

補足

Unit (Boltにおけるノード) は <Unit名> で表現します。

以下のSet Variable Unitは <Set Variable> として記載します。

f:id:tsgcpp:20200723162048j:plain

問題

早速ですが私がぶち当たった問題を簡単に表現したグラフを見てみましょう。
以下のグラフには問題があります!

f:id:tsgcpp:20200723145542j:plain

良かったらちょっと考えてみて下さい!
処理の流れとしては以下を想定して実装しています。

  1. TimeCount + deltaTime の結果をTimeCountに格納
  2. Set Variableした値をDebug.Logで出力
  3. やりたいことは更新後の TimeCount の確認

答え

初期変数を以下とすると、ログには何が出力されるでしょうか?

  • TimeCount = 2.96
  • deltaTime = 0.02

答えは 3.00 です!
deltaTimeが2回足されたものになっています!

f:id:tsgcpp:20200723150003j:plain

※理由がわかっている人はこの後の話はつまらないと思いますw

要注意ポイント

おそらくほとんどの人が気にしたと思いますが、以下の赤色と橙色の丸で囲った部分が要注意ポイントです!

f:id:tsgcpp:20200723152046j:plain

Unitは「そのUnitのOutputが要求されたときに、そのUnitが処理してOutputを出力」します。

そして、今回の<Add><Set Variable><Debug Log>の2つからOutputが要求されているため、 1フレームに2回<Add>が動くことになります。

<Add> は処理したときのOutputの値を所持することはなく、要求毎にOutputを出力します。

処理の流れ

1. Set Variableが実施

  • <Set Variable><Add>のOutputを要求
  • <Add>が自身の処理を実施
    • Outputは <Time>deltaTime <Get Variable>TimeCountを取得して加算したもの
  • <Add>がOutputを<Set Variable>に渡す
  • <Set Variable>がOutputをTimeCountに格納

2. Debug.Logが実施

  • <Debug.Log>`のOutputを要求
  • <Add>が自身の処理を実施
    • Outputは <Time>deltaTime<Get Variable>TimeCountを取得して加算したもの
    • この時点で取得されたTimeCountは「1.」でdeltaTimeが加算されたものになっている
  • <Add>がOutputを<Set Variable>に渡す
  • <Debug.Log>がOputputをコンソールに出力

詳細に処理の流れを説明するとこんな感じでしょうか 。

f:id:tsgcpp:20200723161652j:plain

<Add>が2回動く」、「2回目の<Add>が使用するTimeCountが加算済みのものになっている」ことで、ログには2回加算されたTimeCountの値が出力されたんですね。

解決策

Set Variable のOutput から取得

<Set Variable>のOutputは処理した時のものが保持されるようです。

f:id:tsgcpp:20200723153952j:plain

ついでに以下のように、<Set Variable>が保持されているかの確認もしてみました。

変数から直接取得

今回やりたいことは更新されたTimeCountの値を知りたいだけです。
よって単純に変数から取得すれば問題ありません。

f:id:tsgcpp:20200723154636j:plain

プログラマーはコーディングしているときは意識することなくこの形を取っていると思います。
今回紹介したパターンはビジュアルスクリプティング特有かもしれませんね。

複雑なグラフでの例

今回はシンプルな例で紹介しましたが、実際は以下のようにたくさんのUnitから成るグラフでも発生すると思います。

f:id:tsgcpp:20200723165225j:plain

こちらの例でも<Add>から2回Outputの要求が発生するため、同様の問題が起きます。

雑感

いよいよBoltが無償化しましたね。
実は自分、2年ほど前にBoltを買ってたんですが1回も使ってませんでした。。。(無償化してから使うというUnityユーザのプチあるあるw)。

Playmakerとかもちょいちょい触っていたんですが、これからはBoltも触っていこうと思います。

UE4のBluePrintを触ったことがあったせいか、わりとすんなり扱うことができました。
(ちなみにBoltのControl Schemeという設定のデフォルト名は「Unreal」だったりしますw)

Bolt2というものも予定されているようなので、Unityでもノードベースのビジュアルスクリプティングがもっと盛り上がりそうですね!
BluePrintにもあるInterfaceとかも導入されると個人的にはうれしいです。

それでは~。

【Unity】Unity向けDependency Injection講座

概要

Unity向けDependency Injectionの方法について紹介します。
もしDependency Injectionを知らない方は前回の記事も併せてどうぞ!

ちなみにZenject(Unity向けDIフレームワーク)は使用しません!

環境

  • Unity 2019.4.3f1

今回の主目的

UnityEditor上で依存を切り替え可能にすること

DIの種類

前回の#DIの種類でも紹介しましたが、
DIには主に "Constructor Dependency Injection"と"Method/Property Dependency Injection"があります。

前回と同様に以下のILoggerを注入する前提で紹介します。

public interface ILogger
{
    void Log(string message);
}

Method/Property Dependency Injection

public変数や[SerializeField]を経由させて依存注入することになります。
注意点としてInspectorで扱うにはMonoBehaviour継承クラスである必要があります。

Inspector上でも扱いたいですが「抽象に依存」させたいため、abstractクラスとして実装します。

public abstract class BaseLogger : MonoBehaviour, ILogger
{
    public abstract void Log(string message);
}

後は上記 abstractクラス をpublic変数や[SerializeField]で定義します。

using UnityEngine;
using System.Threading.Tasks;

public class PropertyDIMessenger : MonoBehaviour
{
    [SerializeField]
    public BaseLogger logger;

    public void Recv(string message)
    {
        logger.Log(message);
    }

    // 動作確認用
    async void Start()
    {
        await Task.Delay(1000);
        Recv("Hello, I'm Messenger!");
    }
}

そして、UnityEditorのInspector上でBaseLoggerを継承したコンポーネントを渡してあげましょう

f:id:tsgcpp:20200715231322j:plain

Constructor Dependency Injection

残念ながらUnityのMonoBehaviourのコンストラクタは直接コールすることは基本的にありません。
よって、Unityのコンポーネントでは Constructor Dependency Injection はできません。

ただ、
インスタンス生成後は依存の切り替えはなしのDIは実装可能です!

その方法は、以下の2つで実現可能です。

  • [SerializeField] private変数を定義する
    • Method/Property Dependency Injection で紹介したものと同様
  • Awake()などのメソッド内でGetComponent経由で注入

ということでAwake()メソッドを使ったDIを紹介します!

Awake()だけでなく、Start(), OnEnable()でも可能です!

Awake を使った Dependency Injection

以下のように実装します!

using UnityEngine;
using System.Threading.Tasks;

[RequireComponent(typeof(ILogger))]
public class AwakeDIMessenger : MonoBehaviour
{
    private ILogger _logger;

    void Awake()
    {
        _logger = GetComponent<ILogger>();
    }

    public void Recv(string message)
    {
        _logger.Log(message);
    }

    // 動作確認用
    async void Start()
    {
        await Task.Delay(1000);
        Recv("Hello, I'm Messenger!");
    }
}

ポイントとしては

  • RequireComponentで依存する抽象インターフェースを指定
  • GetComponentで依存する依存オブジェクトを取得(注入)

Awake を使ったDIのメリット

  • インスタンス生成後は依存の切り替え不可ということを明示可能
  • interfaceに依存させることが可能
    • abstractクラスの用意が不要となる

今回使用してるILogger実装クラスであれば以下のようにabstractクラスを継承させなくてもよくなります。

using UnityEngine;

public class BaselessDebugLogger : MonoBehaviour, ILogger
{
    public void Log(string message)
    {
        Debug.Log(message);
    }
}

Inspector上では以下のようになります。

f:id:tsgcpp:20200715234301j:plain

RequireComponentで指定したコンポーネントが追加されていない場合

以下のようにエラーメッセージが表示されます。
よって、依存するコンポーネントから先に追加してください

f:id:tsgcpp:20200715234411j:plain

注意:スクリプトで直接AddComponentする場合はエラーメッセージは出ません!

Script Execution Order に注意

Awake()などの実行順序はScript Execution Orderで決まります。
もし注入される依存のAwake()が実行されていることが前提の場合は注意してください!

以下は注意が必要な例です。

以下のようにAwake()がコールされていることが前提のLogger AwakeInitializedLogger があったとします(結構無理やりに見えますがw)。

using System;
using UnityEngine;

/// <summary>
/// Awake後のみ使用可能なLogger(ExecutionOrder検証用)
/// </summary>
public class AwakeInitializedLogger : MonoBehaviour, ILogger
{
    private Action<string> _loggerAction = null;

    void Awake()
    {
        _loggerAction = message => Debug.Log(message);
    }

    public void Log(string message)
    {
        // Awake前に実行するとNullReferenceExceptionが発生
        _loggerAction(message);
    }

}

そして、以下のようにAwake()で早速Loggerを使用する AwakeLoggingMessengerがあったとします。

using UnityEngine;

// [DefaultExecutionOrder(-1)]  // こちらを指定することでAwake()のコールを優先可能
[RequireComponent(typeof(ILogger))]
public class AwakeLoggingMessenger : MonoBehaviour
{
    void Awake()
    {
        var logger = GetComponent<ILogger>();

        // logger取得後すぐにLogをコール
        logger.Log("Good Morning!");
    }
}

そして、「Awake を使った Dependency Injection」と同様に依存注入を実施しましょう。

f:id:tsgcpp:20200716215253j:plain

このDIの構成、問題があります。

AwakeLoggingMessenger.Awake()AwakeInitializedLogger.Awake()の順番で実行されるとNullReferenceExceptionが発生します。

Order of Execution の何がいけないのか

「それってOrder of Executionを設定すればいいだけじゃない?」って思った人もいるかもしれません。

確かにその通りではあるのですが、
DIのメリットの1つである「抽象に依存」というメリットが弱くなります!

注入される依存の状態を考慮する必要が出た時点で、少し蜜結合になっていると言えます。

余談:Awake, OnEnableの順番について

「じゃあOnEnableで依存注入すれば」と自分は考えました。
が、残念ながらそれも問題解決にならず。。。

オブジェクトA, BがそれぞれAwake(), OnEnable()を持っていて同時に生成される場合、以下の順番でメソッドがコールされます。

  1. A.Awake()
  2. A.OnEnable()
  3. B.Awake()
  4. B.OnEnable()

これはOrder of Execution for Event FunctionsによるとAwake()後は即座にOnEnable()が実施される仕様のためですね。

Script Execution Order の問題がなぜ起きるのか

理由は単純で Script Execution Order は依存関係を設定するために存在します。
逆に言えば Script Execution Order の設定なしの場合は依存関係が考慮されません。

設定すれば少し蜜結合になり、設定しなければ問題が発生するという悩ましい部分があります。
もちろん、今回紹介したDebugLoggerの様に問題が起きないパターンも存在します(インスタンスができた時点で使用できるため)。

Script Execution Orderの問題を回避したい場合は、次に紹介するDIフレームワークを使用することをオススメします!

Unity向けDIフレームワークについて

Unity向けDIフレームワークには以下のようなものがあります(詳細は省略)。

Unity向けDIフレームワークのメリット

  • 依存関係を指定する設計になっているため、依存関係を考慮した初期化が実施される
    • 基本的に依存するオブジェクトが先に初期化される
    • GetComponentの方法では「必ず依存注入されること」を保証できない(AddComponentを使用する場合など)
  • Script Execution Orderの意識がある程度不要
    • DIフレームワークは基本的にScript Execution Orderは意識しなくてもいいように設計されています(Awake()より前に依存解決と依存注入が実施されるなど)

上記のメリットがあるため、場合によっては利用を検討してみても良いと思います。

サンプルプロジェクト

github.com

雑感

DIはアプリケーションの柔軟性やテスタビリティにおいてとても重要なテクニックなのでぜひ活用してみて下さい。

最近、記事書くことが目的になりつつあって個人開発がおろそかになってる気が。。。

でも皆さんのお役立てできれば何よりです。
最後まで読んでいただきありがとうございました!

それではまた~。

Dependency Injection解説 と C#における実装例

概要

Dependency Injection解説 と C#における実装例を紹介しようと思います。
C#を使っていますが考え方は他のオブジェクト指向プログラミング言語でも応用できます。

次回の記事はUnityEditor向けDependency Injection講座の予定です!

DIの本質

DIの本質は以下のように言えます。

抽象オブジェクトに依存させ、その依存オブジェクトを注入によって柔軟に切り替え可能にする

上記のことを意識しながら以下の解説を読んでください。

DIに関連の深いキーワード

  • 依存注入
  • 疎結合
    • 実体ではなく抽象と結合していること
  • 抽象への依存

Dependency Injection の例

Dependency Injectionは直訳すると「依存注入」となります。
どうやって依存注入を実現するのか簡単な例を紹介します。

DIを使用しないパターン

以下のMessengerクラスはRecvメソッドを経由してメッセージを受け取る構成です。

using System;

public class Messenger
{
    public void Recv(string message)
    {
        Console.WriteLine(message);
    }
}

このMessengerクラスはConsole.WriteLIneを直接コーディング(ハードコーディング)しており、そのメソッドが属する名前空間Systemが必要となります。

これはSystem.Console.WriteLine蜜結合している状態と言えます。

DIを使用するパターン

上記MessengerのDI対応をしましょう。
本質で述べたように抽象オブジェクトに依存させます

DIを実装する場合はよくinterfaceが使用されます。 edrfvb 今回は「stringを受け取って何かをする」interface ILogger を用意します。

public interface ILogger
{
    void Log(string message);
}

そして、MessengerにはSystem.Console.WriteLineではなくILoggerに依存させましょう。

public class Messenger
{
    private ILogger _logger;

    // コンストラクタでILoggerの実体を受け取る
    public Messenger(ILogger logger)
    {
        _logger = logger;
    }

    public void Recv(string message)
    {
        // ILogger.Logをコール
        _logger.Log(message);
    }
}

Messengerクラスのコードは多くなりましたが、以下の特徴を持つようになりました。

  • Recvの動きはILogger.Logに依存
  • System.Console.WriteLineへの蜜結合が解消

DI対応したMessengerは依存するILoggerをコンストラクタ経由で注入されるようになりました。
これが「依存注入(Dependency Injection」と呼ばれる理由です。

実体ではなく抽象オブジェクトに依存させるため、Loggerとは疎結合している状態と言えます。

Loggerを実装して注入

Console.WriteLineを実施するLogger

これはいわゆる「DIを使用しないパターン」と同じ挙動となるLogger

using System;

public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

ちなみに以下のように使用します。

var injectedLogger = new ConsoleLogger();
var messenger = new Messenger(injectedLogger);
messenger.Recv("Hello, Messenger!");

コンソールに"Hello, Messenger!"と出力されます。

ファイルにメッセージを追記していくLogger

指定されたパスのファイルにmessageを追記しているLogger

public class FileLogger : ILogger
{
    private string _filePath;

    public FileLogger(string filePath)
    {
        _filePath = filePath;
    }

    public void Log(string message)
    {
        File.AppendAllText(_filePath, message);
    }
}
var injectedLogger = new FileLogger(@"C:\test.txt");
var messenger = new Messenger(injectedLogger);
messenger.Recv("Hello, Messenger!");

C:\test.txt"Hello, Messenger!"と追記されていきます。

メッセージを無視するLogger

あえて何もしないLogger

public class DummyLogger : ILogger
{
    public void Log(string message) {}
}
var injectedLogger = new DummyLogger();
var messenger = new Messenger(injectedLogger);
messenger.Recv("Hello, Messenger!");

DummyLogger.Logは何もしないため、messenger.Recvをコールしても何も起きません。
何に使うかというと、Messenger.Recvで何もさせたくないとき、もしくはさせることがないときに使います。

Messengerのコンストラクタにnullを渡すとRecv時にNullReferenceExceptionが発生してしまいます。
何もさせることがないけどnullを渡すとまずいときにDummyLoggerのような何もしないLoggerを渡してやれば問題は起きません。

依存注入でnullが渡された場合の対策

今回作成したMessengerですが、nullを渡されたときに問題が起きないようにしておいた方が安全です。
_loggerがnullの時は処理を無視する方法ももちろん可能ですが。。。

public void Recv(string message)
{
    _logger?.Log(message);
}

今回はせっかくDummyLoggerを作ったので、個人的にはコンストラクタを以下のように修正するのがオススメです! loggerにnullが渡された場合はDummyLoggerを使用させます。

public Messenger(ILogger logger)
{
    _logger = (logger != null) ? logger : new DummyLogger();
}

C#にはコンパイラレベルでNull非許容型などがありませんので、現状はルール化、もしくは上記のような対応が必要になります。
KotlinなどのようなNull Safetyの概念が入れてば不要になると思います。

DIの利点

DIを導入することの利点をいくつか紹介します

SOLID原則における「open/closed principle」を満たせる

平たく言うと柔軟にクラスの挙動をクラス自体を修正することなく変更できるようになります。

  • Messengerの挙動の追加はLoggerを作成して注入することで対応(拡張に対してOpen)
  • Messenger自体の修正は不要(修正に対してClose)

テスタビリティ(Testability)を満たせる

UnitTestなどのテストコード作成への対応が可能になります。
(逆にDIを知らない場合はUnitTestやテスタビリティのあるクラス設計が難しい気がします)。

例えば以下のようにMessenger.Recvにnullが渡されたときは動作が無視されるとしましょう。

public class Messenger
{
    public void Recv(string message)
    {
        if (message == null) {
            return;
        }

        Console.WriteLine("" + message);
    }
}

if文による条件分岐のテストはコードを実行してわざわざ人力でコンソールを見ないといけません。
今時そんな事やってられないので、UnitTestに対応させてテストを自動化させます。

そんなときもDIに対応させておくことで、UnitTestにも柔軟に対応できます!

public class Messenger
{
    private ILogger _logger;

    // コンストラクタでILoggerの実体を受け取る
    public Messenger(ILogger logger)
    {
        _logger = logger;
    }

    public void Recv(string message)
    {
        if (message == null) {
            return;
        }

        _logger.Log(message);
    }
}

DIの種類

DIの実装方法はいくつかあります。
都合の良い方法で実装してあげてください。

Constructor Dependency Injection

コンストラクタでDIを行う方法です。
上記で説明したDIはこちらになります。

インスタンス生成後は依存の切り替えはなしということを明示します。

※コードは省略

Method/Property Dependency Injection

メソッドやプロパティ経由でDIする方法です。

public class Messenger
{
    private ILogger Logger { get; set; }

    public void Recv(string message)
    {
        Logger.Log(message);
    }
}

インスタンス生成後でも依存の切り替え可能ということを明示します。

雑感

今回はDIの解説を入れてみました。
本当はUnityにおけるDIを解説したかったんですが、DIの話が長くなったので分割することにしました。

そもそもDIに関する記事って普通に多いのでいらないんじゃね?とも思ったんですが、自分なりの解説記事も出してみたくなったので作ってみました。

皆さんのエンジニアリングの参考になれば幸いです。

本題は次回の「UnityEditor向けDependency Injection講座」です!
お楽しみに~

【Unity】シーンからCubemapを作成してpngファイルで出力するツールを作ってみた

f:id:tsgcpp:20200701205557p:plain

2021/06/19追記

諸々更新してツールの見た目が変わっています。
以下の記事は v0.0.3 に関する記事となっています。

概要

Unityで作成したシーンからCubemapを作成して、pngファイルとして出力するツールを作成したので紹介します。
Camera.RenderToCubemapを使用することでCubemap自体は作成できますが、そのCubemapをテクスチャファイルに出力するツールが見当たらなかったので作ってみました。
また、作成の過程で気になった点も併せて共有します。

記事の最後にgithubのリンクを記載します。

環境

前知識

テクスチャ形式

  • 公式ドキュメントのCubemapsに記載されているCreating Cubemaps from Texturesで解説されているテクスチャに準拠して作成します
  • 現時点では縦または横に並べた形式のみをサポート

f:id:tsgcpp:20200701192521p:plain
テクスチャ形式 (Cubemapsページより引用)

Texture2D.SetPixels

Cubemap.GetPixels

  • Cubemap.GetPixels
  • 指定したfaceに対応するテクスチャのピクセル配列をゲットするメソッド
  • ピクセルの順番は右上から左下
    • Texture2D.SetPixelsとは順番が異なるため要注意
    • The returned array is a flattened 2D array, where pixels are laid out right to left, top to bottom より

Camera.RenderToCubemap

  • Camera.RenderToCubemap
  • Cameraオブジェクトのpositionを中心としてCubemapを作成するメソッド

実装方針

  • タブから作成を実行
    • MenuItemを使用
  • テクスチャサイズは可変
    • Wizardにpublic int widthを追加
  • テクスチャ形式を指定可能
    • Wizardにpublic FileType fileTypeを追加
  • Cameraの位置を指定可能
    • Wizardにpublic Transform renderFromPositionを追加
  • ファイル出力先を指定可能
    • EditorUtility.SaveFilePanelを使用

実装までの流れ

CubemapFileGeneratorWizardの作成

  • EditorツールにするためScriptableWizardCubemapFileGeneratorWizard`を継承したクラスCubemapFileGeneratorWizardを作成
    • 基本的にはCamera.RenderToCubemapRenderCubemapWizardを参考に作成
    • ScriptableWizardの詳細については割愛

テクスチャ形式に合わせたConverterを作成

注意点としてVerticalPng(テクスチャを縦に並べる形式)の場合はSetPixelsを実施する順番を気を付けてください。
Texture2D.SetPixelsは画像の左下が原点なので、-Z, +Z, -Y, +Y, -X, +Xの順番(下から上)でセットするように実装しています。

Texture2DとCubemapの差異を補正するSetPixelsメソッドを作成

  • 「前知識」で記載しましたが、Texture2DとCubemapには差異があるため、それを補正するメソッドを作成
  • Texture2DExtensions
    • 今回はTexture2Dの拡張として実装
Color[] pixels = cubemap.GetPixels(face);

// Cubemap.GetPixelsは右上から左下の順番のため、
// SetPixelsに左下から右上の順番に合わせて反転させる
System.Array.Reverse(pixels);
for (int i = 0; i < blockWidth; ++i) {
    System.Array.Reverse(pixels, i * blockHeight, blockWidth);
}

texture.SetPixels(x, y, blockWidth, blockHeight, pixels);

ツールの使用方法

ツールの起動

f:id:tsgcpp:20200701204256j:plain

ツールの設定

  • 以下は設定例
    • サイズは512x512
    • テクスチャは横に並べる

f:id:tsgcpp:20200701201152j:plain

実行結果

対象のシーン

  • UniversalRPのサンプルシーンを使用

f:id:tsgcpp:20200701201112j:plain

生成されたテクスチャ

f:id:tsgcpp:20200701201253p:plain

f:id:tsgcpp:20200701202053j:plain
TextureShape: Cubeとして適用した場合

おまけ: Texture2DとCubemapの差異を補正しない場合

  • 以下のように各テクスチャの向きがおかしくなります
  • Cubemapとして適用してもシームレスではなくなっています

f:id:tsgcpp:20200701201755p:plain

f:id:tsgcpp:20200701202104j:plain
TextureShape: Cubeとして適用した場合

生成した画像をCubemapとして適用

  • 作成した画像は2次元テクスチャなので、Cubemapとして適用してください
    • TextureShape: Cube
    • Mapping: 6 Frames Layout (Cubic Environment)

※現時点では特にEditorGUILayout.PropertyFieldなどを使った自動設定は作成していません

f:id:tsgcpp:20200701203322j:plain

Github

2021/06/19追記

諸々更新してツールの見た目が変わっています。
使用方法はREADMEに記載されているYoutubeの動画を参考にしてください

github.com

※プロジェクトサイズ削減のためサンプルシーンは別物になっています

雑感

ちょっとこういったツールがほしくなったので作ってみました。
もしかしたら何らかの形で配布されているのかもしれませんが見つかりませんでした。。。
もしご存知の方がいましたら教えていただけるとありがたいです!

ちょっとCubemap.GetPixelsの差異の部分で詰まりましたが、ひとまずほしかったものは作れました。

良かったら使ってみて下さい!
感想や問題報告などもいただけるとありがたいです。

それでは~。

【Unity】バグ?Tex2DArray型RenderTextureにVRTextureUsage.TwoEyesが指定されている場合にGraphics.Blitが機能しないパターンがある (2021/06/05 追記)

概要

タイトル通りです。
単なる共有なので簡素に書きます。

※一応UnityにBug Report投げてます。

環境

  • Unity 2019.4.1f1 (発生)
  • Unity 2020.3.11f1 (発生)
  • Unity 2021.1.10f1 (解消済み)
  • Unity 2021.2.0a19 (解消済み)

詳細はブログの末尾に追記

機能しないパターン

  • RenderTextureDescriptor.vrUsage = VRTextureUsage.TwoEyesが指定されたTex2DArray型RenderTexture
    • TwoEyes以外の場合は問題なし
  • 上記RenderTextureをpublic static void Blit(Texture source, RenderTexture dest, int sourceDepthSlice, int destDepthSlice);のdestに使い、destDepthSlice: 0で使う
    • public static void Blit(Texture source, RenderTexture dest);であれば、source, destともにTex2DArray型では問題なく機能する

関連

機能しないときにRenderTextureDescriptor

var desc = new RenderTextureDescriptor(1024, 1024);

desc.dimension = UnityEngine.Rendering.TextureDimension.Tex2DArray;
desc.volumeDepth = 2;

// TwoEyesの場合に発生
desc.vrUsage = VRTextureUsage.TwoEyes;

機能しないときのGraphics.Blit

  • destDepthSliceが0の場合に、コピーが正しく実施されない
Graphics.Blit(
    source: srcTex, dest: arrayTex,
    sourceDepthSlice: 0, destDepthSlice: 0);
  • ちなみにdestDepthSliceが1以上の場合は問題なし

どうなるのか

  • 左側がTex2DArrayのIndex0のテクスチャで正しくコピーされていない
  • 右側がIndex1で問題なくコピーされている
  • 特にエラーログも出ない

f:id:tsgcpp:20200627195357j:plain
TwoEyesの場合

期待する結果(TwoEyes以外の場合)

f:id:tsgcpp:20200627195453j:plain
TwoEyes以外の場合

雑感

前回の記事でTex2DArray型RenderTextureを作成する場合は注意してください。

仕様なのかどうか分らなかったのでBug Reportに上げました。
最近Bug Report出しすぎて、無視されないか不安になってます。。。

それでは~。

追記

Unityより「再現しました~」メール来ました。
ただ、IssueTrackerは作成されなかったようです。

今回のBug Reportに対する返信がやたら早かったです。
これより前に送った4つぐらいはまだ返信ないのが気になりますけどw。

追記(2021/06/05)

こちらのバグは 2021.1.10f1では解消されています。
ただし、2021/06/05時点のLTS最新の 2020.3.11f1 では解消されていません

理由は定かではありませんが、Unity 2020では対応されないのかもしれませんね。

ついでに検証可能なようにサンプルコードを更新しています。
StereoTextureGenerator コンポーネントCopyTypeを変更することで確認可能です。

github.com

Unity 2020でも GenerateStereoTextureAsTwoEyes メソッドを参考に別のTexture2DArrayにコピーしてから、改めて Graphics.Blit を使うことでVRTextureUsage.TwoEyesのRenderTextureに対してもGraphics.Blitが可能になります。

ただし、コードのとおり余計にメモリを使用するのでご注意ください。

【Unity, XR】左右のビューで異なるテクスチャをサンプリングするシェーダー (unity_StereoEyeIndex使用)

f:id:tsgcpp:20200623010202j:plain

概要

1つのシェーダーで左右異なるテクスチャを描画する方法を紹介します。
例えばXR向けの鏡を作りたい場合、左右それぞれで鏡の映像をRenderTextureに描画して、左右のビューそれぞれに反映する必要があります。
今回はその様に左右の目で異なるテクスチャをサンプリングするシェーダーの実装方法を紹介します。

XR向けポストプロセッシングシェーダーとの違いも紹介していますので、併せてどうぞ。

今回もブログの最後にサンプルプロジェクトを置いておきます。

実行環境

今回作るシェーダーの特徴

  • XRにおいて左右のビューで異なるテクスチャをサンプリング
    • 応用することで左右のビューで異なるエフェクトを作ることも可能
  • RenderModeのMultiPass, SinglePassInstancedの両対応
    • SinglePassはXR Pluginでは除外されているため考慮しない

前知識

ステレオカメラ

  • XR有効時のCameraのこと
  • 描画時に左右のビュー両方の描画を行うCamera
  • 内部的に左用ビュー、右用ビューを持っておりRenderModeに従って左右の描画を行う

Tex2DArray型RenderTexture

  • Editor上で作成は不可で、スクリプトから作成
  • スクリプト上ではRenderTextureだが、シェーダーに渡した場合はTex2DArrayとして扱われる
  • 今回は2枚のテクスチャを持つRenderTextureを作成し使用
    • 0番目は左目用、1番目は右目用

Tex2DArray型RenderTextureの注意点

  • Tex2DArray型RenderTextureをCamera.targetTextureを指定した場合の挙動は不定
    • UnityのバージョンやUniversalRP, HDRPなどでも挙動か変わる
    • 0番目テクスチャにだけ描画されたり、まったくされなかったりする

unity_StereoEyeIndex (シェーダー内変数)

  • シェーダー内で左右どちらの描画を行っているかを判定可能な変数
  • 0なら左目向け、1なら右目向け

使用する左右の画像

以下の画像を左右のビューそれぞれに描画します。
f:id:tsgcpp:20200623013441p:plain  f:id:tsgcpp:20200623013425p:plain

スクリプト実装のポイント

要点のみを記載しますので、最終的な実装状態はサンプルプロジェクトを参照してください。

Tex2DArray型RenderTextureの作成

  • ポイントはRenderTextureDescriptorを使うこと
var desc = new RenderTextureDescriptor(1024, 1024);

// RenderTextureをTex2DArrayとして生成
desc.dimension = TextureDimension.Tex2DArray;

// Textureは2つ(0番目は左目用、1番目は右目用)
desc.volumeDepth = 2;
        
// RenderTextureDescriptorからテクスチャを生成
var stereoTexture = RenderTexture.GetTemporary(desc);

注意

  • desc.vrUsage = VRTextureUsage.TwoEyesにする場合はGraphics.Blitが一部おかしくなるかもしれません

Tex2DArray型RenderTextureの左右それぞれに画像をコピー

  • Graphics.BlitをdepthSlice付きで使用することでTex2DArray型にテクスチャをコピー可能
    • destDepthSliceは左目は0, 右目は1
Graphics.Blit(
    source: leftEyeTexture, dest: stereoTexture,
    sourceDepthSlice: 0, destDepthSlice: 0);
Graphics.Blit(
    source: rightEyeTexture, dest: stereoTexture,
    sourceDepthSlice: 0, destDepthSlice: 1);

余談: Tex2DArray型RenderTextureに直接描画する場合

この記事の目的とは少しずれるので詳細は割愛しますが、 GraphicsCommandBufferを使用します。

// RenderTextureの1番目を青色で塗りつぶす(テクスチャのクリア処理)
Graphics.SetRenderTarget(
    rt: stereoTex,
    mipLevel: 0,
    face: CubemapFace.Unknown,
    depthSlice: 1);
GL.Clear(true, true, Color.blue);

作成したRenderTextureをマテリアルの_MainTexにセット

GetComponent<Renderer>().sharedMaterial.mainTexture = stereoTexture;

シェーダー実装のポイント

unity_StereoEyeIndexを活用

vert (vertex shader)

  • UNITY_SETUP_INSTANCE_ID(v)unity_StereoEyeIndexの値を取得
  • UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output)でfragにunity_StereoEyeIndexを渡す

frag (fragment shader)

  • UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input)でvertからのinputでunity_StereoEyeIndexを更新
  • UNITY_SAMPLE_TEX2DARRAYunity_StereoEyeIndexを渡して左右のビューそれぞれのテクスチャをサンプリング
    • unity_StereoEyeIndexが0なら左目、1なら右目

_MainTexをTex2DArrayとして宣言

  • UNITY_DECLARE_TEX2DARRAY(_MainTex)を記載して、Tex2DArrayとして宣言

XR向けポストプロセッシングシェーダーとの実装の違い

XR向けポストプロセッシングシェーダー(以下PPS)と今回作成するシェーダー(以下SES)は
実装や構成がかなり似ているため、主な違いについて紹介します。

PPSの場合

描画パイプラインにおけるPPS

  • _MainTexは描画のたびにポストプロセッシングのシステムから渡される
  • MultiPassでは、_MainTexはTex2D型でドローコールのたびに異なる(左テクスチャ、右テクスチャ)
    • ※MultiPassはシェーダー1つに対して、ドローコールは2回(左右それぞれで発生)
  • SinglePassInstancedでは、_MainTexはTex2DArray型でシェーダー内で左右のテクスチャを選別
    • ※SinglePassInstancedはシェーダー1つに対して、ドローコールが1回

PPSのシェーダーの特徴

上記の違いを満たすために以下のマクロを使用して、
RenderModeのMultiPass, SinglePassInstancedの両対応を実現しています。

  • UNITY_DECLARE_TEX2DARRAY(_MainTex)で_MainTexの型を宣言(型の調整)
    • MultiPassでTex2D型、SinglePassInstancedでTex2DArray型となる
  • UNITY_SAMPLE_SCREENSPACE_TEXTUREでサンプリング(_MainTexの型に合わせたサンプリング)
    • MultiPassでSampler2D、SinglePassInstanceでSampler2DArrayとなる

PPSのシェーダーについての余談

SinglePassInstancedの場合、コンパイルされたシェーダーがTex2DArray向けになるため、
PPSシェーダーをSESシェーダーとして再利用することが可能だったりします。 MultiPassの場合は基本的に再利用できないはずです。

SESの場合

描画パイプラインにおけるSES

  • _MainTexは独自にスクリプトを用意して指定
  • _MainTexはTex2DArray型にしてシェーダー内でテクスチャ選別が必要
    • プロパティはカメラの描画毎に決定するため、左から右の描画に切り替わるときに_MainTexの切り替えは基本的にできない

SESのシェーダーの特徴

上記の事情から基本的には_MainTexがTex2DArray型で調整して、シェーダー内で左右を切り替えることになる。

  • UNITY_DECLARE_TEX2DARRAY(_MainTex)でTex2DArray型として宣言
  • UNITY_SAMPLE_TEX2DARRAYでサンプリング
    • uv.zにunity_StereoEyeIndexを指定する必要有り

SESのシェーダーについての余談

コンパイルされたシェーダーがSinglePassInstancedと同様の構成なるため、
今回作成するSESはSinglePassInstancedとの相性が良かったりします。

実行結果

問題なければ左右のビューで異なるテクスチャが描画されているはずです。

f:id:tsgcpp:20200623010202j:plain

サンプルプロジェクト

github.com

雑感

このテクニックを使えばXR向けのどこでもドアのようなワープゲートや鏡なども、シェーダーでエフェクトを付けることが可能になります。

実は、どっちかというとTex2DArray型のRenderTextureにどうやって描画するかのほうがもっと重要な問題だったりします。 Camera.targetTextureにTex2DArray型は指定しても動かなかったり、UniversalRPやHDRPではCommandBufferが使えなかったりと何かと不便があったり。。。

ただ、このシェーダーの原理をわかっておくだけで、XRにおける1歩進んだ表現が可能になると思います。

話は少しずれますが、VRChatのVRC_MirrorReflectionで使うカスタムシェーダーもunity_StereoEyeIndexを使用する点においては原理は同じです。
(VRChatはSinglePassだったり、カスタムシェーダーも_ReflectionTex0, 1で左右の鏡テクスチャを受け取るので、シェーダーの構成自体は異なります)。

最後まで読んでいただいてありがとうございました!
何かのお役に立てれば幸いです。

【Unity】Zenject (Extenject) を使った自動テストを紹介

f:id:tsgcpp:20200617194213p:plain
Extenject Thumbnail

概要

Zenjectを使った自動テスト(UnitTest, IntegrationTest, SceneTest)を試してみたので共有します。

ちなみにZenjectはUnity向けのDependency Injection(以下DI)ライブラリです。

ある程度DIとZenjectに親しみのある人向けになりますが、サンプルコードはシンプルにしてますのでZenject実装の参考にもなるかと思います。

基本的にはWriting Automated Unit Tests and Integration Testsに準拠しますが、補足などや注意点なども併せて紹介します。

記事の最後にサンプルプロジェクトを載せておきます。

環境

Extenjectのインストール

  • releasesよりZenject@v9.2.0.unitypackageをダウンロードし導入
  • 必要な項目のみチェックしてインストール
    • 大事なのは/Plugins/Zenject/OptionalExtras/TestFrameworkに必ずチェックを入れること
    • サンプルゲームなどは除外

f:id:tsgcpp:20200617000952j:plain
Extenjectのインストール

Zenjectにおける自動テスト種別とそのポイント

共通

  • 使用するDIContainerはテスト毎に再生成
    • テスト毎にDIContainerはリフレッシュされる
    • テスト時にBindしたものを、テスト後にUnbindするなどの処理は基本的に不要

Unit Test

  • Injectされるオブジェクトのテスト
    • UnitTest向けのZenjectUnitTestFixtureを使用
  • BindによるInjectの挙動検証のテストも可能
  • EditorModeでテスト
    • UnityにおけるUnitTestと同様に、Unityライフサイクルを意識したテストは不可

Integration Test

  • DIによる依存注入されるクラスのテスト
    • PlayMode向けにカスタマイズされたZenjectIntegrationTestFixtureを使用
  • Injectされるオブジェクトをモックなどに切り替えてテストを行う
  • PlayModeでテスト
    • Zenjectの Order Of Operations に従ってテストを実施するため
    • Unityのライフサイクルを意識したテストが可能
  • 空のシーンでスクリプトからテスト用オブジェクトを追加

Scene Test

  • 予め作成したシーンを使ったIntegration Test
    • シーン読み込み向けにカスタマイズされたSceneTestFixtureを使用
  • 既存のシーン内でInjectされるオブジェクトを切り替えてテストを行う
  • PlayModeでテスト
  • シーンをロードしてテストする以外はIntegration Testと同様

テストシチュエーション

今回は乱数を使ったゲームのテストを自動化することを想定してシチュエーションを作ります。

乱数生成器をそのままテストに使用すると想定した乱数が全然生成されず、まったくTestabilityを満たすことができません。
そこで、乱数生成器をDIする構成にして、テスト時は指定した数値を返す乱数生成器のモックをInjectしてテストを行ってみましょう。

登場クラス

  • RandomScaler
    • メインのテスト対象
    • Randomizerから乱数を受け取って、オブジェクトのスケールを変更
  • Randomizer
    • Injectされるクラス
    • [0.0, 1.0]の間で乱数を返す乱数生成器
    • テスト時はモックに切り替えてインジェクト

テスト対象コードの作成

  • RandomScalerはRandomizerから取得した乱数を元にGameObjectのScaleを変更するMonoBehaviour
  • RandomizerはIRandomizerの実装クラス
using UnityEngine;
using Zenject;

public class RandomScaler : MonoBehaviour
{
    [Inject]
    private IRandomizer Randomizer { get; set; }

    void Start()
    {
        // 乱数によってスケールを変更
        float scale = Randomizer.value;
        this.transform.localScale = new Vector3(scale, scale, scale);
    }
}
/// <summary>
/// 乱数生成器のインターフェース
/// 乱数を[0.0f, 1.0f]で返す
/// </summary>
public interface IRandomizer
{
    /// <summary>
    /// 乱数を[0.0f, 1.0f]で返す
    /// </summary>
    float value { get; }
}
using UnityEngine;

/// <summary>
/// 通常の乱数生成器
/// </summary>
public class Randomizer : IRandomizer
{
    public float value => Random.Range(0.0f, 1.0f);
}

Scripts直下にTestTargets AssemblyDefinition を作成

f:id:tsgcpp:20200617003756j:plain
TestTargets用AssemblyDefinitionの設定

テスト種別それぞれの実装例

Randomizerモックを作成

  • RandomScalerのテストを作成する前に、Randomizerのモック FixedRandomizerを作成しましょう

モック用のAssemblyDefinitionを作成

  • /Tests/MockTest/フォルダを作成
  • フォルダで右クリック -> Create -> Assembly Definition
  • 名前はTestMocks
  • 各種設定を追加
    • Define ConstraintsにUNITY_INCLUDE_TESTSを入れることでビルド後のManagedから除外されます

f:id:tsgcpp:20200617002353j:plain
TestMocks用AssemblyDefinition

FixedRandomizerを作成

  • 指定された乱数値を返すIRandomizer実装クラスFixedRandomizerを用意
    • 乱数値を固定化することでテスト時の挙動を一定化
    • 要するに乱数器の皮をかぶったテスト用モックに変更
using Zenject;

/// <summary>
/// 毎回固定値を返すRandomizer
/// </summary>
public class FixedRandomizer : IRandomizer
{
    [InjectOptional]  // Injectにより数値の変更可能
    public float FixedValue { get; set; } = 0.0f;

    /// <summary>
    /// 乱数ではなく固定値を返す
    /// </summary>
    public float value => FixedValue;
}

Unit Test

RandomScalerにDIする前に、DIされるFixedRandomizerのテストを実施

1. Test Runnerを開き、EditMode用TestAssemblyを作成

  • Test RunnerタブでEditMode -> Create EditMode Test Assembly Folder

    f:id:tsgcpp:20200617001317j:plain
    UnitTest用AssemblyDefinitionの作成

  • Assembly DefinitionにReferencesなどを設定

    • zenject, Zenject-TestFrameowrk, Zenject-usage.dllを追加
    • TestTargets, TestMocks を追加
      f:id:tsgcpp:20200617002732j:plain
      UnitTest用AssemblyDefinitionの設定

2. FixedRandomizerTestを作成

f:id:tsgcpp:20200617012522j:plain
UnitTest用ファイルの作成

  • ZenjectによりBindし、InjectされたFixedRandomizerをテスト
    • FixedRandomizerにInjectされたFixedValueのテストも併せて実施
    • PlatformはEditor
using Zenject;
using NUnit.Framework;

[TestFixture]
public class FixedRandomizerTest : ZenjectUnitTestFixture
{
    // テスト対象
    [Inject]
    IRandomizer _target;

    const float injectedValue = 0.75f;

    [SetUp]
    public void CommonInstall()
    {
        // Bindの実施
        Container
            .Bind<IRandomizer>()
            .To<FixedRandomizer>()
            .AsSingle()
            .WithArguments<float>(injectedValue);  // FixedRandomizer.FixedValueにInjectするfloat値

        // DIの実施
        Container.Inject(this);
    }

    /// <summary>
    /// Inject結果の検証
    /// </summary>
    [Test]
    public void TestInjectType()
    {
        Assert.IsInstanceOf<FixedRandomizer>(_target);
    }

    /// <summary>
    /// Injectされた固定値を返すことの検証
    /// </summary>
    [Test]
    public void TestInjectedValue()
    {
        Assert.AreEqual(injectedValue, _target.value, 0.0f);
    }

    /// <summary>
    /// 指定した固定値を返すことの検証
    /// </summary>
    [Test]
    public void TestFixedValue()
    {
        (_target as FixedRandomizer).FixedValue = 0.25f;
        Assert.AreEqual(0.25f, _target.value, 0.0f);
    }
}

UnitTest実装のポイント

  • .WithArguments<float>(injectedValue)FixedRandomizer内で[InjectOptional]が付いたfloat変数にInjectされる
  • Container.Inject(this)によりテストクラス内の[Inject]が付いた変数に該当するクラスインスタンスがInjectされる

3. UnitTestの実施

  • Test Runner -> EditMode -> Run All (タブの左上)

IntegrationTest

RandomScalerFixedRandomizerをDIしてテストを行う

1. Test Runnerを開き、PlayMode用TestAssemblyを作成

  • Test RunnerタブでPlayMode -> Create PlayMode Test Assembly Folder

    f:id:tsgcpp:20200617015738j:plain
    IntegrationTest用AssemblyDefinitionの作成

  • Assembly DefinitionにReferencesなどを設定

    • zenject, Zenject-TestFrameowrk, Zenject-usage.dllを追加
    • TestTargets, TestMocks を追加
    • PlatformはAny Platform
      f:id:tsgcpp:20200617020058j:plain
      IntegrationTest用AssemblyDefinitionの設定

2. RandomScaleTestを作成

f:id:tsgcpp:20200617021237j:plain
IntegrationTest用ファイルの作成

using Zenject;
using UnityEngine;
using UnityEngine.TestTools;
using System.Collections;
using NUnit.Framework;

public class RandomScaleTest : ZenjectIntegrationTestFixture
{
    // テスト対象
    [Inject]
    RandomScaler _target;

    const float fixedScale = 0.12345f;

    /// <summary>
    /// テスト毎に共通のInstall処理
    /// </summary>
    void CommonInstall()
    {
        PreInstall();

        // InjectされるRandomizer
        Container
            .BindInterfacesAndSelfTo<FixedRandomizer>()
            .AsSingle()
            .WithArguments<float>(fixedScale);  // Scale値をfloatとしてBindし、FixedRandomizer.FixedValueにInject

        // RandomScalerがアタッチされたGameObjectの生成してBind
        Container
            .Bind<RandomScaler>()
            .FromNewComponentOnNewGameObject()
            .AsSingle();

        // この処理で_targetにInject
        PostInstall();
    }

    /// <summary>
    /// 初期スケールが1であることのテスト
    /// </summary>
    [UnityTest]
    public IEnumerator TestInitScale()
    {
        CommonInstall();

        Vector3 localScale = _target.gameObject.transform.localScale;
        Assert.AreEqual(Vector3.one, localScale);

        yield break;
    }

    /// <summary>
    /// Start()後のスケールがfixedScaleであることのテスト
    /// </summary>
    [UnityTest]
    public IEnumerator TestAfterStartScale()
    {
        CommonInstall();

        yield return null;  // 1frame動かす

        Vector3 localScale = _target.gameObject.transform.localScale;
        Assert.AreEqual(new Vector3(fixedScale, fixedScale, fixedScale), localScale);
    }
}

IntegrationTest実装のポイント

  • PreInstall()PostInstall()の間にBind処理を挟む
    • PreInstall()はPlayModeにおける初期化処理を実施
    • PostInstall()はテストクラスなどへのInject処理を実施
  • CommonInstall()メソッドを定義する必要はないが、公式ドキュメントの実装方針に準拠
  • CommonInstall()後にyield return null;することて、シーン内のオブジェクトにInjectが実施される

3. IntegrationTestの実施

  • Test Runner -> PlayMode -> Run All (タブの左上)

SceneTest

以下の動画のようなシーンを作成してテストに利用

テスト用シーンの作成

  • シーン名はRandomScaleSphereScene
  • RandomScaleSphereオブジェクトにはRandomScalerを追加
    • RandomizerInstaller(後述)でBindされたRandomizerがInjectされている
  • シーンを起動するたびにオブジェクトのスケールが乱数値によって変更される

RandomizerInstallerの作成

使用方法はZenjectのInstallerと同様のため割愛

  • IRandomizerのBindとしてRandomizerを登録
  • .IfNotBound()を追加
    • 実コードがテストに依存しているみたいであまり好きではないんですが、公式サンプルのSpaceFighterTestIfNotBound()を使用していたため準拠します
    • 詳しくは後述
using Zenject;

public class RandomizerInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container
            .Bind<IRandomizer>()
            .To<Randomizer>()
            .AsSingle()
            .IfNotBound();  // 親のContextで登録されていたら、Bindしない
    }
}

youtu.be

1. Test Runnerを開き、PlayMode用TestAssemblyを作成

※IntegrationTestと同じフォルダにテストを配置する場合は不要

  • 「IntegrationTest」と同様
    • IntegrationTestにて作成したAssemblyDefinitionをコピーしてしても良い
    • Assembly Definitionの設定も同様

f:id:tsgcpp:20200617134717j:plain
SceneTest用AssemblyDefinitionの設定

2. テストに使用するシーンを Scenes In Build に追加

LoadSceneを実施するため、Scene in Buildに登録する必要がある

3. RandomScaleSphereSceneTestを作成

f:id:tsgcpp:20200617135924j:plain
SceneTest用ファイルの作成

using Zenject;
using UnityEngine;
using UnityEngine.TestTools;
using System.Collections;
using NUnit.Framework;

public class RandomScaleSphereSceneTest : SceneTestFixture
{
    const string sceneName = "RandomScaleSphereScene";

    [UnityTest]
    [Timeout(5000)]  // タイムアウトは5秒(5000ms)
    public IEnumerator TestSceneStartup()
    {
        // シーンの読み込みが問題ないことの確認
        yield return LoadScene(sceneName);

        // シーンが問題なく開始していることの確認
        yield return new WaitForSeconds(1.0f);
    }


    [UnityTest]
    [Timeout(5000)]  // タイムアウトは5秒(5000ms)
    public IEnumerator TestSphereScaleWithFixedRandomizer()
    {
        float targetScale = 0.45678f;

        // InjectされるRandomizer
        StaticContext.Container
            .BindInterfacesAndSelfTo<FixedRandomizer>()
            .AsSingle()
            .WithArguments<float>(targetScale);

        // シーンの読み込み
        yield return LoadScene(sceneName);

        var resolved = SceneContainer.Resolve<IRandomizer>();
        Assert.IsInstanceOf<FixedRandomizer>(resolved);

        // テスト対象のGeameObjectを取得
        var targetObject = GameObject.Find("RandomScaleSphere");

        yield return null;  // 1frame動かす

        // Start()後のスケールを確認
        Vector3 localScale = targetObject.transform.localScale;
        Assert.AreEqual(new Vector3(targetScale, targetScale, targetScale), localScale);
    }
}

SceneTest実装のポイント

  • StaticContext.Containerに対してBindを行う
    • StaticContextProjectContextよりさらに上位のContext
  • SceneContainer.Resolveでシーン内のBind状態を確認可能
  • [Timeout(X)]タイムアウトを指定することが可能

StaticContextについて

  • ZenjectのBindはStaticContext -> ProjectContext -> SceneContext -> GameObjectContextのような階層でStaticContextは一番優先度が低い
    • RandomizerInstaller.IfNotBound()を使用した理由はこのため
    • 公式サンプルではOverride settings to only spawn one enemy to testとあるが正確にはオーバーライドではなく、.IfNotBound()で親Contextを優先させています
    • 実コードにテスト向けコードを追加している感があるため、.IfNotBound()をつけなくてもオーバーライドする方法が判明したら共有します

サンプルプロジェクト

雑感

結構詳細に実装までの流れを載せたので読みづらかったらすみません。。。
ここ最近Zenjectの存在を知って勉強していたので、共有も兼ねて記事を書いてみました。

DI系のライブラリやフレームワークは結構好きで、Zenjectもかなり気に入っています。
StaticContextやUnitTestのために.IfNotBound()を使うのは少し残念かなって思ったぐらいでしょうか。
また、Extenjectが出てくるなど保守周りは調整中のような空気を感じました。

.WithKernel周りにもまだバグがあるようなので、余裕があったら調査してみようと思います。

最後まで読んでいただきありがとうございます!
何かのお役に立てばと思います。