テラシュールブログ

旧テラシュールウェアブログUnity記事。主にUnityのTipsやAR・VR、ニコニコ動画についてのメモを残します。

【Unity】Genericなクラスをシリアライズで来た

ジェネリックなクラスをシリアライズ

 Unity 2020.1aを試している時に気づいたのですが、どうやらジェネリックなクラスをシリアライズ可能になったみたいです。記憶だとUnity 2019.3だったような気がしましたが、改めて手元で確認するとUnity 2020.1ですので、多分勘違いです。

 今までのUnityではジェネリックなクラスを直接シリアライズすることは出来ませんでした。そのため、一旦ジェネリックなクラスを継承したクラスを定義するという面倒な実装が必要でした。例えば任意の引数を要求するUnityEventを実装したい場合は下のような実装が必要でした。

// 適当なジェネリックなクラス
[Serializable]
public class MyGenericData<T>
{
    public T value;
}
// ジェネリックなクラスを使用する場合、事前に型を定義しておく必要がある
[System.Serializable]
public class UnitySerializeEvent<T> : UnityEvent<T> { }

[System.Serializable]
public class Vector3Data : MyGenericData<Vector3> { }


// 実際にコンポーネントで使用している例
public class MyComponent : MonoBehaviour
{
    [SerializeField] GameObjectEvent gameObjectEv;
    [SerializeField] Vector3Data mydata;
 
    //  コレは使えない
    [SerializeField] MyGenericData<Vector3> vector3Value;
}

f:id:tsubaki_t1:20191101223927j:plain
シリアライズされた値

Unity 2020.1でシリアライズ出来た

 さて、この制限ですがUnity 2020.1で試した所、なんとジェネリックなクラスを継承したクラスを経由せず、直接定義してもシリアライズ出来ました。まぁDictionaryQueueといったクラスは無理でしたが。またシリアライズ出来なかったUnityEventも一度Serializableを設定するクラスを経由すれば使用可能でした。超面倒くさいAssetReferenceTも大本にSerializableを付ければOK。

public class MyComponent : MonoBehaviour
{
    // ジェネリックなクラスを継承したクラスをそのまま定義
    [SerializeField] MyGenericData<Vector3> vector3Value;

    [SerializeField] MyGenericData<string> strValue;

    [SerializeField] MyGenericData<GameObject> objValue;

    [SerializeField] AssetReferenceT<GameObject> assetRefValue;

    [SerializeField] UnitySerializeEvent<GameObject> objEv;
}

// UnityEventにはSerializableが無かったので追加
[System.Serializable]
public class UnitySerializeEvent<T> : UnityEvent<T> { }

f:id:tsubaki_t1:20191101224409j:plain
コンポーネントに直接定義しても行けた

Dictionaryには使えなかった

 これDictionaryにも使えるかなと思いましたが、使えませんでした。

 単純に使うとシリアライズ出来ず、Serializable属性を付けるとInspectorには表示出来ますが値を保持出来ませんでした。

public class MyComponent : MonoBehaviour
{
    [SerializeField] Dictionary<int, string> dic1;

    [SerializeField] MyDicM<int, string> dic2;

    void Reset()
    {
        dic2 = new MyDicM<int, string>();
        dic2.Add(0, "message1");
        dic2.Add(1, "message2");
        dic2.Add(2, "message3");
    }

    void Start() => Debug.Log(dic2.Count); // Reset後なら3が帰るが、シーンを読み直すと0になる
}
[System.Serializable]
public class MyDicM<TKey, TValue> : Dictionary<TKey, TValue> { }

f:id:tsubaki_t1:20191101225643j:plain
Dictionaryの内容が格納されていないか、表示されない

感想

 これでコールバックを用意する度にUnityEventの種類を増やし続ける作業に終止符が…?

 なお検証はアルファ版を使用した為、仕様が変わる可能性があります。

【Unity】SerializeReference、Inspectorウィンドウでinterfaceを使用する

