This document is about: QUANTUM 3
SWITCH TO

コンポーネント

はじめに

コンポーネントは、エンティティにアタッチできる特別な構造体で、フィルタリング(アタッチされたコンポーネントに基づいて、アクティブなエンティティのサブセットのみを反復処理)に使用されます。

独自コンポーネントとは別に、Quantumにはいくつかの組み込みコンポーネントがあります。

  • Transform2D/Transform3D:固定小数点数(FP)を使用した位置と回転の値です。
  • PhysicsCollider/PhysicsBody/PhysicsCallbacks/PhysicsJoints(2D/3D):Quantumのステートレスな物理エンジンで使用されます。
  • PathFinderAgent/SteeringAgent/AoidanceAgent/AvoidanceObstacle:ナビメッシュに基づく経路探索と移動です。

コンポーネント

以下はDSLのコンポーネント定義の基本的な例です。

Qtn

component Action
{
    FP Cooldown;
    FP Power;
}

コンポーネントとラベル付け(上記のように)すると、構造体のかわりに、適切なコード構造(マーカーインターフェース、IDプロパティなど)が生成されます。一度コンパイルされると、これらはUnityエディター上からエンティティプロトタイプに使用できるようになります。エディターでは、独自コンポーネントはEntity Component ComponentNameとして名付けられます。

コンポーネントで作業するためのAPIは、Frameクラスから提供されます。開発者は、コンポーネントのコピーで作業するか、コンポーネントのポインタで作業するかを選択できます。アクセスの種類を区別するために、コピーで作業するためのAPIはFrameから直接アクセスし、ポインタにアクセスするAPIはFrame.Unsafeから利用可能です。後者はメモリを直接変更します。

コンポーネントの「追加」「取得」「設定」する最も基本的な関数は、同じ名前(AddGetSet)の関数になります。

Add<T>は、エンティティにコンポーネントを追加するために使用されます。各エンティティは、特定のコンポーネントのコピーを1つだけ持つことができます。デバッグを便利にするため、Add<T>は列挙型AddResultを返します。

C#

public enum AddResult {
    EntityDoesNotExist     = 0, // 渡されたEntityRefは無効
    ComponentAlreadyExists = 1, // 対象のエンティティには既にコンポーネントがアタッチされている
    ComponentAdded         = 2  // コンポーネントが正常にエンティティに追加された
}

エンティティがコンポーネントを持っている場合、Get<T>を使用してそれを取得できます。これにより、コンポーネントの値のコピーが返されます。コピーで作業しているなら、Set<T>を使用して変更した値をコンポーネントに保存する必要があります。Addメソッドと同様に、結果を確認したりするために使用できるSetResultを返します。

C#

public enum SetResult {
    EntityDoesNotExist = 0, // 渡されたEntityRefは無効
    ComponentUpdated   = 1, // コンポーネントの値が正常に更新された
    ComponentAdded     = 2  // エンティティはコンポーネントを持たないため、新しい値が追加された
}

例えば、ヘルスコンポーネントの初期値を設定する場合は、次の通りです。

C#

private void SetHealth(Frame frame, EntityRef entity, FP value){    
    var health = frame.Get<Health>(entity);
    health.Value = value;
    frame.Set(entity, health);
}

以下の表は、コンポーネントやその値を操作するためのメソッドの概要です。

メソッド 戻り値 追加情報
Add<T>(EntityRef entityRef) AddResult列挙型(上記参照) 無効なEntityRefを指定可能です。
Get<T>(EntityRef entityRef) Tの現在値のコピー 無効なEntityRefは指定できません。
コンポーネントTがエンティティに存在しない場合は例外がスローされます。
Set<T>(EntityRef entityRef) SetResult列挙型(上記参照) 無効なEntityRefを指定可能です。
Has<T>(EntityRef entityRef) エンティティが存在し、コンポーネントがアタッチされている場合はtrue

エンティティが存在しないか、コンポーネントがアタッチされていない場合はfalse
無効なEntityRefや、存在しないコンポーネントを指定可能です。
TryGet<T>(EntityRef entityRef, out T value) エンティティが存在し、コンポーネントがアタッチされている場合はtrue

エンティティが存在しないか、コンポーネントがアタッチされていない場合はfalse
無効なEntityRefを指定可能です。
TryGetComponentSet(EntityRef entityRef,
out ComponentSet componentSet)
エンティティが存在し、すべてのコンポーネントがアタッチされている場合はtrue

