Posted at

【Unity】PureECSの衝突判定周りについて解説してみる

More than 1 year has passed since last update.

前回に記事引き続き、Unity ECSに関する小ネタ?第二弾です。

※タイトルにもある通り、こちらで触れるECSについてはPureECSを前提としたものとなります。(Hybridについては触れません)

現状のPureECSはまだRigidbodyやColliderと言った物に対応しておらず、何かしらと自作する必要があるかと思われます。

今回の記事では球体形状を前提としたPureECSでの衝突判定についてメモ序でに纏めてみたいと思います。

※これで簡単なSTG等に活用できるはず...。ちなみにテラシュールブログさんの記事を見た所だと「コリジョンはもうちょっとで搭載されるとの噂」との事なので、公式で出るまでの繋ぎや学習用途と言うことで..。


  • ※以下、この記事に於ける注意点です。


    • この記事中ではECSの基礎的な部分についての説明は致しません。(例 : そもそもEntityComponentSystemとは何か?と言った話など)


      • 最後に参考リンクを載せてあるのでそちらを御覧下さい。



    • 内容としては手探りな部分も多々あります。その為に確実に正しい実装や考え方かと言われると微妙な部分もあるかもしれないので、あくまで実装の一例程度に留めて頂けると幸いです。。

    • ECS自体についてもまだまだPreviewな状態であり、将来的には実装が変わってくる可能性もあります。その点を踏まえて参考にして頂ければと思います。



サンプルのプロジェクト一式についてはGitHubにアップしてあるので必要に応じてご覧ください。


▼ 実装/動作環境


  • Unity version


    • Unity2018.3.0b5



  • 依存パッケージ



    • com.unity.entities@0.0.12-preview.17


      • ※ECSはPreviewの為か、バージョンによっては破壊的な変更が入っているのでバージョン間に置ける互換性が保証されておりません。

      • ※こちらの記事では最新版を前提とした内容且つ旧版の説明については割愛するのでご了承下さい。






▽ サンプルについて

今回のサンプルは「プレイヤーとなる操作可能なEntity(赤い球体)」が「当たり判定確認用のEntity(白い球体)」に衝突すると、後者が消滅すると言った単純なサンプルとなっております。

※以降、「プレイヤーEntity → 赤い球体」「当たり判定確認用のEntity → 白い球体」と記載

sample.gif


▽ 実装について

今回の実装についてもWorldは自前生成を前提として進めていきたいと思います。

※自作ワールドとDefault Worldとの違いなどについては前回の記事にて簡単に解説しているので、そちらを御覧ください。

メインとなる衝突判定の解説については先ずは「総当たり」を前提とした実装の説明から入っていき、次に「空間分割」についても簡単に触れていきたいと思います。

実装周りについてはCEDEC2018のECSの講演で聞いた内容及びリポジトリを参考にさせて頂いております。


▼ 必要となるComponentSystemについて

以下の物が今回のサンプルで必要となるComponentSystemです。

今回は当たり判定処理の解説記事となるので、そちらを中心に解説の方を進めていきたいと思います。

それ以外の物については簡単に概要だけ纏めておきます。



  • EntityManager


    • Entituyの管理クラス

    • Entityの生成/ComponentDataの設定/削除諸々を行う。ほぼ必須になるかと思われる。




  • EndFrameTransformSystem


    • デフォルトで用意されているTransform周りのComponentSystem

    • 詳細は前回の記事を参照。




  • RenderingSystemBootstrap


    • デフォルトで用意されているPureECSで描画を行うためのComponentSystemである「MeshInstanceRendererSystem」の補助クラス

    • こちらも詳細については前回の記事を参照。




  • PlayerMove


    • プレイヤーの移動制御

    • ソースはPlayerMove.csを参照




  • ColliderUpdate


    • 衝突プリミティブの更新処理

    • 詳細は後述




  • CollisionSystem


    • 衝突判定(総当たり)


      • ※他にも「衝突判定(空間分割) : SplitSpaceCollisionSystem」と言う別タイプのシステムも用意しております。



    • 詳細は後述




  • DestroySystem


    • Entityの破棄処理

    • ソースはDestroy.csを参照




  • DestroyBarrier


    • Entityの破棄処理制御用のBarrierSystem

    • ソースはDestroy.csを参照




▼ Entityのアーキタイプについて

Entityのアーキタイプについても軽く触れておきます。

今回のサンプルで登場するEntityの種類は以下の2種類のみです。

Worldの立ち上げから生成処理全体に関してはBootstrap.csをご確認下さい。


  • 赤い球体のEntity


    • ※ソース中ではPlayerと言う名称で扱っている。