f:id:tsubaki_t1:20191031221044j:plain

 Unity 2019.3で利用可能になった SerializeReference Attributeについて紹介します。

SerializeReference

 SerializeReferenceで任意のInterfaceを継承したクラスや構造体をコンポーネントに格納し、SceneやPrefabにシリアライズしたり、Inspectorで編集したりします。

 正しくは、値としてではなく参照型としてC#シリアライズ出来るようになります。これによりinterfaceをスロットとして任意のクラスを格納することが可能になるみたいです。

interfaceをシリアライズ

 まずはinterfaceとクラスを定義します。interfaceはアクションを行う役割を持っており、処理の進行待機としてコルーチンを使用する予定です。

public interface ITankAction
{
    IEnumerator Process(Transform transform);
}

 処理の中身を実装します。前方(上)に移動する処理で、publicとして処理時間と移動距離が含まれます。

// 前方に移動するアクション
[Serializable]
public class MoveForward : ITankAction
{
    public float time = 1;        // 処理時間
    public float distance = 1;  // 移動距離

    IEnumerator ITankAction.Process(Transform transform)
    {
        float endTime = Time.timeSinceLevelLoad + time;
        Vector3 startPos = transform.position;
        Vector3 endPos = startPos + transform.up * distance;

        while (endTime > Time.timeSinceLevelLoad)
        {
            var diff = (endTime - Time.timeSinceLevelLoad) / time;
            transform.position = Vector3.Lerp(endPos, startPos, diff);
            yield return null;
        }
        transform.position = endPos;
    }
}

 これをコンポーネントで使用してみます。コンポーネントITankAction tankActionと定義し[SerializeReference]Attributeを追加します。またtankActionにはMoveRotateクラスをnewしておきます。MoveForwardクラスはUnityEngine.ObjectMonoBehaviourも継承していないので、そのままnew で構築が可能です。これでTankControllerにはITankActionを継承した何らかのクラスを追加し、それをInspectorで編集出来るようになりました。

public class TankController : MonoBehaviour
{
    [SerializeReference]
    ITankAction tankAction = new MoveRotate();

    IEnumerator Start()
    {
        yield return StartCoroutine( tankAction.Process(transform));
    }
}

f:id:tsubaki_t1:20191031222058j:plain
登録したクラスの中身を編集できる

複数のアクションがある場合にアクションを差し替えられるようにする

 この ITankAction tankAction = new MoveRotate(); ですがシリアライズされてしまうのでコードの変更を無視されるようになります。もし異なる型を注入したい場合、拡張を実装する必要があります。下は複数のアクションがある場合の例です。

 まず、移動・回転・待機のアクションをつけてみました。

[Serializable]
public class MoveForward : ITankAction
{
    public float time = 1;        // 処理時間
    public float distance = 1;  // 移動距離

    IEnumerator ITankAction.Process(Transform transform)
    {
        float endTime = Time.timeSinceLevelLoad + time;
        Vector3 startPos = transform.position;
        Vector3 endPos = startPos + transform.up * distance;

        while (endTime > Time.timeSinceLevelLoad)
        {
            transform.position = Vector3.Lerp(endPos, startPos,  (endTime - Time.timeSinceLevelLoad) / time);
            yield return null;
        }
        transform.position = endPos;
    }
}

[Serializable]
public class MoveRotate : ITankAction
{
    public float time = 1;
    public float angle = 90;

    IEnumerator ITankAction.Process(Transform transform)
    {
        float endTime = Time.timeSinceLevelLoad + time;
        Quaternion startRot = transform.rotation;
        Quaternion endRot = startRot * Quaternion.AngleAxis(angle, -Vector3.forward);

        while (endTime > Time.timeSinceLevelLoad)
        {
            var diff = (endTime - Time.timeSinceLevelLoad) / time;
            transform.rotation = Quaternion.Lerp(endRot, startRot, diff);
            yield return null;
        }
        transform.rotation = endRot;
    }
}

