📝

Unity ECSで使える実装パターン4選

2022/12/05に公開

はじめに

Happy Elements株式会社 カカリアスタジオで新規タイトルを開発中のチームでゲームエンジニアとして開発をおこなっているN.M.です。

以前はとある会社でiOS/Androidのアプリ開発をしていたのですが、カカリアスタジオに入社してからは、運営タイトルで主にゲームクライアントの開発を担当してきました。
入社してから丁度6年が経ちました。

今年度から新規タイトルの開発チームにアサインされ、日々新しい技術の習得を行なっています。運営タイトルでは導入にハードルがあるような技術も色々と試していっている最中です。

本稿では、その中でもUnity ECS(以下、ECS)の調査を通して得られた実装手法を紹介したいと思います。Unity ECSは1.0.0がリリースされたばかりということもあり、設計に関する知見がまだあまり出回っておらず、日々悩みながら開発をしています。本稿の内容についても不正確な部分があるかもしれませんが、ご容赦いただければ幸いです。

ECSの実装パターン

ECSのコンポーネントにはマネージドなclass型変数を載せることができるため、(ECSの仕組みに乗っかる必要はあるものの)実のところほとんど制限なく従来のスタイルの実装を行うことが可能です。
ただし、ECSはJob SystemやBurst Compilerとの連携を強く意識して設計されており、これらの恩恵を最大限に得ようとすると、HPC#(High Perfromance C#)という枠組みの中で実装を行う必要があります。

HPC#はC#そのものに比べ、実装に様々な制限がかかるため、従来の実装方法をそのまま適用することが難しくなります。本記事では、HPC#の枠組みの中で従来のような実装を実現するために使えそうな方法を紹介していきたいと思います。

継承

これはstructで継承を実現するためのテクニックです。
HPC#ではclassが使えないため、そのままでは継承を実現することができません。
C#標準の継承に比べ機能が限定されてしまいますが、HPC#の範囲内でも継承のようなものを実現することが可能です。

実際にUnity Physicsの実装で使われているコードの一部を紹介します。

PhysicsColliderのフィールドは次のようになっています。

public struct PhysicsCollider : IComponentData
{
  public BlobAssetReference<Collider> Value;  // null is allowed
  public unsafe Collider* ColliderPtr => (Collider*)Value.GetUnsafePtr();
}

上記のメンバにあるColliderのフィールドは、ColliderHeader型メンバを持っています。

public struct Collider : ICompositeCollider
{
  private ColliderHeader m_Header;
}

Colliderの派生型として、SphereCollider、BoxColliderなどが定義されています。SphereCollider、BoxColliderのフィールドは以下の通りです。

public struct SphereCollider : IConvexCollider
{
  private ConvexColliderHeader m_Header;
  internal ConvexHull ConvexHull;
  private float3 m_Vertex;
}

public struct BoxCollider : IConvexCollider
{
  private ConvexColliderHeader m_Header;
  internal ConvexHull ConvexHull;

  private unsafe fixed byte m_Vertices[sizeof(float) * 3 * 8];
  ...
}

一見すると、BoxCollider、ShpereColliderとColliderの間に継承関係がないように見えます。
ここでポイントとなるのは、派生型と基底となる型(Collider)の先頭のフィールドがそれぞれ、ConvexColliderHeader、ColliderHeaderであり、さらにConvexColliderHeaderの先頭のフィールドがColliderHeaderが持つフィールドと型、配置が揃えられている点にあります。

ConvexColliderHeader、ColliderHeaderは以下のようなフィールドを持っています。

public struct ColliderHeader
{
  public ColliderType Type;
  public CollisionType CollisionType;
  public byte Version;
  public byte Magic;
  public CollisionFilter Filter;
}

public struct ConvexColliderHeader
{
  public ColliderType Type;
  public CollisionType CollisionType;
  public byte Version;
  public byte Magic;
  public CollisionFilter Filter;
  public Material Material;
}

Filterまでのフィールドの型と定義順が全く同じになるように配置されています。
これはそのように意図して配置されており、逆にこれらの配置が違うと正しく動作しなくなります。