/// <summary>

/// プレイヤー(赤い球体) 識別子
/// </summary>
public struct Player : IComponentData { }

// プレイヤー(赤い球体) アーキタイプ
var playerArchetype = entityManager.CreateArchetype(
// Transform関連
ComponentType.Create<Position>(),
ComponentType.Create<Rotation>(),
ComponentType.Create<Scale>(),
// プレイヤー(赤い球体) 識別子
ComponentType.Create<Player>(),
// 衝突プリミティブ
ComponentType.Create<SphereCollider>(),
// 描画用
ComponentType.Create<MeshInstanceRenderer>());


  • 白い球体のEntity


    • ※ソース中ではCheckHitと言う名称で扱っている。



/// <summary>

/// 当たり判定確認用(白い球体) 識別子
/// </summary>
public struct CheckHit : IComponentData { }

var checkHitArchetype = entityManager.CreateArchetype(
// Transform関連
ComponentType.Create<Position>(),
ComponentType.Create<Rotation>(),
// 当たり判定確認用(白い球体) 識別子
ComponentType.Create<CheckHit>(),
// 衝突プリミティブ
ComponentType.Create<SphereCollider>(),
// 破棄管理
ComponentType.Create<Destroyable>(),
// 描画用
ComponentType.Create<MeshInstanceRenderer>());

SphereColliderについては後述で説明します。


▼ 当たり判定処理について

ソースの一部を引用しつつ順に解説していきます。

全体についてはCheckHit.csを参照して下さい。

// 衝突判定関連グループ

public sealed class CollisionUpdateGroup { }

以下の物は衝突判定用の形状プリミティブの定義/処理周りです。

実装についてはAnotherThreadECS -> ECSColliderを参考にしております。



  • SphereCollider


    • 球体形状の衝突プリミティブのデータ部分です。


      • ※既存の機能で言うとSphereColliderに近いものです。






  • ColliderUpdate


    • 衝突プリミティブの更新処理です。

    • 衝突プリミティブが付いているEntityからPosition/Rotationの情報を取得し、座標やヒット情報の更新を行っております。


      • その為、こちらのシステムで動かすEntityとしてはArchetypeにPositionRotationSphereColliderの3点が期待されるものとなります。





// ▽ 参照

// https://github.com/Unity-Technologies/AnotherThreadECS
// AnotherThreadECS/Assets/Scripts/ECSCollider.cs

/// <summary>
/// 球体形状の衝突プリミティブ
/// </summary>
public struct SphereCollider : IComponentData
{
public float3 Position;
public float3 OffsetPosition;
public float Radius;
public byte IsUpdated; // boolean
public bool Intersect(ref SphereCollider another)
{
if (this.IsUpdated == 0) { return false; }
var diff = another.Position - this.Position;
var dist2 = math.lengthsq(diff);
var rad = this.Radius + another.Radius;
var rad2 = rad * rad;
return (dist2 < rad2);
}
}

/// <summary>
/// 衝突プリミティブの更新処理
/// </summary>
[UpdateInGroup(typeof(CollisionUpdateGroup))]
[UpdateAfter(typeof(Unity.Rendering.MeshInstanceRendererSystem))]
public sealed class ColliderUpdate : JobComponentSystem
{
[BurstCompile]
public struct UpdateJob : IJobProcessComponentData<Position, Rotation, SphereCollider>
{
public void Execute([ReadOnly] ref Position position, [ReadOnly] ref Rotation rotation, ref SphereCollider collider)
{
collider.Position = math.mul(rotation.Value, collider.OffsetPosition) + position.Value;
collider.IsUpdated = 1;
}
}
protected override JobHandle OnUpdate(JobHandle inputDeps) => new UpdateJob().Schedule(this, inputDeps);
}

以下の処理は各Entityから衝突判定プリミティブと言ったConponentDataなどを取得して「衝突を検知したら○○する」を実行する部分となります。