エンティティが存在しないか、セット中のコンポーネントが1つ以上アタッチされていない場合はfalse
無効なEntityRefを指定可能です。
Remove<T>(EntityRef entityRef) 戻り値なし。
エンティティが存在し、コンポーネントがアタッチされていれば、コンポーネントを削除します。
それ以外の場合は何もしません。
無効なEntityRefを指定可能です。

コンポーネントを直接取り扱えるようにしてGet/Setを使用する小さなオーバーヘッドを避けるために、Frame.UnsafeunsafeバージョンのGet/TryGetを提供しています(以下の表を参照)。

メソッド 戻り値 追加情報
GetPointer<T>(EntityRef entityRef) T* 無効なEntityRefは指定できません。
コンポーネントTがエンティティに存在しない場合は例外がスローされます。
TryGetPointer<T>(EntityRef entityRef
out T* value)
エンティティが存在し、コンポーネントがアタッチされている場合はtrue

エンティティが存在しないか、コンポーネントがアタッチされていない場合はfalse
無効なEntityRefを指定可能です。
AddOrGet<T>(EntityRef entityRef, out <T>* result) エンティティが存在し、コンポーネントを追加するか既にアタッチされていたらtrue

エンティティが存在しない場合はfalse
無効なEntityRefを指定可能です。

モノリシック(巨大)な構造体は避けて、複数の構造体に分割してください。IL2CPPコンパイル時に、bracket nesting level exceeded maximumエラーが発生する場合があります。

シングルトンコンポーネント

「シングルトンコンポーネント」は、同時に1つだけ存在できる特殊なコンポーネントです。T型のシングルトンコンポーネントのインスタンスは、ゲームステート全体で任意のエンティティに1つだけ存在できます。これはQuantumのECSデータバッファのコア部分で厳密に強制されます。

独自の「シングルトンコンポーネント」は、DSLでsingleton componentを使用して定義できます。

C#

singleton component MySingleton {
    FP Foo;
}

シングルトンはIComponentSingletonというインターフェースを継承し、このインターフェース自体はIComponentを継承しているため、通常のコンポーネントに期待される一般的な機能をすべて実行できます。

  • 任意のエンティティにアタッチできます。
  • すべての通常のsafe/unsafe(例:GetSetTryGetPointerなど)メソッドで管理できます。
  • Unityエディターからエンティティプロトタイプに設定したり、コードからエンティティ上にインスタンス化できます。

通常のコンポーネント関連のメソッドに加えて、シングルトン専用の特別なメソッドがいくつかあります。通常のコンポーネントと同様に、メソッドは値を返すかポインタを返すかどうかでsafe/unsafeに分かれています。

メソッド 戻り値 追加情報
API - Frame
SetSingleton<T> (T component,
EntityRef optionalAddTarget = default)
void シングルトンが存在しない場合にシングルトンを設定します。
-------
(オプション引数の)EntityRefで、どのエンティティに追加するかを指定します。
指定しない場合は、シングルトンを追加するために新しいエンティティが作成されます。
GetSingleton<T>() T シングルトンが存在しない場合は例外をスローします。
EntityRefは自動的に取得するため、パラメーターに渡す必要はありません。
TryGetSingleton<T>(out T component) bool
シングルトンが存在するならtrue
シングルトンが存在しないならfalse
シングルトンが存在しない場合でも例外をスローしません。
EntityRefは自動的に取得するため、パラメーターに渡す必要はありません。
GetOrAddSingleton<T>(EntityRef optionalAddTarget = default) T シングルトンを取得して返します。
シングルトンが存在しない場合は、SetSingletonのように作成されます。
-----
(オプション引数の)EntityRefで、シングルトンを作成する必要がある場合は、どのエンティティに追加するかを指定します。
EntityRefが渡されない場合は、新しいエンティティが作成されてシングルトンが追加されます。
GetSingletonEntityRef<T>() EntityRef シングルトンを保持しているエンティティを返します。
シングルトンが存在しない場合は例外をスローします。
TryGetSingletonEntityRef<T>(out EntityRef entityRef) bool
シングルトンが存在するならtrue
シングルトンが存在しないならfalse
シングルトンを保持しているエンティティを取得します。
シングルトンが存在しない場合でも例外をスローしません。
API - Frame.Unsafe
Unsafe.GetPointerSingleton<T>() T* シングルトンのポインタを取得します。
存在しない場合は例外をスローします。
TryGetPointerSingleton<T>(out T* component) bool
シングルトンが存在するならtrue
シングルトンが存在しないならfalse
シングルトンのポインタを取得します。
GetOrAddSingletonPointer<T>(EntityRef optionalAddTarget = default) T* シングルトンを取得、または追加して返します。
シングルトンが存在しない場合は、シングルトンが作成されます。
-----
(オプション引数の)EntityRefで、シングルトンを作成する必要がある場合は、どのエンティティに追加するかを指定します。
EntityRefが渡されない場合は、新しいエンティティが作成されてシングルトンが追加されます。

