Unityで最近のDOTSを触ってみた。前編:実装Tips
※この記事は2023年10月23日に弊社運営の技術情報サイト「ギャップロ」に掲載した記事です。
はじめに
2019年ごろにプレリリースされましたが、2023年6月ごろにようやく正式リリースされました。触ってみた方は分かると思いますが、オブジェクト指向からデータ指向に代わっただけあって、プログラムの組み方が全然違いますよね。記事も最新情報が全然なく、古い書き方が多い印象です。自分が困ったことを中心に前編後編に記事を分けて紹介しようと思います。
前編 : 自分が困った実装方法のまとめ
後編 : 従来の実装方法との比較
開発環境
Unity 2022.3.4f1
Entities v1.0.16
Entities Graphics v1.0.16
※この記事ではUnity Physicsは扱いません。
学習フロー
どこから手を出したら良いか分からない人も多いのではないでしょうか?他の人が書いた記事を調べると大抵プレリリース版のやり方で、日本語で分かりやすいのは良いんですが、そのままやろうとすると動かなったりします。自分もこの辺りで苦戦したので個人的におススメする学習フローを紹介したいと思います。
公式ドキュメントのSpawner exampleをやってとりあえず手を動かす。
https://docs.unity3d.com/Packages/com.unity.entities@1.0/manual/ecs-workflow-example.html
GameObjectをEntity化して表示する、必要最小限のやり方が載っています。
理解が困難になると思うので、Jobによる最適化は一旦飛ばしても良いかもしれません。
過去に公開されている動画を見て概要を理解する。
1番のSpawner exampleでやった、Bake, IComponentData, ISystemの意味が分かるようになると思います。
プレリリース時の動画が多く、ECSの概要は同じですが、実装方法は今と異なるため注意が必要です。
Gitに公開されている豊富なサンプルを見る。
https://github.com/Unity-Technologies/EntityComponentSystemSamples
Bakeのやり方やSubSceneの読み込み方法などのシンプルな物から、魚群の応用的な物まで豊富にサンプルが用意されています。
自分のやりたいことをやっている箇所をここで探すのが良いかと思います。
プレリリース版との主な実装の変更点
下記サイトにプレリリース版との主な変更点が紹介されています。過去記事に特に多かった個人的に気になった部分を紹介したいと思います。
以下のSystemのサンプルコードですが、プレリリース版と正式リリース版で実装方法が大きく異なっています。IComponentDataのSpawnerを持つ全てのEntityを探して、座標を(1,1,1)に設定する同じ処理をしています。以下の点に注意して比較してみてください。
Systemの継承方法
SystemBase -> ISystem
classからstructになったため、BurstCompileも可能です。
Entityの探索方法
Entities.ForEach -> foreach(var MyType , in Query<MyType>)
EntityのTransfromの参照方法
Translation -> LocalTransform
//プレリリース版(~v0.5)
public partial class SpawnerSystem : SystemBase
{
protected override void OnUpdate()
{
Entities
.WithAll<Spawner>()
.ForEach((Entity entity, ref Spawner spawner) =>
{
EntityManager.SetComponentData(entity, new Translation()
{
Value = new float3(1,1,1)
});
})
.Run();
}
}
//正式リリース版(v1.0~)
[BurstCompile]
public partial struct SpawnerSystem : ISystem
{
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var (spawner, entity) in
SystemAPI.Query<RefRW<Spawner>>().WithEntityAccess())
{
var setPos = new float3(1,1,1);
state.EntityManager.SetComponentData(entity, LocalTransform.FromPosition(setPos));
}
}
}
実装Tips
ここからは各種実装方法を紹介したいと思います。部分的なコードの抜粋になるので、分からない部分は前述の学習フローに沿って理解を深めてください。
EntityCommandBufferの取得
Entityのインスタンス化や破棄等のEntity操作に関わるEntityComandBufferの取得方法です。自身で作成することも可能ですが、自動で用意されるBeginSimulationEntityCommandBufferSystemを利用すると、再生と破棄を自動的に行ってくれるので、基本的にはこれを利用したら良いのではないかと思います。
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ecbSingleton = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);
}
「ecb」という変数名がこの後のTipsにも頻繁に出てきますが、EntityCommandBufferの事を指します。サンプルや他の方の記事でもこの書き方が多いので一般的なのではないかと思います。
GameObjectの配列のBake
基本的には配列はDynamicBufferを使用することになると思います。配列にしたいデータはIComponentDataではなく、IBufferElementDataを継承して作成します。
//Authoring側
//GameObjectをBakeする側
public class TipsDemoAuthoring : MonoBehaviour
{
/// <summary>
/// インスペクタから設定するGameObject
/// </summary>
public List<GameObject> Prefabs = new List<GameObject>();
/// <summary>
/// Baker
/// </summary>
class Baker : Baker<TipsDemoAuthoring>
{
/// <summary>
/// Bake
/// </summary>
/// <param name="authoring"></param>
public override void Bake(TipsDemoAuthoring authoring)
{
//自身のEntityの取得
var entity = GetEntity(TransformUsageFlags.None);
//GameObjectの配列のEntity化
var buffer = AddBuffer<GameObjectBuffer>(entity);
foreach (var prefab in authoring.Prefabs)
{
buffer.Add(new GameObjectBuffer()
{
Entity = GetEntity(prefab,TransformUsageFlags.Dynamic)
});
}
}
}
}
/// <summary>
/// GameObjectの配列用のBuffer
/// InternalBufferCapacityには使用予定の要素数を設定しておく。
/// </summary>
[InternalBufferCapacity(5)]
public struct GameObjectBuffer : IBufferElementData
{
/// <summary>
/// Entity(GameObject)
/// </summary>
public Entity Entity;
}
//System側
//BakeしてEntity化したGameObjectをインスタンス化
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
foreach (var gameObjectBuffer in SystemAPI.Query<DynamicBuffer<GameObjectBuffer>>())
{
foreach (var objectBuffer in gameObjectBuffer)
{
ecb.Instantiate(objectBuffer.Entity);
}
}
}
特定のIComponentDataが作成されてればSystemを作成する
特定の条件下でのみSystemを作成したい時等に有効だと思います。Gitのサンプルでは、これを活用してSystem生成を区別していました。下記コードの例は、インスペクタからIsDemoTipsにチェックを入れなければ、TipsDemoInitSystemが生成されないようになっています。
//Authoring側
public class DemoExecuteAuthoring : MonoBehaviour
{
/// <summary>
/// 実装Tipsのデモを開始するか?
/// </summary>
public bool IsDemoTips;
class Baker : Baker<DemoExecuteAuthoring>
{
var entity = GetEntity(TransformUsageFlags.None);
if(authoring.IsDemoTips) AddComponent<ExecuteDemoTips>(entity);
}
}
/// <summary>
/// 実装Tipsのデモのシステム
/// </summary>
public struct ExecuteDemoTips : IComponentData{}
//System側
[BurstCompile]
public partial struct TipsDemoInitSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
//これよって、ExecuteDemoTipsが作成されてなければ、このSystemは作成されない。
state.RequireForUpdate<ExecuteDemoTips>();
}
}
MonoBehaviourとSystem間で共通の参照データを持つ
ECSの思想に反するかもしれないですが、ハイブリッドECSにすると、従来の実装方法のMonoBehaviourで行った処理をECSのSystem側に伝えたい時もあると思います。下記コードの例では、初期化用のSystemの中で共有データを作っておき、MonoBehaviour側でSpace押下時に値を変更して、System側でそれを確認できる例です。
//MonoBehaviourとSystem間で共有する用のデータ
public class TipsDemoShareData : IComponentData
{
public int Number;
public TipsDemoShareData(){}
}
//初期化用のSystem側
public void OnUpdate(ref SystemState state)
{
//自身を非活性にして止める。
state.Enabled = false;
//共有データ保持用のEntity作成
var dataEntity = state.EntityManager.CreateEntity();
ecb.SetName(dataEntity,"DataEntity");
//共有データの作成。Entityに追加。
var shareData = new TipsDemoShareData();
ecb.AddComponent(dataEntity,shareData);
//MonoBehaviour側に共有データの設定
var monoManager = GameObject.FindAnyObjectByType<TipsDemoMonoManager>();
monoManager.SetShareData(shareData);
}
//MonoBehaviour側
public class TipsDemoMonoManager : MonoBehaviour
{
//Systemとの共有データ
private TipsDemoShareData _shareData;
//共有データの設定
public void SetShareData(TipsDemoShareData shareData)
{
_shareData = shareData;
}
public void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
//Space押下時に共有データの数字を加算
_shareData.Number++;
}
}
}
//System側
[BurstCompile]
public void OnCreate(ref SystemState state)
{
//共有データが作成されるまでこのSystemを作成しない。
state.RequireForUpdate<TipsDemoShareData>();
}
public void OnUpdate(ref SystemState state)
{
var shareData = SystemAPI.ManagedAPI.GetSingleton<TipsDemoShareData>();
Debug.Log($"共有データ : {shareData.Number}");
}
Entityの操作関連
Entityの生成
従来の実装方法とあまり変わりません。
public void OnUpdate(ref SystemState state)
{
//BakeされたEntityのインスタンス化
//従来の実装方法だと、var go = Instantiate(prefab); みたいなもの。
var newEntity = ecb.Instantiate(bakedEntity);
//純粋なEntityの作成
//従来の実装方法だと、var go = new GameObject(); みたいなもの。
var pureEntity = state.EntityManager.CreateEntity();
}
Entityの親子関係の設定
transform.SetParent();みたいな従来の便利な関数は無く、親と子とお互いに丁寧に関係を設定する必要があります。
public void OnUpdate(ref SystemState state)
{
//親となるEntityの生成
var newParentEntity = ecb.Instantiate(parentEntity);
//親Entityに子Entityの配列となる、<Child>をアタッチ。
var children = ecb.AddBuffer<Child>(newParentEntity);
foreach (var gameObjectBuffer in SystemAPI.Query<DynamicBuffer<GameObjectBuffer>>()){
foreach (var objectBuffer in gameObjectBuffer) {
//子になるEntityの生成
var newEntity = ecb.Instantiate(objectBuffer.Entity);
//親Entityを指定して、<Parent>と<PreviousParent>の両方をアタッチ。
ecb.AddComponent(newEntity, new Parent { Value = newParentEntity });
ecb.AddComponent(newEntity, new PreviousParent(){ Value = newParentEntity });
//親Entityの子Entityとして登録
children.Add(new Child() { Value = entity });
}
}
}
子エンティティを追加したい場合は、何故か下記のような遠回りな処理をする必要があります。
public void OnUpdate(ref SystemState state)
{
//読み込み専用の子エンティティのバッファを取得
DynamicBuffer<Child> roParentChildren = SystemAPI.GetBuffer<Child>(parentEntity);
//読み書きが出来る子エンティティのバッファ取得(SetBufferを呼ぶと、何故か中身が初期化される。)
DynamicBuffer<Child> rwParentChildren = ecb.SetBuffer<Child>(parentEntity);
//子エンティティのコピー
rwParentChildren.AddRange(roParentChildren.AsNativeArray());
//新規子エンティティの追加
rwParentChildren.Add(new Child() { Value = newChildEntity });
}
Entityの破棄
state.EntityManager.DestroyEntity(); では何故か破棄が出来ず、EntityCommandBufferを使う必要があります。
public void OnUpdate(ref SystemState state)
{
ecb.DestroyEntity(entity);
}
親子関係を設定している場合は、ParentとChildを丁寧に剥がしておく必要があります。下記のような関数を用意しておくと便利だと思います。
public static void DestroyEntity(ref SystemState state, EntityCommandBuffer ecb, Entity entity)
{
if (state.EntityManager.HasBuffer<Child>(entity))
{
//子エンティティの破棄
DynamicBuffer<Child> roParentChildren = state.EntityManager.GetBuffer<Child>(entity);
for (var i = roParentChildren.Length - 1; i >= 0; i--)
{
var childEntity = roParentChildren[i].Value;
DestroyEntity(ref state, ecb, childEntity);
}
}
//自身の破棄
if (state.EntityManager.HasComponent<Parent>(entity))
{
ecb.RemoveComponent<Parent>(entity);
}
ecb.DestroyEntity(entity);
}
EntityのScene設定
BakeされたEntityはオーサリングしたゲームオブジェクトと同じSubSceneに生成されるのですが、新規に純粋にEntityを生成した場合は、World直下に生成され、任意のSubScene配下になるように設定する必要があります。SubSceneの情報はすでにSubScene配下にあるEntityから取得するのが楽だと思います。
public void OnUpdate(ref SystemState state)
{
//SubScene配下のEntityからSubScene情報を取得
var subSceneEntity = SystemAPI.GetSingletonEntity<SubSceneEntity>();
var sceneSection = state.EntityManager.GetSharedComponent<SceneSection>(subSceneEntity);
var sceneTag = state.EntityManager.GetSharedComponent<SceneTag>(subSceneEntity);
var pureEntity = state.EntityManager.CreateEntity();
ecb.SetName(pureEntity,"PureEntity");
//新規生成されたEntityにSubSceneの設定
ecb.AddSharedComponent(pureEntity,sceneSection);
ecb.AddSharedComponent(pureEntity,sceneTag);
}
動的にGameObjectをEntityに変換
AssetBundleから読み込んだGameObject等、動的にEntityに変換したい時があるかと思います。プレリリース版では、「ConvertToEntity」と「GameObjectConversionUtility.ConvertGameObjectHierarchy」という便利なUtilityがあったみたいのですが、v1.0以上の正式版では何故か無くなっています。公式フォーラムをいくつか見てみると、ECSはデータ指向なので、Systemでオブジェクトを生成するのはおかしいという発言が見られるので、その考え方から無くしたのかなと思います。以下のコードは、出来る限りでGameObjectからEntityに変換している例です。TransformとRendererのみなので、他にもコンポーネントが何かある場合は追記する必要があります。
//Entityの作成
public static Entity CreateEntity(ref SystemState state,
EntityCommandBuffer ecb,
string entityName,
SceneSection sceneSection,
SceneTag sceneTag)
{
var archetype = state.EntityManager.CreateArchetype(
typeof(LocalTransform),
typeof(LocalToWorld));
var newEntity = state.EntityManager.CreateEntity(archetype);
ecb.SetName(newEntity,entityName);
ecb.AddSharedComponent(newEntity, sceneSection);
ecb.AddSharedComponent(newEntity, sceneTag);
return newEntity;
}
//GameObjectからEntityに変換
public static Entity ConvertGameObjectToEntity(ref SystemState state,
EntityCommandBuffer ecb,
SceneSection sceneSection,
SceneTag sceneTag,
GameObject targetObj)
{
var newEntity = CreateEntity(ref state, ecb, targetObj.name, sceneSection, sceneTag);
//トランスフォームの設定
var localToWorldMatrix = (float4x4)targetObj.transform.localToWorldMatrix;
LocalTransform lt = LocalTransform.FromMatrix(localToWorldMatrix);
ecb.SetComponent(newEntity,lt);
LocalToWorld lw = new LocalToWorld(){Value = localToWorldMatrix};
ecb.SetComponent(newEntity,lw);
//描画周りの設定
var buildingRenderer = targetObj.GetComponent<MeshRenderer>();
if (buildingRenderer != null)
{
var buildingMeshFilter = targetObj.GetComponent<MeshFilter>();
var sharedMesh = buildingMeshFilter.sharedMesh;
var sharedMats = buildingRenderer.sharedMaterials;
var rmArray = new RenderMeshArray(sharedMats, new [] { sharedMesh });
var rmDescription = new RenderMeshDescription(buildingRenderer);
var matMeshInfo = MaterialMeshInfo.FromRenderMeshArrayIndices(0, 0);
//Entityの描画に必要なものをまとめて追加してくれるUtility
RenderMeshUtility.AddComponents(
newEntity,
state.EntityManager,
rmDescription,
rmArray,
matMeshInfo);
}
return newEntity;
}
まとめ
自分自身もまだ触り始めたばかりで、データ指向プログラミングというのがまだ全然出来ていないと思います。今回まとめた実装方法も、なかなか見つからなくて苦戦しましたが、そもそも考え方が間違っている可能性もあります。後編では、MonoBehaviour(GameObject)とSystem(Entity)で本当に処理が早くなるのか検証したいと思います。