テラシュールブログ

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

【Unity】GameObjectもECSも使いたい Hybrid ECSについて

 ECSは現状正直な所、物量が必要な部分をECSで試して大まかな部分は既存のMonoBehaviourやUnityEngine系のコンポーネントを使うのがベターな回答です。今回はECSとGameObjectを連携させる手法についてを紹介します。

検証バージョン:Unity 2019.3、Entities 0.3.0 preview 4

Hybrid ECS

 ConvertToEntityはGameObjectをEntityへ変換してくれるコンポーネントですが、ConvertAndInjectGameObjectを使用すると変換後にGameObjectを削除する代わりに、GameObjectやComponentをEntityへ登録してくれます。これでEntityからGameObjectやComponentへ参照が可能になり、2つセットで運用可能になります。

f:id:tsubaki_t1:20191208185211p:plain
GameObjectとEntityの連携

 この時、格納されるデータはStructではなくClassであり、内部での扱いはだいぶ違います。その辺りは後述のMnaagedComponentData辺りで。

GameObjectとEntityの同期

 Hybrid ECSの有益な使い方の一つはGameObjectとの同期です。ECS側で複雑な計算を行いデータを同期、計算結果をMonoBehaviourで使用します。

 なおGameObjectとEntityの同期は一方通行が望ましいです。つまりEntityの座標をGameObjectに同期するか、GameObjectの座標をEntityに同期するのかという話です。前者の場合は必然的にECSのSystemによる管理、後者の場合はMonoBehaviourの管理になります。

f:id:tsubaki_t1:20191208200629p:plain
同期は一方通行

 情報を同期するタイミングはPlayerLoopをカスタムしていなければ、InitializationSystemGroupMonoBehaviourのUpdateSimulationSystemGroupPresentationSystemGroupMonoBehaviourのLateUpdateのどこかのタイミングに設定します。個人的なオススメは SimulationSystemGroupでジョブを発行して次のInitializationSystemGroupで同期です。情報は常に1フレーム遅れる事になりますが、そういうものと割り切って使えばクセがなく使いやすいです。

f:id:tsubaki_t1:20191208190552p:plain
ジョブの発行から取得はフレームを跨ぐと色々と楽

同期のサンプルコード

f:id:tsubaki_t1:20191208195815p:plain
処理の流れ

 例えば下のようなシステムを考えます。PlayerTagを持つキャラクターとの距離を確認してDistanceFromPlayerに格納するといったものです。

/// <summary> PlayerTagを持つEntityとの距離 </summary>
public struct DistanceFromPlayer : IComponentData{ public float Value;}

/// <summary> PlayerTagとDistanceFromPlayerの距離をValueに格納するシステム</summary>
public class DistanceCheckSystem : JobComponentSystem
{
    // 中略

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var playerPos = playerQuery.GetSingleton<LocalToWorld>().Position;
       // 距離を格納
        return  Entities
             .ForEach((ref DistanceFromPlayer distance, in LocalToWorld pos) =>  {
                 distance.Value = math.distance(pos.Position, playerPos);
             } ).Schedule(inputDeps);
    }
}

f:id:tsubaki_t1:20191208192806j:plain
距離がDistanceFromPlayer.Valueに格納される

 このままだとECSでは使えてもMonoBehaviourでは面倒くさいので、計算結果をMonoBehaviourに同期します。これで他のMonoBehaviourから見ると distanceFromPlayerAuthoring.Distance でプレイヤーとの距離を取得出来るようになります。
 もしGameObject→Entityにしたい場合は、authoring.Distance = distance.Value; を逆にします。

[DisallowMultipleComponent]
[RequiresEntityConversion]
public class DistanceFromPlayerAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
    /// <summary> プレイヤーとの距離 </summary>
    public float Distance { get; set; }

   // 中略
}

// DistanceFromPlayerをDistanceFromPlayerAuthoringに反映するシステム
[UpdateInGroup(typeof(InitializationSystemGroup))]
public class SetDistanceDataToAnimator : ComponentSystem
{
    protected override void OnUpdate()
    {
        // DistanceFromPlayer の値をDistanceFromPlayerAuthoring へ反映
        Entities.
            ForEach((DistanceFromPlayerAuthoring authoring, ref DistanceFromPlayer distance) => {
                authoring.Distance = distance.Value;
            });
    }
}

 これで他のMonoBehaviourからは下のようなコードでPlayerとの距離を取得出来るようになりました。本当はEntityManagerで個別に取っても良いのですが、シーケンシャルに反映させたほうが最終的に安いので。

    void Update()
    {
        Debug.Log(fromPlayer.Distance);
    }

全文

MonoBehaviourとComponentDataの同期 · GitHub

GameObjectもしくはEntityの破棄について

 GameObjectとEntityを同期する方法はConvertToEntityを使用することですが、このコンポーネントは実はEntityの破棄にあわせてGameObjectを破棄してくれない、もしくはGameObjectの破棄に合わせてEntityを破棄してくれないです。

 このため、単純にプレイヤーやUIに使用した場合、シーンの再ロードでプレイヤーの数が増えたり、スコアを表示するUIが2つあったりといった挙動がありえます。つまりEntityがリークします。またEntityをJobSystemから破棄してもGameObjectが破棄されないので、対象により挙動を変えるという非常に面倒くさいトリックが必要になります。