[Serializable]
public class WaitTank : ITankAction
{
    public float sec = 1;
    public IEnumerator Process(Transform transform)
    {
        yield return new WaitForSeconds(sec);
    }
}

 次に使用する側です。こちらは拡張コードを実装しておきます。アクションの注入は「ContextMenu」で行っています。アクションを切り替えるとTankControllerのアクションが利用できるパラメーターが変化していることを確認出来ます。

public class TankController : MonoBehaviour
{
    [SerializeReference]
    ITankAction tankAction = new MoveRotate();

    IEnumerator Start()
    {
        yield return StartCoroutine( tankAction.Process(transform));
    }

    [ContextMenu("Act/Move")] void AddMove() => tankAction = new MoveForward();
    [ContextMenu("Act/Rotate")] void AddRotate() => tankAction = new MoveRotate();
    [ContextMenu("Act/Wait")] void AddWait() => tankAction = new WaitTank();
}

f:id:tsubaki_t1:20191031223523g:plain
モードを切り替わる度に、アクションに使用するプロパティが切り替わる

アクションを複数登録する

 最後にアクションを複数登録できる形にしてみます。アクションの中身は同じですが、TankControllerを少し修正して規定の動きを行うようにする感じです。やっていることはITankActionをリストに変更しているだけです。これで同じインターフェースを継承したクラスを複数登録することが出来ます。

public class TankController : MonoBehaviour
{
    [SerializeReference]
    List<ITankAction> tankActions = new List<ITankAction>();

    IEnumerator Start()
    {
        foreach( var act in tankActions)
            yield return StartCoroutine(act.Process(transform));
    }

    [ContextMenu("Act/Move")] void AddMove() => tankActions.Add(new MoveForward());
    [ContextMenu("Act/Rotate")] void AddRotate() => tankActions.Add(new MoveRotate());
    [ContextMenu("Act/Wait")] void AddWait() => tankActions.Add(new WaitTank());
}

f:id:tsubaki_t1:20191031224701g:plain
アクションを登録する作業風景

f:id:tsubaki_t1:20191031224826g:plain
アクションでタンクを動かす

感想

 正直、任意のinterfaceを継承したMonoBehaviourもしくはScriptableObjectを登録出来るオブジェクトフィールドをInspectorに表示する機能と思ってました。

 今まではアクションを行う処理をSceneもしくはprefabにシリアライズする場合、UnityEngine.Objectを継承しなければいけなかった(つまり複数の継承が出来ない)のが、インターフェース単位デシリアライズ出来るようになったので楽出来る所もあるかなという印象です。ほしかった機能とは違いますが、まぁコレはこれで。

関連

qiita.com

【Unity】Device Simulatorでノッチとセーフエリアの対策

f:id:tsubaki_t1:20191030233736j:plain

 Unity 2019.3で確認できるDevice Simulatorを使用して、ノッチやセーフエリアといった色々と面倒くさい問題の対策してみます。

Device Simulator

 Device Simulator はUnity 2019.3で追加されたパッケージです。このパッケージを使用するとエディター上でノッチの位置やセーフエリアの位置を大雑把に確認できる機能です。この機能の素晴らしい事はエディターでScreen.SafeAreaを確認出来る点です。この機能がなければ実際に動かさないと分からない部分も、この機能を使用することで確認することが出来ます。ということでSafeAreaやNotchの内容を確認して、安全な形でUIを構築してみます。

f:id:tsubaki_t1:20191030233853j:plain
被らない範囲を視覚的に確認できる

 また、この画面はGameViewであることから察せられる通り、普通に端末解像度やセーフエリアを維持しつつゲームを再生出来ます。ポップアップ等、ゲーム画面外にあるという前提で作成されている演出をSafeAreaコミコミで作ると正しく表示されないことがあるので、そのあたりも含めてよく確認するのが良さそうです。
 実際、ここで作成したUIはゲームにビルドしても想定通りに表示されました。

f:id:tsubaki_t1:20191030235843g:plain
DeviceSimulatorで調整した後にビルドしたもの。セーフエリア内でちゃんと表示出来ている