上記のように先頭のフィールドのレイアウトを揃えると、以下のようなポインタのキャストが可能になります。 [1]

public unsafe Aabb CalculateAabb(RigidTransform transform)
{
  fixed(Collider* collider = &this)
  {
    switch (collider->Type)
    {
      case ColliderType.Sphere:
        return ((SphereCollider*)collider)->CalculateAabb(transform);
      case ColliderType.Box:
          return ((BoxCollider*)collider)->CalculateAabb(transform);
        ...
    }
  }
}

ColliderとSphereCollider、BoxColliderには直接の継承関係はありませんが、上記のようにレイアウトを揃えておくと、ポインタをキャストしても問題なく動作するようになり、継承と似た動作を実現できていることがわかります。 [2]

仮想関数についてはBurstパッケージのFunction Pointers[3]を使用すれば擬似的に実現できる可能性はあるものの、本記事の執筆時点では検証まで至っていません。[4]

この手法は本来継承機能がないC言語において継承を実現するための手法と同様のものです。[5]

SharedStatic

通常のstatic変数でアンマネージドな参照を作ろうとすると、ポインターを宣言する必要があるのですが、SharedStatic[6]はそれを安全に取り扱うための仕組みとなっています。

典型的な使い方は、static readonlyなSharedStaticを作成し、GetOrCreateを使って初期化を行います。

static readonly SharedStatic<T> Ref = SharedStatic<T>.GetOrCreate<TContext>()
static readonly SharedStatic<T> Ref = SharedStatic<T>.GetOrCreate<TContext, TSubContext>()

ここで、Tは格納するデータ(struct)の型、TContextはこのstatic変数を同定するための型です。TContextは、SharedStatic変数を包含する型が使われることが多いようです。TSubContextは、TContextだけだと情報が足りないという場合に追加する補助的な型となっています。

SharedStaticには破棄を行うためのメソッドが実装されておらず、一度作ったらアプリケーションの終了まで破棄しないという考えで作られているようです。このため、SharedStaticを使用する場合は、必ずstatic readonlyな変数にしておいた方がよさそうです。

Native Array Proxy

NativeArrayはComponentに直接載せるとコンパイルエラーになりますが、少し工夫をするとこれを回避することが可能です。

まず、以下のようなProxyクラスを作成します。

struct NativeArrayProxy
{
  void* Ptr;
  int Size;

  public static NativeArrayProxy Create<T>(NativeArray<T> src) where T : struct
  {
    return new NativeArrayProxy() { Ptr = src.GetUnsafeReadOnlyPtr(), Size = src.Length };
  }
}

コンポーネントには、NativeArrayの代わりにこのProxyクラスをメンバとして配置します。

struct FooComponent : IComponentData
{
  NativeArrayProxy _list;
}

適当なタイミングでCreateを呼んであげれば、NativeArrayをコンポーネントに直接関連付けることができます。より詳しい扱い方については、Unity.PhysicsのPhysicsCompoentの実装を参考にしてください。

メモリーリークを避けるために、配列の解放を確実に行うための何らかの管理機構が必要です。

例えば、エンティティの破棄を専用のコンポーネント(例えば、DestroyComponent)を付与することで表現し、そのコンポーネントを処理するシステム(例えば、DestroySystem)で一括してエンティティの破棄を行うようにするなどが考えられます。

上記の管理方法とは別の方法として、NativeArrayの本体をEntity外の別の場所で管理する方法もあり得ます。

コンテナの入れ子

DictionaryはNativeParallelHashMapで表現できますが、Dictionary<string, Dictionary<string, object>> のように入れ子になっている場合はどうでしょうか?

NativeParallelHashMapは入れ子にすることができないため、別の方法を考える必要があります。
このようなケースで使用できるのがUnsafeParallelHashMapです。

NativeParallelHashMapのようなNative系コンテナは、安全性を高めるために様々なチェックを行うのですがBlittableではありません。
一方、UnsafeParallelHashMapのようなUnsafe系コンテナは、安全性を犠牲にしている代わりにBlittableな型として実装されており、IntPtrへの変換が可能という仕様になっています。