今回のサンプルとしては赤い球体が白い球体に衝突すると白い球体が持つ破棄管理データ(Destroyable)に破棄フラグを立てて、後に実行される破棄システム(DestroySystem )でフラグが立っているものを一斉に破棄する流れとなってます。



  • CheckHitJob



    • IJobProcessComponentDataで白い球体が持つ「SphereCollider」と「Destroyable(破棄管理データ)」を受け取り、赤い球体との接触判定を行って接触していたら破棄フラグを立てるJobです。




  • CollisionSystem


    • 衝突判定処理全般を回すComponentSystemです。

    • やっている事を順に纏めると以下の様になります。


      1. 赤い球体のComponentGroupから取得できるComponentDataArray<SphereCollider>を一時的なNativeArrayにコピー。


        • ※CEDECの講演でも話されておりましたが、NativeArrayは内部的に色々と最適化が掛かっているところがあり、効率をあげられる可能性があるとのことなので今回の実装ではComponentDataArrayをそのまま使わずにReadOnlyな物は一度コピーしてます。

          その上で処理中で使用されているCopyComponentData<T>はComponentDataArrayの内容をNativeArrayにコピーするJobとなります。



      2. CheckHitJobに手順1の手順でコピーした赤い球体のNativeArray<SphereCollider>を渡し、Jobをスケジュールして総当たりでチェック。





今回のサンプルでは衝突したら破棄だけを行っておりますが、例えばIJobProcessComponentDataでEntityのライフ情報などを含んだComponentDataも取得し、衝突したらHPを減らすと言った実装例なども考えられるかと思われます。

/// <summary>

/// 衝突判定(総当たり)
/// </summary>
[UpdateInGroup(typeof(CollisionUpdateGroup))]
[UpdateAfter(typeof(ColliderUpdate))]
public sealed class CollisionSystem : JobComponentSystem
{
// ------------------------------
#region // Jobs

/// <summary>
/// 当たり判定(CheckHit → Player)
/// </summary>
[BurstCompile]
struct CheckHitJob : IJobProcessComponentData<SphereCollider, Destroyable>
{
[ReadOnly] public NativeArray<SphereCollider> PlayerColliders;
public void Execute([ReadOnly] ref SphereCollider checkHitCollider, ref Destroyable destroyable)
{
for (int i = 0; i < this.PlayerColliders.Length; ++i)
{
var playerCollider = this.PlayerColliders[i];
if (checkHitCollider.Intersect(ref playerCollider))
{
// ヒット
destroyable = Destroyable.Kill;
}
}
}
}

#endregion // Jobs

// ------------------------------
#region // Private Fields

ComponentGroup _playerGroup;
NativeArray<SphereCollider> _playerColliders;

#endregion // Private Fields

// ----------------------------------------------------
#region // Protected Methods

protected override void OnCreateManager() => this._playerGroup = GetComponentGroup(ComponentType.ReadOnly<Player>(), ComponentType.ReadOnly<SphereCollider>());
protected override void OnDestroyManager() => this.DisposeBuffers();

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
this.DisposeBuffers();
var handle = inputDeps;

var playerGroupLength = this._playerGroup.CalculateLength();

// Allocate Memory
this._playerColliders = new NativeArray<SphereCollider>(
playerGroupLength,
Allocator.TempJob,
NativeArrayOptions.UninitializedMemory);

// ComponentDataArray
handle = new CopyComponentData<SphereCollider>
{
Source = this._playerGroup.GetComponentDataArray<SphereCollider>(),
Results = this._playerColliders,
}.Schedule(playerGroupLength, 32, handle); ;

// Check Hit
handle = new CheckHitJob
{
PlayerColliders = this._playerColliders
}.Schedule(this, handle);

return handle;
}

#endregion // Protected Methods

// ----------------------------------------------------
#region // Private Methods

void DisposeBuffers()
{
if (this._playerColliders.IsCreated) { this._playerColliders.Dispose(); }
}

#endregion // Private Methods
}


▼ 当たり判定処理について(空間分割)

上述の当たり判定は総当たりを行うタイプでしたが、別例としてCEDECの講演でも話されていた空間分割についても軽く触れてみたいと思います。こちらは空間を分割し総当たりコストを減らすアプローチとなります。

サンプルでこちらを動作させる場合は、SampleSceneにあるMain Camera -> Bootstrap -> 「Is Split Space」にチェックを付けることで総当たりの代わりに以下のシステムを動かすことが出来ます。



  • HashPosition


    • ShpereColliderの位置を元に所属グリッドを決定し、NativeMultiHashMap<int, int>.Concurrentにハッシュ値を格納するJobです。


      • ※参考先のコードの方では複数のEntity(プレイヤー、敵、弾丸など)に対する所属グリッドの決定を行う都合もあってか、IJobParallelForでSphereColliderを受け取る形で算出を行っておりますが、今回の実装例だと赤い球体のShpereColliderに対するグリッドの決定しか行っていないので、IJobProcessComponentDataでPlayer : IConponentDataを受け取るようすることで実装する手段も考えられるかもしれません。

      • ※後はJobで回す想定となるのでConcurrentで渡しております。






  • CheckHitJob


    • 総当たりと違い、こちらの方では赤い球体の所属グリッドに属する白い球体を取得 → その範囲に対して削除処理を行ってます。




  • CollisionSystem


    • こちらの方は総当たりで言う手順1と手順2の間に所属グリッドを決定する為のJob(HashPosition)が挟まる形になってます。