ComponentTypeRef

ComponentTypeRef構造体は、実行時にコンポーネントの型を参照する方法を提供します。これは、ポリモーフィズムで動的にコンポーネントを追加した場合に便利です。

C#

// 例:アセットかプロトタイプを設定
ComponentTypeRef componentTypeRef;

var componentIndex = ComponentTypeId.GetComponentIndex(componentTypeRef);

frame.Add(entityRef, componentIndex);

機能の追加

コンポーネントは特別な構造体のため、C#ファイルにpartial構造体定義を記述することで、独自のメソッドを追加できます。
例えば、前述のActionコンポーネントを次のように拡張できます。

C#

namespace Quantum
{
    public partial struct Action
    {
        public void UpdateCooldown(FP deltaTime){
            Cooldown -= deltaTime;
        }
    }
}

リアクティブコールバック

コンポーネント特有のリアクティブコールバックが2つあります。

  • ISignalOnComponentAdd<T>:コンポーネント型Tがエンティティに追加されたときに呼び出されます。
  • ISignalOnComponentRemove<T>: コンポーネント型Tがエンティティから削除されたときに呼び出されます。

これらは、コンポーネントが追加/削除された時に、何らかの操作をする必要がある場合に便利です。例えば、独自コンポーネント内のリストのメモリを割り当て/解放するする場合などです。

これらのシグナルを受け取るためには、単にシステムに実装してください。

ComponentIterator

単一のコンポーネントのみが必要な場合は、ComponentIterator(safe)/ComponentBlockIterator(unsafe)が最適です。

C#

foreach (var pair in frame.GetComponentIterator<Transform3D>())
{
    var component = pair.Component;
    component.Position += FPVector3.Forward * frame.DeltaTime;
    frame.Set(pair.Entity, component);
}

ComponentBlockIteratorにより、最速でポインタのアクセスが可能になります。

C#

// この構文によって、エンティティのEntityRefと、指定のコンポーネントT型を持つ
// EntityComponentPointerPair構造体が返される
foreach (var pair in frame.Unsafe.GetComponentBlockIterator<Transform3D>())
{
    pair.Component->Position += FPVector3.Forward * frame.DeltaTime;
}

// また、以下の構文を使用することで構造体を分解して、
// EntityRefとコンポーネントを直接取得することもできる
foreach (var (entityRef, transform) in frame.Unsafe.GetComponentBlockIterator<Transform3D>())
{
    transform->Position += FPVector3.Forward * frame.DeltaTime;
}

フィルター

コンポーネントのセットに基づいてエンティティをフィルタリングして、システムが必要とするコンポーネントのみを取得する便利な方法がフィルターです。フィルターは、Safe(Get/Set)でもUnsafe(ポインタ)でも使用できます。

ジェネリック

フレームのFilter<T>()APIを使用して、フィルターを作成できます。

C#

var filtered = frame.Filter<Transform3D, PhysicsBody3D>();

ジェネリックフィルターは、最大8つのコンポーネントを含むことができます。
ComponentSetフィルターを作成することで、より具体的なフィルタリングが可能です。

C#

var without = ComponentSet.Create<CharacterController3D>();
var any = ComponentSet.Create<NavMeshPathFinder, NavMeshSteeringAgent>();
var filtered = frame.Filter<Transform3D, PhysicsBody3D>(without, any);

ComponentSetは、最大8つのコンポーネントを保持できます。
ComponentSetwithoutパラメーターに渡すと、セットで指定されたコンポーネントを1つ以上含むエンティティをすべて除外します。anyによって、エンティティがセットで指定されたコンポーネントを1つ以上持つことを保証でき、指定されたコンポーネントを1つも持たない場合は、エンティティはフィルターによって除外されます。

Whileループでfilter.Next()を使用して、簡単にフィルターの反復処理が可能です。これによって、すべてのコンポーネントのコピーと、エンティティのEntityRefが格納されます。

C#

while (filtered.Next(out var e, out var t, out var b)) {
  t.Position += FPVector3.Forward * frame.DeltaTime;
  frame.Set(e, t);
}

注意: 反復処理では、コンポーネントのコピーを取り扱っているので、新しいデータは各エンティティに設定し直す必要があります。

ジェネリックフィルターは、コンポーネントのポインタで取り扱うこともできるようになっています。

C#