f:id:tsubaki_t1:20191208201445p:plain
先代勇者の魂が残ってるせいで勇者を操作出来ない…なんてことも

 それを回避するにはGameObjectの破棄を検知して自動でEntityを破棄する、またはその逆といったコードが必要になります。これは下のコンポーネントを使用することで実現出来ます。コードは長いのでリンクのみ。

GameObjectを破棄するとEntityも破棄される。もしくはその逆 · GitHub

f:id:tsubaki_t1:20191208201909j:plain
GameObjectの破棄を同期する

 やっていることはISystemStateComponentDataでEntityの破棄を検出して紐付いているGameObjectを破棄、もしくはAuthoringのOnDestroyに反応してEntityを破棄…としているだけです。
 ManagedComponentDataが無かった時はDictionaryとInstanceIDで紐付けていましたが、0.2.0で不要になりました。

クラスを管理する「Managed」なComponentData

 Hybrid ECSはマネージドなメモリを持つデータをEntityに格納することで成り立ちます。この実装は今まではMonoBehaviour専用でしたが、Entities 0.2.0からclassのIComponentDataも使用可能になりました。 ClassのComponentDataは普通に参照型なので、内部で外部参照したり色々と行うことが出来ます。

f:id:tsubaki_t1:20191208205555p:plain
「Managed」Memoryで管理されるclass な IComponentData

 当然MonoBehaviourを使用した場合と同じ制約「JobSystemでは使えない」「Burstが使えない」「シーケンシャルなメモリアクセスが出来ない」の制約はありますが、量が少なければ大した問題ではないでしょう。これでシステムに色々とステートを格納しなくても良くなりました。

// 文字列をComponentに格納
[GenerateAuthoringComponent]
public class MessageData : IComponentData{ public string Value; }

public class ShowMessage : JobComponentSystem
{
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        // 実行にはWithoutBurstとRunで行う必要がある
        Entities
            .WithoutBurst()
            .ForEach((MessageData data) =>{
                UnityEngine.Debug.Log(data.Value);
            }).Run();

        return inputDeps;
    }
}

 なおHybrid ECSでEntityに登録したMonoBehaviourやUnityEngineベースのコンポーネント、そしてclassのIComponentDataはChunkに格納されず、独自のバッファに格納されEntity経由で参照されます。Hybrid ECSを使用した場合でもstructのIComponentDataのアクセスはリニアなアクセスを約束されているので、速度は低下しません。

f:id:tsubaki_t1:20191208204155p:plain
Chunk外で管理されている

ISharedComponentDataとの違い

 Managedなデータを保持出来るという点では、似たような機能にISharedComponentDataというものがあります。これは今後はバッチ処理したいときに使用するという棲み分けになるんじゃないかんと思います。

 SharedComponentDataは保有するデータの種類でチャンクを分割します。これは言い換えれば同じSharedComponentDataを持っているEntityが連続して配置されるということで、連続して同じ処理を実現する(バッチ処理)に効率的です。システム側からすると連続して同じデータがある事が保証されるので、Burstによる最適化も行いやすくなります。

 実際、AIや状態異常等、挙動が変わる間隔が広い動作についてはComponentDataの中身を見てifで分岐するより、タグComponentDataを追加したりSharedComponentData の値の変更によって動作を切り替えるほうが理にかなっているという話もあります。

f:id:tsubaki_t1:20191208205440p:plain
SharedComponentDataで挙動を切り替える

 ただしChunkが分割されるということは、SharedComponentDataの値が変わる度にデータはソートのために他のChunkへコピーされるという事でもあります。このコピーはそれなりに効率的ではありますが、完全に無視出来るほどに安くはないので、更新頻度が高い場合は避けた方が良いです(毎フレーム変わるような場合) 。また座標のようにユニークな値を大量に持つのも避けるべきです。Chunkがスカスカになります。

 その点Managed Component Dataはチャンク移動無しにComponentDataの値を変更出来、ユニークな値を持っていてもChunkはスカスカにはなりません。ただしデータをソートしてくれないので、ComponentDataの中身に応じて処理を切り替えるといった場合では効率は劣ります。*1

おまけ

 ManagedComponentDataが出来たので、ScriptableObjectをEntityに格納するという力技も出来るようになったみたいです。やって良いのかはまた別として、これでシステムに情報を渡す為に四苦八苦しなくても良くなる気がしなくもないです。あとシステムの値を更新するのが楽です。
 ただLiveLinkと相性が悪そうなので、現実的にはClassなComponentDataに直接データを使用するのが現実的かもしれません。

/// <summary>
/// ScriptableObject (IComponentDataを継承)
/// </summary>
public class MyData : ScriptableObject, IComponentData
{
    public string Value;
}

/// <summary>
/// AuthoringでScriptableObjectを登録
/// </summary>
public class MessageData : MonoBehaviour, IConvertGameObjectToEntity
{
    public MyData data;

    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        dstManager.AddComponentData(entity, data);
    }
}

public class LogTest : JobComponentSystem
{
    EntityQuery query;
    MyData data;

    protected override void OnCreate()
    {
        query = GetEntityQuery(typeof(MyData));
    }

    protected override void OnStartRunning()
    {
        Entities
            .WithoutBurst()
            .ForEach((MyData d) => data = d)
            .Run();
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        // システムがScriptableObjectの値を参照

        Debug.Log(data.Value);

        return inputDeps;
    }
}

関連

learning.unity3d.jp

learning.unity3d.jp

*1:まぁメインスレッドでのアクセス限定で効率もクソもないのですが