UnsafeUtility.IsBlittable<NativeParallelHashMap<int, IntPtr>>() // => False
UnsafeUtility.IsBlittable<UnsafeParallelHashMap<int, IntPtr>>() // => True

UnsafeParallelHashMapへのポインタはIntPtrに変換可能なため、外側の連想配列の値の型としてIntPtrを適用すれば、入れ子になった連想配列を実現することが可能です。

具体的な例は以下のようになります。

public unsafe struct NativeAnyMultiMap<TKey, TSubKey> : IDisposable
  where TKey : unmanaged, IEquatable<TKey>
  where TSubKey : unmanaged, IEquatable<TSubKey>
{
  NativeParallelHashMap<TKey, IntPtr> _buffers;
  readonly Allocator _allocator;

  public void Set<TValue>(TKey key, TSubKey subKey, TValue value)
    where TValue : unmanaged
  {
    var buffer = GetBuffer(key);
    if (buffer->TryGetValue(subKey, out var oldPtr)) {
      *(TValue*)oldPtr = value;
      return;
    }

    var ptr = Malloc<TValue>();
    *ptr = value;
    buffer->Add(subKey, (IntPtr)ptr);
  }

  public TValue Get<TValue>(TKey key, TSubKey subKey)
    where TValue : unmanaged
  {
    var buffer = GetBuffer(key);
    if (buffer->TryGetValue(subKey, out var ptr)) {
      return *(TValue*)ptr;
    }

    return default;
  }

  UnsafeParallelHashMap<TSubKey, IntPtr>* GetBuffer(TKey key)
  {
    if (_buffers.TryGetValue(key, out var buffer)) {
      return (UnsafeParallelHashMap<TSubKey, IntPtr>*)buffer;
    }

    var ptr = Malloc<UnsafeParallelHashMap<TSubKey, IntPtr>>();
    _buffers[key] = (IntPtr)ptr;
    return ptr;
  }

  T* Malloc<T>()
    where T : unmanaged =>
    (T*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<T>(), UnsafeUtility.AlignOf<T>(), _allocator);
}

Unsafe系コンテナは、Unity.Collectionパッケージで提供されており、UnsafeParallelHashMapの他に、UnsafeList、UnsafeParalellHashSet、UnsafeParallelMultiHashMap、UnsafeRingQueue、UnsafeTextが存在します。その名の通り間違った使い方をすると危険ですが、必要に応じてNative系コンテナと使い分けれると良さそうです。

おわりに

ECSを使った実装パターンを4つ紹介しました。

ECS単体で見るとそれ程大きな制限はありませんが。Job SystemとBurst Compilerの恩恵を最大限に引き出そうとすると、HPC#による制限を受けることになり、これが実質的にECSによる実装を大きく制限している要因となっています。ある程度複雑なシステムを実装しようとするとこういった実装パターンを知っていく必要がありそうです。

HPC#による実装は、C/C++的な書き方だなという印象を持たれる方も多いのではないでしょうか。ECSの実装のあちらこちらで使用されているUnsafeUtilityにはMalloc, MemCpy, MemMoveなどまんまC言語なメソッドもたくさんありますし、C#とは少し感覚を変えて取り組む必要があるのかなと感じています。

以上、何かの参考になれば幸いです。

脚注
  1. レイアウトがSequentialであることも必要ですが、structのレイアウトはデフォルトでSequentialなため問題ありません。 ↩︎

  2. 逆に言えば、レイアウトが揃っていない型同士でこのようなキャストを行うと未定義の動作を引き起こします。 ↩︎

  3. https://docs.unity3d.com/Packages/com.unity.burst@1.4/manual/docs/AdvancedUsages.html ↩︎

  4. 言語サポートがない中で無理矢理実現する意義はあまりないかもしれない。 ↩︎

  5. C言語の継承の実装は、基底クラスを派生クラスの最初のメンバとすることが多い印象。 ↩︎

  6. SharedStaticはUnity.Burstパッケージで提供されています。 ↩︎

GitHubで編集を提案
Happy Elements

Discussion