ずいぶん前に Unity ECS の勉強をしたときにhecomiさんのBoidsシミュレーションを題材に使わせていただいたのですが、UnityのDOTS環境がどんどん変わっていって現行環境に合わせて大規模なリファクタリングが必要になったので、その際の情報を備忘録としてまとめます。
2022/4/16 追記
現在の実装でだいぶ構造が変わったので設計のお話として新しく記事を書きました。
[Unity] Entities 0.50 環境で Boids Simulation を再設計した話
環境 (2022/3/24 時点)
- Unity 2020.3.31f
- Hybrid Renderer 0.50-preview.24
- Entities 0.50.0-preview.24
当方の実装結果
現在のECSの書き方
▽Prefab Entity の準備
public class Bootstrap : MonoBehaviour
{
public static Bootstrap Instance { get; private set; }
public static Param Param { get { return Instance.param; } }
// Inspector で Prefab を設定しておく
[SerializeField] GameObject prefab_obj;
// Prefab Entity 保持用の変数
private Entity prefab_entity;
// Boid の全体数の管理用
private int n_boid;
void Awake()
{
Instance = this;
}
public void Start()
{
var world = World.DefaultGameObjectInjectionWorld;
var manager = world.EntityManager;
// convert prefab_obj -> prefab_entity
prefab_entity = GameObjectConversionUtility.ConvertGameObjectHierarchy(
prefab_obj,
GameObjectConversionSettings.FromWorld(world, null)
);
// add user defined component
manager.AddComponent<BoidPrefabType>(prefab_entity); // Prefabであることを示す空のComponent
manager.AddComponent<Scale>(prefab_entity);
manager.AddComponent<Velocity>(prefab_entity);
manager.AddComponent<Acceleration>(prefab_entity);
manager.AddComponent<NeighborsEntityBuffer>(prefab_entity);
n_boid = 0;
}
}
たびたび Hybrid Renderer
が使用する Component
が追加されているようで、自力で ArchType
を組み立てるやり方だと、適当にアップグレードしたときに描画されなくなります。
また、 Hybrid Renderer
も設定(V2を使うか否か)によって動かない場合があり、原因の特定に手間取りました。
公式サンプルもそうですが、Hybrid ECS用のAPIを活用してGameObjectから必要な ArchType
を構築すれば上記の仕様変更にも自動的に追従できることになり、ほんの少しリスクが減ります。
PrefabなのでSystemの操作対象に紛れ込まないよう、空のコンポーネント BoidPrefabType
をつけています。
▽Entityの生成、破棄
public class Bootstrap : MonoBehaviour
{
public static Bootstrap Instance { get; private set; }
public static Param Param { get { return Instance.param; } }
[SerializeField] public float boidScale = 1.0f;
[SerializeField] public Param param;
[SerializeField] GameObject prefab_obj;
private Entity prefab_entity;
private int n_boid;
private Unity.Mathematics.Random random;
/* 略 */
void UpdateBoidNum(int n_tgt)
{
if (n_tgt < 0) return;
var manager = World.DefaultGameObjectInjectionWorld.EntityManager;
int n_diff = n_tgt - n_boid;
if (n_diff > 0)
{
Debug.Log($"update boids num: add {n_diff} boids.");
var scale = this.boidScale;
var initSpeed = this.param.initSpeed;
for (int i = 0; i < n_diff; i++)
{
var entity = manager.Instantiate(prefab_entity);
// BoidPrefabType を BoidType に付け替え
manager.RemoveComponent<BoidPrefabType>(entity);
manager.AddComponent<BoidType>(entity);
// 適当に値を設定
manager.SetComponentData(entity, new Translation { Value = this.random.NextFloat3(1f) });
manager.SetComponentData(entity, new Rotation { Value = quaternion.identity });
manager.SetComponentData(entity, new Scale { Value = scale });
manager.SetComponentData(entity, new Velocity { Value = this.random.NextFloat3Direction() * initSpeed });
manager.SetComponentData(entity, new Acceleration { Value = float3.zero });
}
}
if (n_diff < 0)
{
int n_delete = -n_diff;
Debug.Log($"update boids num: remove {n_delete} boids.");
var entity_query = manager.CreateEntityQuery(new EntityQueryDesc
{
All = new[]
{
ComponentType.ReadOnly<BoidType>()
}
});
var entities = entity_query.ToEntityArray(Allocator.Temp);
manager.DestroyEntity(new NativeSlice<Entity>(entities, n_tgt));
entities.Dispose();
}
n_boid = n_tgt;
}
}
Entityの生成については元の実装と大して違いはありません。
破棄については、今回は全体の数だけを考えて増減させるため、 BoidType
に該当するもの全体の情報にアクセスする必要があり、そのために EntityQuery
の生成 -> 対象となる NativeArray<Entity>
の取得という流れで処理しています。
このへんは、状況に合わせて公式サンプルの SpawnFromMonoBehaviour
, SpawnFromEntity
, SpawnAndRemove
あたりを参考にするのがよいでしょう。
▽Entities.ForEach によるジョブ実装例
まず、entity
自身の情報が主な場合、今回の例でいえば MoveSystem
の実装は下記のとおり。
[UpdateAfter(typeof(BoidSystemGroup))]
public partial class MoveSystem : SystemBase
{
protected override void OnUpdate()
{
// パラメータをローカル変数として置いておき、ラムダ式にキャプチャさせる
float dt = Time.DeltaTime;
float minSpeed = Bootstrap.Param.maxSpeed;
float maxSpeed = Bootstrap.Param.maxSpeed;
// ジョブの定義
Dependency = Entities.
WithName("MoveJob").
WithAll<BoidType>().
WithBurst().
ForEach((ref Translation pos, ref Rotation rotate, ref Velocity vel, ref Acceleration accel) =>
{
vel.Value += accel.Value * dt;
var dir = math.normalize(vel.Value);
var speed = math.length(vel.Value);
vel.Value = math.clamp(speed, minSpeed, maxSpeed) * dir;
pos.Value += vel.Value * dt;
var rot = quaternion.LookRotationSafe(dir, new float3(0, 1, 0));
rotate.Value = rot;
accel.Value = float3.zero;
}).ScheduleParallel(Dependency);
}
}
float
や int
など単純な struct
は適当にOnUpdate()
関数のローカル変数においておけば、同じスコープで書くラムダ式でキャプチャしてくれるので簡単に使えます。
ラムダ式の引数は、C#の仕様かEntitiesの仕様かはわかりませんが、
値渡し(実質 Entity 型のみ)、ref 渡し、in 渡し
の順番で並べる必要があります。また、Entity
型以外を値渡しすると、処理中で書き換える引数に ref
を、書き換えない引数には in
をつけるように警告されます。
Entities
と ForEach()
の間にある With**
関数でエンティティクエリに属性を追加できます。
主なものはReferenceによれば下表のとおり。
エンティティクエリ | 効果 |
---|---|
WithName(string) | Jobの名前を指定。プロファイラやEntityDebuggerでこの名前が使われる |
WithOutBurst | Burst を無効化。デバッグ時やBurstに対応できない処理に使う (デフォルトではBurstが有効) |
WithBurst(FloatMode, FloatPrecision, bool) | Burst Compiler に浮動小数点に関する設定とコンパイルのタイミングを指定する |
WithAll<T1, T2, T3> | 処理対象のEntityはすべてのComponentTypeを持つ必要がある |
WithAny<T1, T2, T3> | 処理対象のEntityはいずれかのComponentTypeを持つ必要がある |
WithNone<T1, T2, T3> | 処理対象のEntityはいずれのComponentTypeも含まない必要がある |
WithChangeFilter<T1, T2> | 指定されたComponentが前回のSystem実行時に変更されたものだけを処理する |
WithSharedComponentFilter(ISharedComponentData) | ISharedComponentDataが特定の値のEntityだけを処理する |
WithEntityQueryOptions(EntityQueryOptions) | EntityQueryOptionsに合致するものだけを処理する |
WithStoreEntityQueryInField(EntityQuery) |
SystemBase.EntityQuery に ForEach実行時に作成された EntityQuery を保存する。 対象Entityの全体数など、EntityQueryのインターフェイスを使いたい場合に用いる |
上表中の <T1, T2, T3>
は <T>
または <T1, T2>
に型パラメータを省略できます。(最大数は表中のとおり)
次に、自身以外の Entity
の情報も必要な処理の例として、 CohesionSystem
の実装は下記のとおり。
[UpdateInGroup(typeof(BoidSystemGroup))]
public partial class CohesionSystem : SystemBase
{
private struct CohesionDataContainer
{
[ReadOnly] public float alignmentWeight;
[ReadOnly] public ComponentDataFromEntity<Translation> positionFromGrovalEntity;
}
protected override void OnUpdate()
{
var common_data = new CohesionDataContainer
{
alignmentWeight = Bootstrap.Param.alignmentWeight,
positionFromGrovalEntity = GetComponentDataFromEntity<Translation>(true),
};
Dependency = Entities.
WithName("CohesionJob").
WithAll<BoidType>().
WithBurst().
ForEach(
(ref Acceleration accel, in Translation pos, in DynamicBuffer<NeighborsEntityBuffer> neighbors) =>
{
if (neighbors.Length == 0) return;
float3 pos_avg = float3.zero;
float3 pos0 = pos.Value;
float3 acc = accel.Value;
for(int i=0; i<neighbors.Length; i++)
{
pos_avg += common_data.positionFromGrovalEntity[neighbors[i].entity].Value;
}
pos_avg /= neighbors.Length;
acc += (pos_avg - pos0) * common_data.alignmentWeight;
accel = new Acceleration { Value = acc };
}
).ScheduleParallel(Dependency);
}
}
ここで、 Entity
経由で他の Boid の Translation
を参照するため、全EntityのTranslationへのアクセサを SystemBase.GetComponentDataFromEntity<Translation>(isReadOnly)
で取得しています。このSystemでは Translation
は書き換えないので isReadOnly = true
です。この場合代入先は [ReadOnly]
アトリビュートが付加されている必要があり、そのままでは関数内の一時変数には代入できません。
そのため、このデータの代入先として struct CohesionDataContainer
の中に [ReadOnly] メンバを用意し、 CohesionDataContainer
を一時変数として作ることでラムダ式にキャプチャさせることができます。
▼IJobEntityBatch の例 (非推奨)
IJobEntityBatch
を使って CohesionSystem
を書くと下記のようになります。
[UpdateInGroup(typeof(BoidSystemGroup))]
public partial class CohesionSystem : SystemBase
{
// チャンク検索のため EntityQuery が必須 (IJobEntityBatch.Schedule()の引数)
private EntityQuery query;
protected override void OnCreate()
{
base.OnCreate();
query = GetEntityQuery(new EntityQueryDesc
{
All = new[] {
ComponentType.ReadOnly<BoidType>(),
ComponentType.ReadOnly<Translation>(),
ComponentType.ReadOnly<NeighborsEntityBuffer>(),
ComponentType.ReadWrite<Acceleration>()
}
});
}
[BurstCompile]
public struct CohesionJob : IJobEntityBatch
{
[ReadOnly] public float alignmentWeight;
[ReadOnly] public ComponentDataFromEntity<Translation> positionFromGrovalEntity;
// アクセサのハンドルを受け取るためのメンバ。 var で受け取れないのでひたすら面倒
[ReadOnly] public ComponentTypeHandle<Translation> translationHandle;
[ReadOnly] public BufferTypeHandle<NeighborsEntityBuffer> neighborsBufferHandle;
public ComponentTypeHandle<Acceleration> accelHandle;
public void Execute(ArchetypeChunk batchInChunk, int batchIndex)
{
var pos_array = batchInChunk.GetNativeArray(translationHandle);
var neighbors_list = batchInChunk.GetBufferAccessor(neighborsBufferHandle);
var acc_array = batchInChunk.GetNativeArray(accelHandle);
// チャンク内のイテレーションは自分で書く。
// 1段ネストが増える分手間だしバグ混入の危険も増える
for(int i=0; i<batchInChunk.Count; i++)
{
var neighbors = neighbors_list[i];
if (neighbors.Length == 0) continue;
float3 pos_avg = float3.zero;
float3 pos0 = pos_array[i].Value;
float3 acc = acc_array[i].Value;
for (int j = 0; j < neighbors.Length; j++)
{
pos_avg += positionFromGrovalEntity[neighbors[j].entity].Value;
}
pos_avg /= neighbors.Length;
acc += (pos_avg - pos0) * alignmentWeight;
acc_array[i] = new Acceleration { Value = acc };
}
// ComponentType のアクセサが NativeArray<> を返すので Dispose する必要がある
pos_array.Dispose();
acc_array.Dispose();
}
}
protected override void OnUpdate()
{
var param = Bootstrap.Param;
var job = new CohesionJob
{
alignmentWeight = param.alignmentWeight,
positionFromGrovalEntity = GetComponentDataFromEntity<Translation>(true),
// 必要な ComponentType, BufferType の Handle をいちいち渡す必要がある
translationHandle = GetComponentTypeHandle<Translation>(),
neighborsBufferHandle = GetBufferTypeHandle<NeighborsEntityBuffer>(),
accelHandle = GetComponentTypeHandle<Acceleration>()
};
// ここで渡す EntityQuery も自分で組み立てる必要がある
Dependency = job.ScheduleParallel(query, 1, Dependency);
}
}
上記は Entities.ForEach
の例と同じ処理ですが、書かなければならないコードの量と複雑さが圧倒的です。
そのため、公式でも
「どうしてもIJobEntityBatch
を使う必要がない限りEntities.ForEach
を使おう」
と言っています。
参考記事
Original implementation:
Unity で Boids シミュレーションを作成して Entity Component System (ECS) を学んでみた
Qiita:
ECSの簡単な実装と変更点について
既存のUnityプロジェクトをUniversalRenderPipelineに移行する -サードインパクト修復-
Official:
Entities
Hybrid Renderer
EntityComponentSystemSamples