while (filtered.UnsafeNext(out var e, out var t, out var b)) {
  t->Position += FPVector3.Forward * frame.DeltaTime;
}

この場合は、コンポーネントのデータを直接変更することになります。

FilterStruct

通常のフィルターに加えて、FilterStructを使用することもできます。
取得したい各コンポーネント型のpublicプロパティを持つ構造体を、最初に定義してください。

Qtn

struct PlayerFilter
{
    public EntityRef Entity;
    public CharacterController3D* KCC;
    public Health* Health;
    public FP AccumulatedDamage;
}

ComponentSetのように、FilterStructは最大8つのコンポーネントのポインタをフィルタリングできます。

注意: FilterStructの構造体には、必ずEntityRefフィールドが必要です!

FilterStructの「コンポーネント型」のメンバーはポインタでなければなりません。ポインタのみがフィルターで取得できます。コンポーネントのポインタに加えて、他の変数を定義することもできますが、フィルターからは無視されるため、開発者自身で管理することになります。

C#

var players = frame.Unsafe.FilterStruct<PlayerFilter>();
var playerStruct = default(PlayerFilter);

while (players.Next(&playerStruct))
{
    // 何かする
}

Frame.Unsafe.FilterStruct<T>()には、さらに詳細なフィルタリングを行うために、オプションでany/withoutComponentSetを渡すオーバーロードがあります。

カウントについての備考

フィルター処理中に、いくつのエンティティが反復対象になるかを知ることはできません。これは、スパースセットECSでフィルターを機能させる方法によるものです。

  1. フィルターは、与えられたコンポーネントに関連するエンティティを探す
  2. フィルターに存在するコンポーネントは設定、存在しないコンポーネントは破棄する

実際の数量を知るためには、フィルターを一度走査する必要があり、O(n)になるため、効率的ではありません。

ComponentGetter

既知のエンティティから特定のコンポーネントのセットを取得したい場合は、FilterStructFrame.Unsafe.ComponentGetterを組み合わせて使用してください。 注意: これはUnsafeコンテキストでのみ利用可能です!

C#

public unsafe class MySpecificEntitySystem : SystemMainThread

    struct MyFilter {
        public EntityRef      Entity; // 必須メンバー
        public Transform2D*   Transform2D;
        public PhysicsBody2D* Body;
    }

    public override void Update(Frame frame) {
        MyFilter result = default;
      
        if (frame.Unsafe.ComponentGetter<MyFilter>().TryGet(frame, frame.Global->MyEntity, &result)) {
            // 何かする
        }
    }

この操作を頻繁に行う必要があるなら、以下のようにシステムでルックアップ構造体をキャッシュできます(100%安全です)。

C#

public unsafe class MySpecificEntitySystem : SystemMainThread

    struct MyFilter {
        public EntityRef      Entity; // 必須メンバー
        public Transform2D*   Transform2D;
        public PhysicsBody2D* Body;
    }

    ComponentGetter<MyFilter> _myFilterGetter;

    public override void OnInit(Frame frame) {
      _myFilterGetter = frame.Unsafe.ComponentGetter<MyFilter>();
    }

    public override void Update(Frame frame) {
      MyFilter result = default;
      
      if (_myFilterGetter.TryGet(frame, frame.Global->MyEntity, &result)) {
        // 何かする
      }
    }

フィルタリング戦略

大量のエンティティが存在する状況で、その中のサブセットのみが必要になることは良くあることです。Quantumにおけるコンポーネントとフィルタリングするためのツールをここまで紹介してきましたが、このセクションでは、それらを利用するための戦略をいくつか示します。
注意: 「最善」なアプローチは、ゲームとそのシステムに依存します。あなたのゲーム固有の状況に合ったものを作成するために、これから紹介する戦略を参考にすることを推奨します。

備考:以下に使用されるすべての用語は、冗長な概念を簡潔に表現するために独自に作成されたものです。

マイクロコンポーネント

多くのエンティティは同じコンポーネント型を使用する可能性がありますが、一部のエンティティは全く同じコンポーネントを使用することがあります。これらをさらに特殊化する方法は、マイクロコンポーネントを使用することです。マイクロコンポーネントは、特定のシステムや振る舞いのデータを高度に特殊化したコンポーネントです。この独自性により、エンティティを速やかに識別するフィルターを作成することが可能になります。

フラグコンポーネント

エンティティを識別する共通の方法の一つは、フラグコンポーネントを追加することです。ECSのコンセプトには「フラグ」は存在せず、Quantumも「エンティティ型」をサポートしていません。では実際フラグコンポーネントは何なのか?というと、エンティティを識別するためだけに作成される、データを持たない/わずかなデータのみを持つコンポーネントです。