// ▽ 参照

// https://github.com/Unity-Technologies/AnotherThreadECS
// AnotherThreadECS/Assets/Scripts/ECSCollision.cs

/// <summary>
/// 衝突判定(空間分割)
/// </summary>
[UpdateInGroup(typeof(CollisionUpdateGroup))]
[UpdateAfter(typeof(ColliderUpdate))]
public sealed class SplitSpaceCollisionSystem : JobComponentSystem
{
// ------------------------------
#region // Jobs

/// <summary>
/// ハッシュ値の算出
/// </summary>
[BurstCompile]
struct HashPositions : IJobParallelFor
{
public float CellRadius;
[ReadOnly] public ComponentDataArray<SphereCollider> SphereColliders;
[ReadOnly] public NativeArray<float3> Offsets;
public NativeMultiHashMap<int, int>.Concurrent Hashmap;
public void Execute(int i)
{
var sphereCollider = this.SphereColliders[i];
var center = sphereCollider.Position;
var hashCenter = GridHash.Hash(center, this.CellRadius);
this.Hashmap.Add(hashCenter, i);
for (int j = 0; j < this.Offsets.Length; ++j)
{
var offset = center + this.Offsets[j] * sphereCollider.Radius;
var hash = GridHash.Hash(offset, this.CellRadius);
if (hash == hashCenter) { continue; }
this.Hashmap.Add(hash, i);
}
}
}

/// <summary>
/// 当たり判定(CheckHit → Player)
/// </summary>
[BurstCompile]
struct CheckHitJob : IJobProcessComponentData<SphereCollider, Destroyable>
{
public float CellRadius;
[ReadOnly] public NativeArray<SphereCollider> PlayerColliders;
[ReadOnly] public NativeMultiHashMap<int, int> PlayerHashmap;
public void Execute([ReadOnly] ref SphereCollider checkHitCollider, ref Destroyable destroyable)
{
int hash = GridHash.Hash(checkHitCollider.Position, this.CellRadius);
int index; NativeMultiHashMapIterator<int> iterator;
for (bool success = this.PlayerHashmap.TryGetFirstValue(hash, out index, out iterator);
success;
success = this.PlayerHashmap.TryGetNextValue(out index, ref iterator))
{
var playerCollider = this.PlayerColliders[index];
if (checkHitCollider.Intersect(ref playerCollider))
{
// ヒット
destroyable = Destroyable.Kill;
}
}
}
}

#endregion // Jobs

// ------------------------------
#region // Private Fields
const int MaxGridNum = 3 * 3 * 3;
const float CellRadius = 8f;

NativeArray<float3> _offsets;

ComponentGroup _playerGroup;
NativeArray<SphereCollider> _playerColliders;
NativeMultiHashMap<int, int> _playerHashmap;

#endregion // Private Fields

// ----------------------------------------------------
#region // Protected Methods

protected override void OnCreateManager()
{
// 3 x 3 x 3( = 27)のグリッドを作成。中心をremoveしているので実体としては26
this._offsets = new NativeArray<float3>(27 - 1, Allocator.Persistent);
this._offsets[0] = new float3(1f, 1f, 1f);
this._offsets[1] = new float3(1f, 1f, 0f);
this._offsets[2] = new float3(1f, 1f, -1f);
this._offsets[3] = new float3(1f, 0f, 1f);
this._offsets[4] = new float3(1f, 0f, 0f);
this._offsets[5] = new float3(1f, 0f, -1f);
this._offsets[6] = new float3(1f, -1f, 1f);
this._offsets[7] = new float3(1f, -1f, 0f);
this._offsets[8] = new float3(1f, -1f, -1f);

this._offsets[9] = new float3(0f, 1f, 1f);
this._offsets[10] = new float3(0f, 1f, 0f);
this._offsets[11] = new float3(0f, 1f, -1f);
this._offsets[12] = new float3(0f, 0f, 1f);
// removed center
this._offsets[13] = new float3(0f, 0f, -1f);
this._offsets[14] = new float3(0f, -1f, 1f);
this._offsets[15] = new float3(0f, -1f, 0f);
this._offsets[16] = new float3(0f, -1f, -1f);

this._offsets[17] = new float3(-1f, 1f, 1f);
this._offsets[18] = new float3(-1f, 1f, 0f);
this._offsets[19] = new float3(-1f, 1f, -1f);
this._offsets[20] = new float3(-1f, 0f, 1f);
this._offsets[21] = new float3(-1f, 0f, 0f);
this._offsets[22] = new float3(-1f, 0f, -1f);
this._offsets[23] = new float3(-1f, -1f, 1f);
this._offsets[24] = new float3(-1f, -1f, 0f);
this._offsets[25] = new float3(-1f, -1f, -1f);

// ComponentGroupの設定
this._playerGroup = GetComponentGroup(ComponentType.ReadOnly<Player>(), ComponentType.ReadOnly<SphereCollider>());
}

protected override void OnDestroyManager()
{
if (this._offsets.IsCreated) { this._offsets.Dispose(); }
this.DisposeBuffers();
}

protected override JobHandle OnUpdate(JobHandle inputDeps)
{
this.DisposeBuffers();
var handle = inputDeps;
var playerGroupLength = this._playerGroup.CalculateLength();

// ---------------------
// Allocate Memory
this._playerColliders = new NativeArray<SphereCollider>(
playerGroupLength,
Allocator.TempJob,
NativeArrayOptions.UninitializedMemory);

this._playerHashmap = new NativeMultiHashMap<int, int>(
playerGroupLength * MaxGridNum,
Allocator.TempJob);

// ---------------------
// ComponentDataArray
var copyPlayerColliderJobHandle = new CopyComponentData<SphereCollider>
{
Source = this._playerGroup.GetComponentDataArray<SphereCollider>(),
Results = this._playerColliders,
}.Schedule(playerGroupLength, 32, handle);

// Hashmap Settings
var playerHashmapJobHandle = new HashPositions
{
CellRadius = CellRadius,
Offsets = this._offsets,
SphereColliders = this._playerGroup.GetComponentDataArray<SphereCollider>(),
Hashmap = this._playerHashmap.ToConcurrent(),
}.Schedule(playerGroupLength, 32, handle);

// ※Jobの依存関係の結合
var handles = new NativeArray<JobHandle>(2, Allocator.Temp);
handles[0] = copyPlayerColliderJobHandle;
handles[1] = playerHashmapJobHandle;
handle = JobHandle.CombineDependencies(handles);
handles.Dispose();

// ---------------------
// Check Hit
handle = new CheckHitJob
{
CellRadius = CellRadius,
PlayerColliders = this._playerColliders,
PlayerHashmap = this._playerHashmap,
}.Schedule(this, handle);

return handle;
}

#endregion // Protected Methods

// ----------------------------------------------------
#region // Private Methods

void DisposeBuffers()
{
if (this._playerColliders.IsCreated) { this._playerColliders.Dispose(); }
if (this._playerHashmap.IsCreated) { this._playerHashmap.Dispose(); }
}
}

