【Unity】Boltなどのビジュアルスクリプティングで気づきにくいミスパターン
概要
Boltが正式に無償化したということで、今回はBoltなどのビジュアルスクリプティングで気を付けたいことを1つ共有したいと思います。
この話はBoltに限らずUE4のBluePrintなどでも当てはまると思います!
Boltについて
【無償化】Bolt ビジュアルスクリプティングがすべての Unity ユーザーにご利用いただけるようになりましたhttps://t.co/mVWuMbiiMo pic.twitter.com/qkA1MyYXzR
— ユニティ・テクノロジーズ・ジャパン (@unity_japan) 2020年7月22日
Boltの参考動画
以下の動画が参考になると思います!
補足
Unit (Boltにおけるノード) は <Unit名>
で表現します。
以下のSet Variable Unitは <Set Variable>
として記載します。
問題
早速ですが私がぶち当たった問題を簡単に表現したグラフを見てみましょう。
以下のグラフには問題があります!
良かったらちょっと考えてみて下さい!
処理の流れとしては以下を想定して実装しています。
TimeCount
+deltaTime
の結果をTimeCount
に格納Set Variable
した値をDebug.Log
で出力- やりたいことは更新後の
TimeCount
の確認
答え
初期変数を以下とすると、ログには何が出力されるでしょうか?
TimeCount = 2.96
deltaTime = 0.02
答えは 3.00 です!
deltaTime
が2回足されたものになっています!
※理由がわかっている人はこの後の話はつまらないと思いますw
要注意ポイント
おそらくほとんどの人が気にしたと思いますが、以下の赤色と橙色の丸で囲った部分が要注意ポイントです!
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
を取得して加算したもの
- Outputは
<Add>
がOutputを<Set Variable>
に渡す<Set Variable>
がOutputをTimeCountに格納
2. Debug.Logが実施
<Debug.Log>
が`のOutputを要求 <Add>
が自身の処理を実施- Outputは
<Time>
のdeltaTime
と<Get Variable>
のTimeCount
を取得して加算したもの - この時点で取得された
TimeCount
は「1.」でdeltaTime
が加算されたものになっている
- Outputは
<Add>
がOutputを<Set Variable>
に渡す- <Debug.Log>がOputputをコンソールに出力
詳細に処理の流れを説明するとこんな感じでしょうか 。
「<Add>
が2回動く」、「2回目の<Add>
が使用するTimeCount
が加算済みのものになっている」ことで、ログには2回加算されたTimeCount
の値が出力されたんですね。
解決策
Set Variable のOutput から取得
<Set Variable>
のOutputは処理した時のものが保持されるようです。
ついでに以下のように、<Set Variable>
が保持されているかの確認もしてみました。
変数から直接取得
今回やりたいことは更新されたTimeCount
の値を知りたいだけです。
よって単純に変数から取得すれば問題ありません。
プログラマーはコーディングしているときは意識することなくこの形を取っていると思います。
今回紹介したパターンはビジュアルスクリプティング特有かもしれませんね。
複雑なグラフでの例
今回はシンプルな例で紹介しましたが、実際は以下のようにたくさんのUnitから成るグラフでも発生すると思います。
こちらの例でも<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
を継承したコンポーネントを渡してあげましょう
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上では以下のようになります。
RequireComponentで指定したコンポーネントが追加されていない場合
以下のようにエラーメッセージが表示されます。
よって、依存するコンポーネントから先に追加してください。
注意:スクリプトで直接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」と同様に依存注入を実施しましょう。
このDIの構成、問題があります。
AwakeLoggingMessenger.Awake()
、AwakeInitializedLogger.Awake()
の順番で実行されるとNullReferenceException
が発生します。
Order of Execution の何がいけないのか
「それってOrder of Executionを設定すればいいだけじゃない?」って思った人もいるかもしれません。
確かにその通りではあるのですが、
DIのメリットの1つである「抽象に依存」というメリットが弱くなります!
注入される依存の状態を考慮する必要が出た時点で、少し蜜結合になっていると言えます。
余談:Awake, OnEnableの順番について
「じゃあOnEnableで依存注入すれば」と自分は考えました。
が、残念ながらそれも問題解決にならず。。。
オブジェクトA, BがそれぞれAwake()
, OnEnable()
を持っていて同時に生成される場合、以下の順番でメソッドがコールされます。
A.Awake()
A.OnEnable()
B.Awake()
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()
より前に依存解決と依存注入が実施されるなど)
- DIフレームワークは基本的にScript Execution Orderは意識しなくてもいいように設計されています(
上記のメリットがあるため、場合によっては利用を検討してみても良いと思います。
サンプルプロジェクト
雑感
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ファイルで出力するツールを作ってみた
2021/06/19追記
諸々更新してツールの見た目が変わっています。
以下の記事は v0.0.3 に関する記事となっています。
概要
Unityで作成したシーンからCubemapを作成して、pngファイルとして出力するツールを作成したので紹介します。
Camera.RenderToCubemap
を使用することでCubemap自体は作成できますが、そのCubemapをテクスチャファイルに出力するツールが見当たらなかったので作ってみました。
また、作成の過程で気になった点も併せて共有します。
記事の最後にgithubのリンクを記載します。
環境
- Unity 2019.4.1f1
- Windows 10
前知識
テクスチャ形式
- 公式ドキュメントのCubemapsに記載されている
Creating Cubemaps from Textures
で解説されているテクスチャに準拠して作成します - 現時点では縦または横に並べた形式のみをサポート
Texture2D.SetPixels
- Texture2D.SetPixels
- ピクセル配列を渡すことで、テクスチャ上のピクセルをセットするメソッド
- ピクセルの順番は左下から右上
The colors array is a flattened 2D array, where pixels are laid out left to right, bottom to top
より
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に
- テクスチャ形式を指定可能
- Wizardに
public FileType fileType
を追加
- Wizardに
- Cameraの位置を指定可能
- Wizardに
public Transform renderFromPosition
を追加
- Wizardに
- ファイル出力先を指定可能
EditorUtility.SaveFilePanel
を使用
実装までの流れ
CubemapFileGeneratorWizardの作成
- Editorツールにするため
ScriptableWizard
CubemapFileGeneratorWizard`を継承したクラスCubemapFileGeneratorWizardを作成- 基本的にはCamera.RenderToCubemapの
RenderCubemapWizard
を参考に作成 ScriptableWizard
の詳細については割愛
- 基本的にはCamera.RenderToCubemapの
テクスチャ形式に合わせた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);
ツールの使用方法
ツールの起動
ツールの設定
- 以下は設定例
- サイズは512x512
- テクスチャは横に並べる
実行結果
対象のシーン
- UniversalRPのサンプルシーンを使用
生成されたテクスチャ
おまけ: Texture2DとCubemapの差異を補正しない場合
- 以下のように各テクスチャの向きがおかしくなります
- Cubemapとして適用してもシームレスではなくなっています
生成した画像をCubemapとして適用
- 作成した画像は2次元テクスチャなので、Cubemapとして適用してください
- TextureShape: Cube
- Mapping: 6 Frames Layout (Cubic Environment)
※現時点では特にEditorGUILayout.PropertyField
などを使った自動設定は作成していません
Github
2021/06/19追記
諸々更新してツールの見た目が変わっています。
使用方法はREADMEに記載されているYoutubeの動画を参考にしてください
※プロジェクトサイズ削減のためサンプルシーンは別物になっています
雑感
ちょっとこういったツールがほしくなったので作ってみました。
もしかしたら何らかの形で配布されているのかもしれませんが見つかりませんでした。。。
もしご存知の方がいましたら教えていただけるとありがたいです!
ちょっと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型RenderTextureTwoEyes
以外の場合は問題なし
- 上記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で問題なくコピーされている
- 特にエラーログも出ない
期待する結果(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
を変更することで確認可能です。
Unity 2020でも GenerateStereoTextureAsTwoEyes メソッドを参考に別のTexture2DArrayにコピーしてから、改めて Graphics.Blit
を使うことでVRTextureUsage.TwoEyes
のRenderTextureに対してもGraphics.Blit
が可能になります。
ただし、コードのとおり余計にメモリを使用するのでご注意ください。
【Unity, XR】左右のビューで異なるテクスチャをサンプリングするシェーダー (unity_StereoEyeIndex使用)
- 概要
- 実行環境
- 今回作るシェーダーの特徴
- 前知識
- 使用する左右の画像
- スクリプト実装のポイント
- シェーダー実装のポイント
- XR向けポストプロセッシングシェーダーとの実装の違い
- 実行結果
- サンプルプロジェクト
- 雑感
概要
1つのシェーダーで左右異なるテクスチャを描画する方法を紹介します。
例えばXR向けの鏡を作りたい場合、左右それぞれで鏡の映像をRenderTextureに描画して、左右のビューそれぞれに反映する必要があります。
今回はその様に左右の目で異なるテクスチャをサンプリングするシェーダーの実装方法を紹介します。
XR向けポストプロセッシングシェーダーとの違いも紹介していますので、併せてどうぞ。
今回もブログの最後にサンプルプロジェクトを置いておきます。
実行環境
- Unity 2019.4.1f1
- XR Plugin
- Mock HMD
- Oculus Quest + Oculus Link
- OpenVR Loader (SteamVR Unity Plugin v2.6.0b2 (sdk 1.12.5))
今回作るシェーダーの特徴
- 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なら右目向け
使用する左右の画像
以下の画像を左右のビューそれぞれに描画します。
スクリプト実装のポイント
要点のみを記載しますので、最終的な実装状態はサンプルプロジェクトを参照してください。
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に直接描画する場合
この記事の目的とは少しずれるので詳細は割愛しますが、 GraphicsやCommandBufferを使用します。
// 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_TEX2DARRAY
にunity_StereoEyeIndex
を渡して左右のビューそれぞれのテクスチャをサンプリングunity_StereoEyeIndex
が0なら左目、1なら右目
_MainTexをTex2DArrayとして宣言
UNITY_DECLARE_TEX2DARRAY(_MainTex)
を記載して、Tex2DArrayとして宣言
XR向けポストプロセッシングシェーダーとの実装の違い
XR向けポストプロセッシングシェーダー(以下PPS)と今回作成するシェーダー(以下SES)は
実装や構成がかなり似ているため、主な違いについて紹介します。
- 公式ドキュメント : Single Pass Instanced rendering
- XR向けポストプロセッシングについて紹介されています
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
を指定する必要有り
- uv.zに
SESのシェーダーについての余談
コンパイルされたシェーダーがSinglePassInstancedと同様の構成なるため、
今回作成するSESはSinglePassInstancedとの相性が良かったりします。
実行結果
問題なければ左右のビューで異なるテクスチャが描画されているはずです。
サンプルプロジェクト
雑感
このテクニックを使えばXR向けのどこでもドアのようなワープゲートや鏡なども、シェーダーでエフェクトを付けることが可能になります。
実は、どっちかというとTex2DArray型のRenderTextureにどうやって描画するかのほうがもっと重要な問題だったりします。 Camera.targetTextureにTex2DArray型は指定しても動かなかったり、UniversalRPやHDRPではCommandBufferが使えなかったりと何かと不便があったり。。。
ただ、このシェーダーの原理をわかっておくだけで、XRにおける1歩進んだ表現が可能になると思います。
話は少しずれますが、VRChatのVRC_MirrorReflectionで使うカスタムシェーダーもunity_StereoEyeIndexを使用する点においては原理は同じです。
(VRChatはSinglePassだったり、カスタムシェーダーも_ReflectionTex0, 1で左右の鏡テクスチャを受け取るので、シェーダーの構成自体は異なります)。
最後まで読んでいただいてありがとうございました!
何かのお役に立てれば幸いです。
【Unity】Zenject (Extenject) を使った自動テストを紹介
- 概要
- 環境
- Zenjectにおける自動テスト種別とそのポイント
- テストシチュエーション
- テスト種別それぞれの実装例
- サンプルプロジェクト
- 雑感
概要
Zenjectを使った自動テスト(UnitTest, IntegrationTest, SceneTest)を試してみたので共有します。
ちなみにZenjectはUnity向けのDependency Injection(以下DI)ライブラリです。
ある程度DIとZenjectに親しみのある人向けになりますが、サンプルコードはシンプルにしてますのでZenject実装の参考にもなるかと思います。
基本的にはWriting Automated Unit Tests and Integration Testsに準拠しますが、補足などや注意点なども併せて紹介します。
記事の最後にサンプルプロジェクトを載せておきます。
環境
- Unity 2019.4.0f1
- Extenject Version 9.2.0
- Extenjectは現在保守されているZenjectのForkプロジェクトです
- 詳細は Extensions, bug fixes and updates for Zenject を参照
Extenjectのインストール
- releasesより
Zenject@v9.2.0.unitypackage
をダウンロードし導入- Asset Storeからでも可
- 必要な項目のみチェックしてインストール
- 大事なのは
/Plugins/Zenject/OptionalExtras/TestFramework
に必ずチェックを入れること - サンプルゲームなどは除外
- 大事なのは
Zenjectにおける自動テスト種別とそのポイント
共通
- 使用するDIContainerはテスト毎に再生成
- テスト毎にDIContainerはリフレッシュされる
- テスト時にBindしたものを、テスト後にUnbindするなどの処理は基本的に不要
Unit Test
- Injectされるオブジェクトのテスト
- UnitTest向けの
ZenjectUnitTestFixture
を使用
- UnitTest向けの
- BindによるInjectの挙動検証のテストも可能
- EditorModeでテスト
- UnityにおけるUnitTestと同様に、Unityライフサイクルを意識したテストは不可
Integration Test
- DIによる依存注入されるクラスのテスト
- PlayMode向けにカスタマイズされた
ZenjectIntegrationTestFixture
を使用
- PlayMode向けにカスタマイズされた
- 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 を作成
テスト種別それぞれの実装例
Randomizerモックを作成
RandomScaler
のテストを作成する前に、RandomizerのモックFixedRandomizer
を作成しましょう
モック用のAssemblyDefinitionを作成
/Tests/MockTest/
フォルダを作成- フォルダで右クリック -> Create -> Assembly Definition
- 名前は
TestMocks
- 各種設定を追加
- Define Constraintsに
UNITY_INCLUDE_TESTS
を入れることでビルド後のManagedから除外されます
- Define Constraintsに
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
Assembly DefinitionにReferencesなどを設定
zenject
,Zenject-TestFrameowrk
,Zenject-usage.dll
を追加TestTargets
,TestMocks
を追加
2. FixedRandomizerTestを作成
- 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
RandomScaler
にFixedRandomizer
をDIしてテストを行う
1. Test Runnerを開き、PlayMode用TestAssemblyを作成
Test RunnerタブでPlayMode -> Create PlayMode Test Assembly Folder
Assembly DefinitionにReferencesなどを設定
zenject
,Zenject-TestFrameowrk
,Zenject-usage.dll
を追加TestTargets
,TestMocks
を追加- PlatformはAny Platform
2. RandomScaleTestを作成
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が実施される- Zenject Order Of Operationsの
Awake()
,Start()
が実施されるため
- Zenject Order Of Operationsの
3. IntegrationTestの実施
- Test Runner -> PlayMode -> Run All (タブの左上)
SceneTest
以下の動画のようなシーンを作成してテストに利用
テスト用シーンの作成
- シーン名は
RandomScaleSphereScene
RandomScaleSphere
オブジェクトにはRandomScaler
を追加RandomizerInstaller
(後述)でBindされたRandomizer
がInjectされている
- シーンを起動するたびにオブジェクトのスケールが乱数値によって変更される
RandomizerInstallerの作成
使用方法はZenjectのInstallerと同様のため割愛
IRandomizer
のBindとしてRandomizer
を登録.IfNotBound()
を追加- 実コードがテストに依存しているみたいであまり好きではないんですが、公式サンプルの
SpaceFighterTest
もIfNotBound()
を使用していたため準拠します - 詳しくは後述
- 実コードがテストに依存しているみたいであまり好きではないんですが、公式サンプルの
using Zenject; public class RandomizerInstaller : MonoInstaller { public override void InstallBindings() { Container .Bind<IRandomizer>() .To<Randomizer>() .AsSingle() .IfNotBound(); // 親のContextで登録されていたら、Bindしない } }
1. Test Runnerを開き、PlayMode用TestAssemblyを作成
※IntegrationTestと同じフォルダにテストを配置する場合は不要
- 「IntegrationTest」と同様
- IntegrationTestにて作成したAssemblyDefinitionをコピーしてしても良い
- Assembly Definitionの設定も同様
2. テストに使用するシーンを Scenes In Build に追加
LoadScene
を実施するため、Scene in Build
に登録する必要がある
- File -> Build Settings
Scene in Build
にシーンRandomScaleSphereScene
を追加(ドラッグ&ドロップ)
3. RandomScaleSphereSceneTestを作成
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を行うStaticContext
はProjectContext
よりさらに上位の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
周りにもまだバグがあるようなので、余裕があったら調査してみようと思います。
最後まで読んでいただきありがとうございます!
何かのお役に立てばと思います。