例えば、チーム対戦ゲームでは以下のいずれかを作成します。

  1. チームA/チームBの列挙型を持つ「チーム」コンポーネント
  2. 「チームA」「チームB」コンポーネント

1の方法は、ビューからデータをポーリングすることが主目的の場合に便利です。2の方法は、関連するシミュレーションのシステム内のフィルタリング効率が良い利点があります。

備考: 「フラグコンポーネント」は「タグコンポーネント」とも呼ばれます。エンティティの「タグ付け」と「フラグ追加」は同じ意味だからです。

カウント

シミュレーションに存在するコンポーネントTの数量は、Frame.ComponentCount<T>()を使用して取得できます。フラグコンポーネントと組み合わせて使用すると、例えば特定のユニット数などを、速やかにカウントできます。

Add / Remove

一時的にのみ、エンティティにフラグコンポーネント/マイクロコンポーネントをアタッチする必要がある場合は、適切なオプションとしてAdd/Remove操作がO(1)で実行できます。

グローバルリスト

フラグコンポーネントの代替方法として、ECSらしくない方法ではありますが、FrameContext.User.csにグローバルリストを持たせる方法があります。保持数が多いとスケールが難しいことがありますが、サブセットが限定的な場合で便利です。

例えばライフが50%未満のすべてのプレイヤーをハイライトしたい場合は、グローバルリストを持たせて以下のようにします。

  • シミュレーションの最初にリストにEntityRefを追加/削除するシステムを持つ
  • その後のシステムで同じリストを使用する

注意: この種の状況の識別が散発的にのみ必要なら、グローバルリストではなく必要に応じて動的に計算することを推奨します。

最大コンポーネント数

デフォルトでは、Quantumは最大256個の異なるコンポーネント型の定義をサポートしています。
ユーザー定義コンポーネントは236個で、Core DLLの組み込みコンポーネント(TransformCollider)が20個です。

大抵のゲームではこれで十分ですが、QTNファイルにコンパイラ定義を追加することで、最大数を512まで増やすことができます。

#pragma max_components 512

コンポーネント数を増やすと、エンティティ数が多いゲームはコンポーネントに基づくフィルタリングに強く依存するようになるため、平均シミュレーション時間が可能性があります。そのため、プロファイリングページで紹介されているプロファイリングテストで、特定のシナリオにおけるパフォーマンスの影響を計測することを推奨します。

独自定義コンポーネントのインポート

Quantum 3では、DSL外でコンポーネントを定義して、それを手動でインポートすることができます。例えば、DLL外でコンポーネントを定義する必要がある場合に便利です。ただし通常はまったく必要ありません。DSLでコンポーネントを定義する通常の方法が安全です。

コンポーネントをインポートするには、DSLにimport FooComponent;/import singleton FooComponent;を追加してください。

コンポーネント定義は、正しくインポートする前にいくつかのガイドラインに従う必要があります。
追記: この定義は非常に慎重に行ってください。正しく実装することは、SDKを機能させるために非常に重要です。正しいコンポーネントサイズの宣言とフィールドオフセットは非常に重要です。

定義の要件は次の通りです。

  1. IComponentインターフェースを実装する
  2. const int SIZEフィールドを持ち、コンポーネントのサイズを定義する
  3. Serializeメソッドを持ち、コンポーネントのシリアライズに使用される(以下のシグネチャを参照)
  4. ComponentChangedDelegate OnAdded静的プロパティ(nullを返す)か、デリゲートのシグネチャと一致するOnAdded静的メソッドを持つ
  5. ComponentChangedDelegate OnRemoved静的プロパティ(nullを返す)か、デリゲートのシグネチャと一致するOnRemoved静的メソッドを持つ

より安全な代替方法の一つは、最初にDSLでコンポーネントを定義し、生成されたコードをコピーした後に削除して、すべての重要な詳細を処理されたものにすることです。

コンポーネント定義に必要な基本的構造体の例は次の通りです。

C#

[StructLayout(LayoutKind.Explicit)]
public unsafe struct Example : IComponent {
  public const int SIZE = sizeof(int);
        
  public static ComponentChangedDelegate OnAdded;
  public static ComponentChangedDelegate OnRemoved;
    
  [FieldOffset(0)]
  public int _number;
  
  public static void Serialize(void* ptr, IDeterministicFrameSerializer serializer) {
    serializer.Stream.Serialize(&((Example*)ptr)->_number);
  }

  public override int GetHashCode() {
    return _number;
  }
}
Back to top