インストール

 Device Simulator のインストールはいつも通りのPackageManagerからです。現状はPreviewなのでShowPreviewPackageを有効にする必要があります。

f:id:tsubaki_t1:20191030223752p:plain
PackageManagerから選択してInstall

 パッケージ導入後はGameViewをSimulation Modeに切り替えます。これでGameViewが表示する内容を、ゲーム端末と同じ解像度、同じDPIに切り替えることが出来ます。またノッチの位置やピクセルが潰される位置をグラフィカルに表現してくれます。  その際にSafeAreaも使用可能にしてくれます。

f:id:tsubaki_t1:20191030223958g:plain
GameViewを切り替える

セーフエリア情報を元にUIを調整可能にする

f:id:tsubaki_t1:20191030223655j:plain

 ノッチの位置が確認できるぜヤッターだけでは片手落ちです。ノッチの位置や大きさは端末によって千差万別。iPhoneのように殆ど画面上をノッチが埋めている端末もあれば、幾つかのAndroid端末のようにほんの少ししかノッチで埋めていない端末もあります。セーフエリアの位置も色位rと微妙に違います。なので、セーフエリアの位置を元にUIを調整出来る方法を考えてみます。

 まずセーフエリアの位置を元にUIを配置します。UIの場合、親のRectTransfromのサイズSafeAreaの大きさに調整してやれば概ね問題は解決します。アンカーの位置さえ調整すれば良いわけですから。ということで、親となるRectTransformのサイズをSafeAreaの位置に合わせます。この時、RectTransformのLeft、Rightは0にしてください。

using UnityEngine;

[RequireComponent(typeof(RectTransform))]
[ExecuteAlways]
public class SafeAreaPadding : MonoBehaviour
{
    private DeviceOrientation postOrientation;

    void Update()
    {
        if (Input.deviceOrientation != DeviceOrientation.Unknown && postOrientation == Input.deviceOrientation)
            return;

        postOrientation = Input.deviceOrientation;

        var rect = GetComponent<RectTransform>();
        var area = Screen.safeArea;
        var resolition = Screen.currentResolution;

        rect.sizeDelta = Vector2.zero;
        rect.anchorMax = new Vector2(area.xMax / resolition.width, area.yMax / resolition.height);
        rect.anchorMin = new Vector2(area.xMin / resolition.width, area.yMin / resolition.height);
    }
}

 このコードをCanvas直下の適当なRectTransformに設定すれば、RectTrasnfromはSafeAreaの大きさになります。あとはSafeAreaのサイズになったUIの子としてアンカーを調整してやれば、セーフエリア内を出ない形でUIを配置出来ます。

https://user-images.githubusercontent.com/1644563/67865571-63e4ae80-fb1f-11e9-90f5-64b715e8b379.gif

f:id:tsubaki_t1:20191030234539j:plain
セーフエリアの範囲にRectTransformを調整

 なお、内部でアンカーを使用しているのは単純に「Canvas Scalerでサイズが調整されても問題なく動作する」事を保証するためです。

ノッチの範囲だけ回避したい

 ノッチの部分だけピンポイントで回避したい…という強気の場合はScreen.cutoutsが使用できます。ただしAndroidのCutoutの位置は必ずしも一つではない点に注意してください。ノッチは必ずしも上にある訳ではないですし、そもそもノッチという形をとっていない端末も存在します。またノッチが左右どちらかに偏っている場合もあります。

f:id:tsubaki_t1:20191031122549j:plain
おや、ノッチの位置が

 とはいえ、今回はノッチは上に一つだけあるという前提の実装です。

 例えば下のコードでノッチの左側と右側に一致するRectTransformを構築します。これにより、ノッチ外の部分のUIを構築出来ます。あとはRectTransformのサイズをはみ出ないようにUIを実装すれば、ノッチでUIが隠れるといった事を回避出来ます。

using UnityEngine;