ちなみに、今回のサンプルで「総当たり」と「空間分割」のパフォーマンスを計測した所、前者の方がパフォーマンス的には少し上でした。

講演の方でも「場合によっては総当たりで十分な場合もある」と語られているように、内容によっては手順が減る分、総当たりの方がパフォーマンスが上/又は変わらないぐらいと言った結果もあるかもしれないので、規模に合わせて使い分けていくのが良いかと思われます。


▽ まとめ/所感など

内容としてはほぼほぼCEDEC講演の物を参考/ベースにした物となりましたが、一先ずは必要であろうモジュール部分を切り取って解説してみました。(破棄管理システム周り等は個人的に参考になりました)

実は以前にも「ECSで弾幕STGを作ってみたので技術周りについて解説」と言う記事にて簡単に衝突判定についても触れていたのですが、この時はComponentSystem側で直接座標なり半径なりを取得して総当たりで当たり判定を取っておりました。

それでもパフォーマンス自体は出ていたという認識では有りましたが、今回の実装を踏まえて空間分割と言ったテクニックについて(多分)ある程度の理解は進んだかと思われます。

後は今回の内容では扱えておりませんが、現状の物だとコリジョンの可視化と言った対応が出来ていないので、本格的に運用を見据えるとデバッグ用のシステムを実装してコリジョンを可視化させる/衝突したら色を変えると言ったシステムの実装を検討してみるのも有りかもしれません。(公式で実装されるコリジョンシステムに搭載されるかどうかは分かりませんが...)

上記の弾幕STGだと簡易的な可視化機能は実装したものの、MonoBehaviour側で描画を行っているので大量に表示する分だけ重くなるという弱点が有りました...。物によってはこれで十分というのもあるかもしれませんが、要件によってはComponentSystemでの実装を検討してみるのも有りかもしれません。


▽ 参考/関連サイト


GitHub