Unity ECSとGameObjectを連携させる
はじめに
既存のGameObjectとECSの連携について、プレビュー版の時の解説をされている記事はいくつか見かけたのですが、正式版だと少しやり方が変わってるようなので、勉強がてら記事にしてみました。
今回はUnity公式のSpawner Exampleをベースとして、SubScene内にスポーンした敵オブジェクトを従来のGameObjectで構成されたプレイヤーオブジェクトを追いかけるものを作っていきます。
事前準備
Player
まずPlayer
オブジェクトを用意しておきます。RigidbodyをアタッチしたCubeを作成し、WSADで移動できるようにしておきます。
public class PlayerMovement : MonoBehaviour
{
[SerializeField] private Rigidbody playerRigidbody;
[SerializeField] private float speed = 10.0f;
private void FixedUpdate()
{
var x = Input.GetAxis("Horizontal");
var z = Input.GetAxis("Vertical");
var move = new Vector3(x, 0, z);
playerRigidbody.MovePosition(playerRigidbody.transform.position + move * (speed * Time.deltaTime));
}
}
Enemy
Spawner
がEnemy
が付いたPrefabをスポーンできるようにしておきます。Enemy
はtargetPosition
の方に向かって移動するようになっているので、targetPosition
にPlayer
の位置を更新し続けることでPlayer
を追跡するようにしておきます。
public struct Enemy : IComponentData
{
public float3 targetPosition;
public float speed;
}
public class EnemyAuthoring : MonoBehaviour
{
[SerializeField] public float speed = 1.0f;
}
public class EnemyBaker : Baker<EnemyAuthoring>
{
public override void Bake(EnemyAuthoring authoring)
{
var enemy = new Enemy
{
speed = authoring.speed,
targetPosition = new float3(0,0,0)
};
AddComponent(GetEntity(TransformUsageFlags.None), enemy);
}
}
public partial struct EnemySystem : ISystem
{
public void OnCreate(ref SystemState state) { }
public void OnDestroy(ref SystemState state) { }
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
new EnemyMovementJob
{
DeltaTime = Time.deltaTime
}.ScheduleParallel();
}
}
[BurstCompile]
[StructLayout(LayoutKind.Auto)]
public partial struct EnemyMovementJob : IJobEntity
{
public float DeltaTime { get; set; }
private void Execute([ReadOnly] ref Enemy enemy, ref LocalTransform transform)
{
var direction = enemy.targetPosition - transform.Position;
var distance = math.length(direction);
if (!(distance > 0.1f)) return;
var move = math.normalize(direction) * enemy.speed * DeltaTime;
transform.Position += move;
}
}
GameObject → ECS
GameObjectからECSへのアクセスは比較的楽です。World.DefaultGameObjectInjectionWorld.EntityManager
を使うとMonoBehaviourからEntityManager
を取得できます。EntityManager
からEntity検索用のクエリを組み立てたあと、それを通じてEntityを取得できます。
public class PlayerPositionSender : MonoBehaviour
{
[SerializeField] Transform playerTransform;
private void Update()
{
var entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
var query = entityManager.CreateEntityQuery(typeof(PlayerPositionReceiver));
var entity = query.GetSingletonRW<PlayerPositionReceiver>();
entity.ValueRW.targetPosition = playerTransform.position;
}
}
今回はPlayerの位置をECSに送るためのPlayerPositionSender
と、ECS側でそれを受け取るPlayerPositionReceiver
を作っています。
public partial struct PlayerPositionReceiverSystem : ISystem
{
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<PlayerPositionReceiver>();
state.EntityManager.CreateSingleton<PlayerPositionReceiver>(); // シングルトンとして生成しておきます
}
public void OnDestroy(ref SystemState state)
{
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var targetPosition = SystemAPI.GetSingleton<PlayerPositionReceiver>().targetPosition;
new PlayerPositionReceiverJob
{
TargetPosition = targetPosition
}.ScheduleParallel();
}
}
[BurstCompile]
[StructLayout(LayoutKind.Auto)]
public partial struct PlayerPositionReceiverJob : IJobEntity
{
public float3 TargetPosition;
private void Execute(ref Enemy enemy)
{
enemy.targetPosition = TargetPosition;
}
}
もちろんPlayerPositionSender
からEnemyがついたEntityを検索し、直接値をセットしていくこともできます。ただその場合だと、Entityの数が増えていくのに比例してMonoBehaviour上での計算時間が増えていくため、ECSの恩恵をあまり受けることができません。
以下のように次々とスポーンするEnemy
をGameObjectのPlayer
に追跡させることができました。
ECS → GameObject
逆の場合は少々面倒です。Managed Componentを使ってECS側からGameObjectにアクセスします。まずはPlayer
オブジェクトを保持するだけのPlayerObjectRef
を作ります。
public class PlayerObjectRef : MonoBehaviour
{
public GameObject playerObject;
}
次にこのPlayerObjectRef
をManagedComponentに変換する処理と、変換後のPlayerObjectRefManaged
を作ります。
public class PlayerObjectRefManaged : IComponentData
{
public GameObject playerObject;
public PlayerObjectRefManaged(){} // ManagedComponentは引数なしのコンストラクタが必要
}
public partial struct PlayerObjectRefInitSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state) {}
public void OnUpdate(ref SystemState state)
{
state.Enabled = false; // 一回だけの実行でいいので無効化しておく
InitPlayerObjectRefManaged(ref state);
}
[BurstCompile]
public void OnDestroy(ref SystemState state) {}
private void InitPlayerObjectRefManaged(ref SystemState state)
{
var go = GameObject.Find("Player");
var playerObjectRef = go.GetComponent<PlayerObjectRef>();
var playerObjectRefManaged = new PlayerObjectRefManaged
{
playerObject = playerObjectRef.playerObject
};
var entity = state.EntityManager.CreateEntity();
state.EntityManager.AddComponentData(entity, playerObjectRefManaged);
}
}
GameObject.Find
とGetComponent
を使ってPlayerObjectRef
を取得し、それをPlayerObjectRefManaged
に詰めてEntityManager
に登録しています。その後各Enemy
のEntityにPlayer
の位置を送る処理を書きます。
public partial struct PlayerObjectRefSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<PlayerObjectRefManaged>();
}
public void OnUpdate(ref SystemState state)
{
var playerObjectRefManaged = SystemAPI.ManagedAPI.GetSingleton<PlayerObjectRefManaged>();
var targetPosition = playerObjectRefManaged.playerObject.transform.position;
new SetTargetPositionJob
{
TargetPosition = targetPosition
}.ScheduleParallel();
}
[BurstCompile]
public void OnDestroy(ref SystemState state) {}
}
[BurstCompile]
public partial struct SetTargetPositionJob : IJobEntity
{
public float3 TargetPosition { get; set; }
private void Execute(ref Enemy enemy)
{
enemy.targetPosition = TargetPosition;
}
}
これでGameObject → ECSの時と同様に、Enemy
にPlayer
を追跡させることができました。
ManagedComponentの注意点
ManagedComponentを使うとECS側からGameObjectの参照を取ることができますが、注意することがあります。
- ManagedComponentを使用している関数はBurstCompileの対象にできない
- 通常のComponent(UnmanagedComponent)よりもアクセス効率が悪い
一つ目に関しては、ManagedComponentにアクセスする部分を限定し、それ以外の部分を細く区切ってBurstCompileの対象にしたり、Jobにすることで影響を最小限にできます。
二つ目に関しては、ManagedComponentが通常のComponentと管理の仕方が違うことに起因しています。以下DeepL訳
Unmanaged componentsとは異なり、Unityはmanaged componentsをチャンクに直接格納しません。その代わりに、UnityはWorld全体で1つの大きな配列に格納します。そしてチャンクには、関連するmanaged componentsの配列インデックスが格納されます。つまり、Entityのmanaged componentにアクセスすると、Unityは余分なインデックス検索を処理します。このため、managed componentsはunmanaged componentsよりも最適化されません。
Managed componentsのパフォーマンスへの影響から、可能であれば代わりにunmanaged componentsを使用するべきです。
そのためManagedComponentを使う機会をできるだけ減らすことが望ましいです。
比較
ではGameObject → ECSとECS → GameObjectとではパフォーマンスに差があるのか見てみたいと思います。両者ともに差分がないスポーン処理やEnemy
の移動処理についてはJobSystemで最適化してあり、Enemy
を一万体スポーンさせた時を計測してみます。
GameObject → ECS
GameObjectからECSへアクセスするPlayerPositionSender
の処理時間が0.068ms、各Enemy
へ値をセットするPlayerPositionReceiverJob
の処理時間が0.006msだったため、合計0.074msです。
ECS → GameObject
ECSからGameObjectへアクセスするPlayerObjectRefSystem
の処理時間が0.066ms、各Enemy
へ値をセットするSetTargetPositionJob
の処理時間が0.019msだったため、合計0.085msです。
結果
PlayerPositionReceiverJob
とSetTargetPositionJob
とで0.013msの差がありましたが、両方とも実装が全く同じなため、おそらく誤差だと考えられます。そのため今回のような簡単な処理では、GameObject → ECSとECS → GameObjectのどちらを使ってもパフォーマンスにそこまで差がないと考えられ、パフォーマンスの観点ではどちらを使うほうがいいか判断を下すことができませんでした。
さいごに
今回のGameObjectとECSの連携にどれくらいの需要があるかは分かりませんが(そもそもECSみんな使ってる?)、部分的にECSを導入する方法として有効かもしれません。質問やご指摘などあればお気軽にコメントください。
今回調べるにあたってたくさんサポートしていただいたnotargsさん、ありがとうございました。
参考
-
ECSの公式サンプルリポジトリ
- 今回の実装はこのリポジトリのHelloCube/GameObjectSyncをベースにしています
Discussion