[ExecuteAlways]
public class NotchPadding : MonoBehaviour
{
    [SerializeField] RectTransform left, right;

    void Update()
    {
        if (Screen.cutouts[0] == null || left == null || right == null)
            return;

        var resolition = Screen.currentResolution;

        left.anchorMin = new Vector2(0, Screen.cutouts[0].yMin / resolition.height);
        left.anchorMax = new Vector2(Screen.cutouts[0].xMin / resolition.width, 1);

        right.anchorMin = new Vector2(Screen.cutouts[0].xMax / resolition.width, Screen.cutouts[0].yMin / resolition.height);
        right.anchorMax = new Vector2(1, 1);
    }
}

https://user-images.githubusercontent.com/1644563/67867527-73b1c200-fb22-11e9-973d-e4b52d619087.gif

f:id:tsubaki_t1:20191030235404j:plain
ノッチに被らない形でUIを配置

対応する端末が一覧に無い場合

 対応する端末が一覧に無い場合、ノッチの設定を自分で追加することも出来るみたいです。com.unity.device-simulator/com.unity.device-simulatorフォルダ内に設定と使用する画像ファイルがあり、コレを編集することで独自のレイアウト設定が作れます。

f:id:tsubaki_t1:20191030234018j:plain
セーフエリア・ノッチの設定と、その際に表示する描画範囲の画像

関連

blogs.unity3d.com

www.gaprot.jp

www.fast-system.jp

【Unity】ISystemStateComponentDataという機能

今回は微妙に詳細な説明のないISystemStateComponentDataという機能を紹介します。

EntityがDestroyされても破棄されないコンポーネント

 ISystemStateComponentDataはすごく特殊なデータ構造です。具体的には、ISystemStateComponentDataが存在するEntityは、EntityをDestoryされても破棄されなくなるというルールを持っています。

 例えば簡単なEntityを作り、それを破棄してみます。通常であればそのままEntityのIDがリサイクルされるだけなのですが、Entityが破棄されずに残るのを確認出来ます。

// 適当なISystemStateComponentData 
public struct MyData : ISystemStateComponentData { }
    // Entityを作ってから破棄する処理
    IEnumerator Start()
    {
        var dst = World.Active.EntityManager;
        var entity = dst.CreateEntity(typeof(MyData), typeof(LocalToWorld), typeof(Translation));

        yield return new WaitUntil(() => Input.GetKeyDown(KeyCode.D)); // Dキーを押すまで待機

        // MyData以外のComponentDataが破棄される。Entityは破棄されない
        dst.DestroyEntity(entity); 
    }

f:id:tsubaki_t1:20191027124742j:plain
ISystemStateComponentDataがあるEntityは破棄されない

 ISystemStateComponentDataのあるEntityを消すには、ISystemStateComponentDataをRemoveする必要があります。全てのISystemStateComponentDataが破棄されるとEntitiyも破棄されリサイクルに回されます。

    IEnumerator Start()
    {
        var dst = World.Active.EntityManager;
        var entity = dst.CreateEntity(typeof(MyData), typeof(LocalToWorld), typeof(Translation));

        yield return new WaitUntil(() => Input.GetKeyDown(KeyCode.D));
        dst.DestroyEntity(entity); // MyData以外のComponentDataが破棄される

        yield return new WaitUntil(() => Input.GetKeyDown(KeyCode.C));

        dst.RemoveComponent<MyData>(entity); // 改めてEntityが破棄される
    }

Entity生成・破棄のタイミングで処理を一回実行する

 ISystemStateComponentDataの応用で、ComponentDataを生成(追加)したタイミングと破棄したタイミングに処理を実行する方法を考えてみます。本来ならコールバックでこういった事をやりたい所ですが、コールバックはECSの思想とは合ってないので、少し回りくどい方法になっています。使用するのは通常のComponentDataであるBehaviourコンポーネントと、ISystemStateComponentDataのBehaviourInitializedです。

public struct BehaviourInitialized : ISystemStateComponentData{}

