【Unity】GameObjectもECSも使いたい Hybrid ECSについて
ECSは現状正直な所、物量が必要な部分をECSで試して大まかな部分は既存のMonoBehaviourやUnityEngine系のコンポーネントを使うのがベターな回答です。今回はECSとGameObjectを連携させる手法についてを紹介します。
検証バージョン:Unity 2019.3、Entities 0.3.0 preview 4
- Hybrid ECS
- GameObjectとEntityの同期
- 同期のサンプルコード
- GameObjectもしくはEntityの破棄について
- クラスを管理する「Managed」なComponentData
- ISharedComponentDataとの違い
- おまけ
- 関連
Hybrid ECS
ConvertToEntity
はGameObjectをEntityへ変換してくれるコンポーネントですが、ConvertAndInjectGameObject
を使用すると変換後にGameObjectを削除する代わりに、GameObjectやComponentをEntityへ登録してくれます。これでEntityからGameObjectやComponentへ参照が可能になり、2つセットで運用可能になります。
この時、格納されるデータはStructではなくClassであり、内部での扱いはだいぶ違います。その辺りは後述のMnaagedComponentData辺りで。
GameObjectとEntityの同期
Hybrid ECSの有益な使い方の一つはGameObjectとの同期です。ECS側で複雑な計算を行いデータを同期、計算結果をMonoBehaviourで使用します。
なおGameObjectとEntityの同期は一方通行が望ましいです。つまりEntityの座標をGameObjectに同期するか、GameObjectの座標をEntityに同期するのかという話です。前者の場合は必然的にECSのSystemによる管理、後者の場合はMonoBehaviourの管理になります。
情報を同期するタイミングはPlayerLoopをカスタムしていなければ、InitializationSystemGroup
、MonoBehaviourのUpdate
、SimulationSystemGroup
、PresentationSystemGroup
、MonoBehaviourのLateUpdate
のどこかのタイミングに設定します。個人的なオススメは SimulationSystemGroupでジョブを発行して次のInitializationSystemGroupで同期です。情報は常に1フレーム遅れる事になりますが、そういうものと割り切って使えばクセがなく使いやすいです。
同期のサンプルコード
例えば下のようなシステムを考えます。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); } }
このままだと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が破棄されないので、対象により挙動を変えるという非常に面倒くさいトリックが必要になります。
それを回避するにはGameObjectの破棄を検知して自動でEntityを破棄する、またはその逆といったコードが必要になります。これは下のコンポーネントを使用することで実現出来ます。コードは長いのでリンクのみ。
GameObjectを破棄するとEntityも破棄される。もしくはその逆 · GitHub
やっていることは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は普通に参照型なので、内部で外部参照したり色々と行うことが出来ます。
当然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のアクセスはリニアなアクセスを約束されているので、速度は低下しません。
ISharedComponentDataとの違い
Managedなデータを保持出来るという点では、似たような機能にISharedComponentDataというものがあります。これは今後はバッチ処理したいときに使用するという棲み分けになるんじゃないかんと思います。
SharedComponentDataは保有するデータの種類でチャンクを分割します。これは言い換えれば同じSharedComponentDataを持っているEntityが連続して配置されるということで、連続して同じ処理を実現する(バッチ処理)に効率的です。システム側からすると連続して同じデータがある事が保証されるので、Burstによる最適化も行いやすくなります。
実際、AIや状態異常等、挙動が変わる間隔が広い動作についてはComponentDataの中身を見てifで分岐するより、タグComponentData
を追加したり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; } }
関連
*1:まぁメインスレッドでのアクセス限定で効率もクソもないのですが
【Unity】JobSystemが動作しているスレッドの番号を取得する
複数のジョブで処理を行う時、全てのジョブが同じバッファに格納しようとすると当然競合を起こします。コレを回避するために排他処理を行う訳ですが、それを行わず計算結果を格納する方法を考えてみます。
ジョブ毎に計算結果を格納する対象を切り替える
特に何も考えずに全てのジョブから特定の要素に書き込む場合、同時に書き込んだり、計算の前提となる状態が変わったりして問題になります。この挙動はJobSystemでは実行時にエラーになります。
この問題は 異なるスレッドから同じ要素に書き込む事で起こるので、スレッド毎に読み書きする要素を決めて、処理を行う方法を考えてみます。
利用するAPI
まず重要なのが [NativeSetThreadIndex] int threadIndex;
をジョブに定義することです。この記述でジョブが動作しているスレッドの番号が取得できます。注意として1~4といったWorkerThreadが動作している番号ではなく、0~128といったスレッドの内のドレかが入ります。
このthreadIndex
を元に情報を格納したり、取得したりします。
この時、ジョブが読み書きするNativeArrayにはNativeDisableContainerSafetyRestriction
を設定します。コレを設定しなければ、ジョブシステムは複数のスレッドから一つの要素を操作する可能性があるとしてエラーを出してしまいます。今回はそれは起こらない予定なので上記の設定が使用できます。
ジョブの実装はこんな感じです。全てのジョブが各々の担当する要素に+1しているだけです。
[BurstCompile] struct CountJob : IJobParallelFor { [NativeSetThreadIndex] int threadIndex; [NativeDisableContainerSafetyRestriction] public NativeArray<int> array; public void Execute(int index) { var count = array[threadIndex]; count = count + 1; array[threadIndex] = count; } }
あとはジョブを使用する側です。普通にNativeArrayを生成するのですが、要素数はJobsUtility.MaxJobThreadCount
を使用します。threadIndex
が返す数が0~128なので、その要素のどれでも格納出来るようにする必要がある為、この分量の要素を生成しています。
void Update() { handle.Complete(); var array = new NativeArray<int>(JobsUtility.MaxJobThreadCount, Allocator.TempJob); handle = new CountJob { array = array }.Schedule(24576, 8); array.Dispose(handle); JobHandle.ScheduleBatchedJobs(); }
なお最終的な集計はJobsUtility.MaxJobThreadCount
の数の要素を集計する感じになります。ここはイケてない気もしますが、数千の要素をジョブの数だけ分割計算し、最終的な集計は128の要素を舐めれば良い(しかもBurstによる最適化が効きやすい構造)なので、まぁ悪くはないかなと言う認識です。ワーカースレッドの番号を取得できればもうちょっと納得行く感じではありますが。
コード
using UnityEngine; using Unity.Jobs; using Unity.Jobs.LowLevel.Unsafe; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; using Unity.Burst; public class PositionUpdateSystem : MonoBehaviour { JobHandle handle; void OnDestroy() { handle.Complete(); } void Update() { handle.Complete(); var array = new NativeArray<int>(JobsUtility.MaxJobThreadCount, Allocator.TempJob); var counter = new NativeArray<int>(1, Allocator.TempJob); handle = new CountJob { array = array }.Schedule(24576, 8); handle = new GatherJob { array = array, counter = counter }.Schedule(handle); handle = new LogJob { counter = counter }.Schedule(handle); array.Dispose(handle); counter.Dispose(handle); JobHandle.ScheduleBatchedJobs(); } [BurstCompile] struct CountJob : IJobParallelFor { [NativeSetThreadIndex] int threadIndex; [NativeDisableContainerSafetyRestriction] public NativeArray<int> array; public void Execute(int index) { var count = array[threadIndex]; count = count + 1; array[threadIndex] = count; } } [BurstCompile] struct GatherJob : IJob { [ReadOnly] public NativeArray<int> array; [WriteOnly] public NativeArray<int> counter; public void Execute() { var count = 0; for (int index = 0; index < array.Length; index++) { count = count + array[index]; } counter[0] = count; } } struct LogJob : IJob { [ReadOnly] public NativeArray<int> counter; public void Execute() { Debug.Log(counter[0]); } } }
感想
NativeQueueのNativeArray変換や、NativeListの並列書込が使えれば楽なんですが… UnsafeList
君、君には期待しているよ(未検証
【Unity】JobSystemが使うワーカースレッドの数を制限する
たぶんUnity 2019.3
から、ワーカースレッドの数を制限出来るようになったっぽいです。
ワーカースレッドの数を制限する
UnityのJobSystemは基本的に「使用できる全てのスレッドを使用」します。これは例えばゲーム機がコアを1~2個専有してしまっていたり、もしくは何らかのシステムがスレッドを一つ専有してしまう場合でも同様です。
こうなると使用中にのスレッドにジョブを依頼する形になるので、コンテキストスイッチが発生し、効率が低下します。
ジョブの動作数を制限する場合JobsUtility.JobWorkerCount
を使用します。これでジョブが発生する数を制限出来ます。例えば下のように記述すれば、ジョブが割り振る数は制限されます。
using Unity.Jobs.LowLevel.Unsafe; (中略) void Awake() { JobsUtility.JobWorkerCount = 2; }
実際の動作は下のような感じです。左が通常のジョブで、右がWorkerの数を制限した場合です。ジョブを処理しているワーカースレッドの数が減っている事が確認出来ます。
ワーカースレッドの数
ワーカースレッドの数ですが、ハードコーディングで記述するにはゲームの環境は複雑に過ぎます。例えばモバイルのコア数は既に8だし、AMDのCPUは32個(64スレッド)です。コア数は現状すごく上昇しやすい項目なので、そのまま使うのは辛そうです。
とりあえず自分的には JobsUtility.JobWorkerMaximumCount
から-1~-2辺りするのが良さそうに見えますが、実際にどの程度減らすのかは他のスレッドをどの程度使用しているのかに依存しそうです。
【Unity】低フレームレートでも、きれいな入力を受け取りたい
InputManagerでは入力情報はフレームレートに強く依存しており、その中間で取得する情報の大抵は破棄されていました。
InputSystemでは中間の情報をバッファとして確保・使用できるようになったので、それを使用してフレームが低くてもキレイな線を引ける方法を考えてみます。
なお動作はOSの動作に強く依存します。
動作環境:Unity 2018.3 b12
、 Input System 1.0 preview
実際の動作
まず普通に低フレームレート環境下でInputManagerを使用してマウスの位置を追跡して線を引くようなコードを実装した場合、下のような形になります。
動作では、マウスは滑らかに円を描く形で動いていますが、線は非常に角張った形で描画されています。これはマウスの座標を取得する間隔が広いために起こります。
InputSystemを使用した場合はコチラ。こちらもフレームレートを落としていますが入力情報はキレイに補完されており、ちゃんと曲線を描けている事が確認出来ます。1フレームに複数回の入力を受け取れるという認識が一番認識しやすい概念です。
コード全文
InputManagerの場合
まず最初に、InputManagerを使用したコードです。普通に位置情報を取得し、描画を依頼するコンポーネントに情報を渡します。細かいコードは上の全文を見てください。
// GetInput3.cs void Update() { var pos = Input.mousePosition; line.AddPosition(pos); // 座標を注入 }
InputSystemでマウスの位置を取得する
InputSystemでmousePositionを取得するコードです。これはまだInputManagerと同じような動きをします。
void Update() { var pos = Mouse.current.position.ReadValue(); // マウスの位置を取得 line.AddPosition(pos); }
InputSystemでバッファを使用して取得する
バッファを使用して取得します。ここでは InputStateHistory
を使用します。
このアプローチでは中間バッファを補完しているので、滑らかな線が引けます。
なおバッファはnewした地点で確保されるので、自分で開放を行わないとメモリリークを起こします。
InputStateHistory history; void Awake() { history = new InputStateHistory<Vector2>(Mouse.current.position); //マウスの位置を観測 } void OnDestroy() { history.Dispose(); // 無効もしくは破棄のタイミングで必ず開放 } void OnEnable() => history.StartRecording(); // バッファをレコード開始 void OnDisable() => history.StopRecording(); // バッファをレコード終了 void Update() { foreach( var record in history) { var pos = record.ReadValue<Vector2>(); line.AddPosition(pos); } history.Clear(); // 今回の分のバッファは開放 }
InputActionを使用する(ポーリング)
InputActionのポーリングを使用してみます。つまり InputActionTrace
を使用します。
事前準備としてInputActionにPosition
、そのControlにはMousePositionを入れます。これはTouchを始めとした他のControlでも良いです。
これをPlayerInputに設定しセットアップしてもらい、GetComponent<PlayerInput>().currentActionMap["Position"];
でInputActionを取得・使用します。
private InputAction inputAction; private InputActionTrace trace; void Awake() { inputAction = GetComponent<PlayerInput>().currentActionMap["Position"]; trace = new InputActionTrace(); } void OnDestroy() { trace.Dispose(); } void OnEnable() => trace.SubscribeTo(inputAction); // 観測開始 void OnDisable() => trace.UnsubscribeFrom(inputAction); // 観測終了 void Update() { foreach (var record in trace) // 観測内容から一つずつ取り出して処理 { var v = record.ReadValue<Vector2>(); line.AddPosition(v); } trace.Clear(); }
イベント駆動
上のイベント駆動する場合です。Updateを書かなくても良いという反面、1フレームに同じ処理を複数回呼ばれた時の対策が少し面倒といえば面倒かもしれません。
private DrawLine line; private InputAction inputAction; void Awake() { inputAction = GetComponent<PlayerInput>().currentActionMap["Position"]; } void OnEnable() => inputAction.performed += Input_performed; void OnDisable() => inputAction.performed -= Input_performed; void Input_performed(InputAction.CallbackContext c) { var v = c.ReadValue<Vector2>(); line.AddPosition(v); }
ポーリングの頻度
入力イベントをポーリングするタイプのデバイスの場合、ポーリングの頻度をInputSystem.pollingFrequency
で設定できます。初期値は60Hz(1秒間に60回チェック)で、これを上げたり下げたりすることで入力イベントのサンプリング精度を上げたり出来ます。
ただ上げすぎると(1000とか)エディターごと落ちる事があったので注意が必要かもしれません。また幾つかのデバイスはポーリングする形ではなくOSからの入力を受け取るタイプなので、このAPIは動作しません。