public struct Behaviour : IComponentData {}

初期化時の処理(Awake的な)

 最初の状態はBehaviourのみEntityに存在する状態を作ります。これは単純にBehaviourコンポーネントのみをEntityに追加すれば良いです。

f:id:tsubaki_t1:20191027171846j:plain

 上の状態のEntityはEntityQuery的にはBehaviourがあるがBehaviourInitializedが無いという条件で取得することが可能です。ここで見つけたEntity一覧には初期化処理を行い、BehaviourInitializedを追加して初期化処理に呼ばれないようにします。(onStartQuery.CalculateChunkCount() > 0)は、このシステムが

 他のシステムで初期化完了までに処理を行いたい場合、Entityの生成から下のシステムが呼ばれる間にシステムを挿入します。

    protected override void OnCreate()
    {
         // BehaviourはあるがBehaviourInitializedが無いEntity
        onStartQuery = GetEntityQuery(ReadOnly<Behaviour>(), Exclude<BehaviourInitialized>());
    }

    protected override void OnUpdate()
    {
        Entities.With(onStartQuery).ForEach((Entity entity) =>
        {
            // 初期化処理を記述
            Debug.Log($"On Create Enemy {entity.Index}");
        });
        // BehaviourInitializedが無いEntityにBehaviourInitializedを一括追加
        EntityManager.AddComponent<BehaviourInitialized>(onStartQuery);
    }

初期化後の毎フレーム実行する処理(Update的な)

 初期化が完了すれば、下のようにBehaviourBehaviourInitializedが揃った状態になります。もしアップデート処理を行う場合、この2つが揃っていることを確認すれば安全に初期化済みのEntityを操作することが出来ます。

f:id:tsubaki_t1:20191027173108j:plain

protected override void OnUpdate()
{
    Entities.WithAnyReadOnly<Behaviour,BehaviourInitialized>().ForEach((Entity entity) =>
    {
        Debug.Log($"On Update Enemy {entity.Index}");
    });
}

破棄時の処理(OnDestroy的な)

 Entityが破棄された時の処理です。Entityが破棄されてもISystemStateComponentDataであるBehaviourInitializedがあるので破棄されません。それ以外の全てのコンポーネントが破棄された状態になります。つまり、BehaviourInitializedのみのEntityを検索すれば、破棄されてクリーンアップ待ちのEntityを見つけることが出来ます。後はBehaviourInitializedを破棄すれば、破棄処理を完了出来ます。

 こちらもクリーンアップする前に他の処理を挟めば、クリーンナップ処理を他のシステム側で行うことが出来ます。

f:id:tsubaki_t1:20191027174017j:plain

protected override void OnCreate()
{
    // BehaviourInitializedはあるがBehaviourが無いEntity
    onDestroyQuery = GetEntityQuery(Exclude<Behaviour>(), ReadOnly<BehaviourInitialized>());
}

protected override void OnUpdate()
{
    Entities.With(onDestroyQuery).ForEach((Entity entity) =>
    {
        Debug.Log($"On Destroy Enemy {entity.Index}");
    });
    // クェリーからBehaviourInitializedを一括削除
    EntityManager.RemoveComponent<BehaviourInitialized>(onDestroyQuery);
}

その他

  • この例ではISystemStateComponentDataは値を持っていませんが、普通に持てます。データを使用する場合はIComponentDataと同じように取得します。
  • ISystemStateComponentDataのアプローチはOnDestroyと異なり、破棄された後に破棄された座標を確認といった事が出来ません。正確にはISystemStateComponentDataに座標を突っ込んでおけば取得出来ますが、小頻度の更新はあまり推奨されません。
    キャラクターの死亡演出の出力やスコアの計上は破棄するシステム側が責任を持ってやるべきです(もしくは破棄されたフラグを付けて、他のシステムに任せる)
  • SubSceneのコンバージョンワークフローでISystemStateComponentDataを追加すると、追加されないです。コレはバグかもしれませんが。