This document is about: QUANTUM 3
SWITCH TO

システム(ゲームロジック)

はじめに

システムは、Quantumにおけるすべてのゲームプレイロジックのエントリーポイントです。

システムは通常のC#クラスとして実装されますが、予測/ロールバックモデルに準拠するため、いくつかの制限があります。システムは以下の条件を満たす必要があります。

  • ステートレスであること:システム内に変更可能なフィールドを宣言してはいけません。変更可能なすべてのゲームデータは.qtnファイルで宣言し、Frameクラス内のロールバック可能なゲームステートの一部にしてください。
  • 決定論的なライブラリとアルゴリズムのみを使用/実装すること(Quantumには、固定小数点演算・ベクトル演算・物理・乱数生成・経路探索などのライブラリが付属しています)

システムには、継承できる基底クラスがいくつか存在します。

  • SystemMainThreadOnInitUpdateコールバックを持ちます。Updateはシステム毎に一度ずつ実行されます。エンティティやそのコンポーネントを反復処理したい場合は、独自のフィルターを作成する必要があります。Quantumのシグナルを購読して反応するために使用することもできます。
  • SystemMainThreadFilter<Filter>SystemMainThreadと同様に動作しますが、コンポーネントレイアウトを定義したフィルターを使用して、それに当てはまる各エンティティに対して一度ずつUpdateが呼び出されます。
  • SystemSignalsOnlyUpdateコールバックを持たず、Quantumのシグナルに反応するためだけに使用されます。タスクスケジューリングがないため、オーバーヘッドが減少します。
  • SystemBase:高度な用途として、タスクグラフの並列ジョブをスケジューリングするために使用します(この基本マニュアルでは説明を割愛します)。

Coreシステム

Quantum SDKは、デフォルトのSystemsConfig内にすべてのCoreシステムを含んでいます。

  • Core.CullingSystem2D():予測フレーム内でTransform2Dコンポーネントを持つエンティティをカリングします。
  • Core.CullingSystem3D():予測フレーム内でTransform3Dコンポーネントを持つエンティティをカリングします。
  • Core.PhysicsSystem2D()Transform2DPhysicsCollider2Dコンポーネントを持つすべてのエンティティに対して物理演算を実行します。
  • Core.PhysicsSystem3D()Transform3DPhysicsCollider3Dコンポーネントを持つすべてのエンティティに対して物理演算を実行します。
  • Core.NavigationSystem():すべてのNavMesh関連コンポーネントに使用されます。
  • Core.EntityPrototypeSystem()EntityPrototypesの作成・マテリアライズ・初期化を行います。
  • Core.PlayerConnectedSystem()ISignalOnPlayerConnectedISignalOnPlayerDisconnectedシグナルをトリガーするために使用されます。
  • Core.DebugCommand.CreateSystem():エンティティのインスタンス化/削除/修正を即時に送信するために、状態インスペクターによって使用されます(エディターでのみ利用可能です!)。

利便性のため、すべてのシステムがデフォルトで含まれています。ゲームに必要な機能に基づいて、Coreシステムを選択的に追加/削除できます。例えば、ゲームに応じてPhysicsSystem2DPhysicsSystem3Dいずれかのみを残すことができます。

基本システム

Unityでは、右クリックメニューからスクリプトテンプレートを使用して、Quantumシステムを作成できます。

System Templates

これで生成されるコードスニペットは次のようなものです。

System

C#

namespace Quantum {
  using Photon.Deterministic;
  using UnityEngine.Scripting;

  [Preserve]
  public unsafe class NewQuantumSystem : SystemMainThread {
    public override void Update(Frame frame) {
    }
  }
}

オーバーライド可能なAPIは以下の通りです。

  • OnInit(Frame frame)
  • Update(Frame frame)
  • OnDisabled(Frame frame)/OnEnabled(Frame frame)
  • StartEnabled

System Filter

C#

namespace Quantum {
  using Photon.Deterministic;
  using UnityEngine.Scripting;

  [Preserve]
  public unsafe class NewQuantumSystem : SystemMainThreadFilter<NewQuantumSystem.Filter> {
    public override void Update(Frame frame, ref Filter filter) {
    }

    public struct Filter {
      public EntityRef Entity;
    }
  }
}

オーバーライド可能なAPIはSystemMainThreadと同じですが、追加で以下のものがあります。

  • Any
  • Without

System Signals Only

C#

namespace Quantum {
  using Photon.Deterministic;
  using UnityEngine.Scripting;

  [Preserve]
  public unsafe class NewQuantumSystem : SystemSignalsOnly {
  }
}

オーバーライド可能なAPIはSystemMainThreadと同じですが、Updateは含まれません。

システムクラスでオーバーライドできる主なコールバックには、次のものがあります。

  • OnInit:ゲーム開始時に一度だけ実行されます。一般的に、初期ゲームデータのセットアップに使用されます。
  • Update:ゲームステートを進めるために使用されます。
  • OnDisabled(Frame f)/OnEnabled(Frame f):直接システムが無効化/有効化された時や、親システムの状態が切り替わったときに呼び出されます。
  • UseCulling:カリングされたエンティティを除外するどうかを定義します。

補足: すべてのQuantumシステムは、[UnityEngine.Scripting.Preserve]属性を使用することが必須です。

すべての使用可能なコールバックにはFrameインスタンスが含まれることに注意してください。Frameクラスは、エンティティ・物理・ナビゲーション・他の不変アセットオブジェクト(別のチャプターで説明しています)などを含む、すべての変更可能および静的なゲームステートデータのコンテナです。

その理由は、Quantumの予測/ロールバックモデルに準拠するために、システムがステートレスである必要があるためです。Quantumは、すべての(変更可能な)ゲームステートデータが、Frameインスタンスに完全に含まれている場合にのみ決定論性を保証します。

読み取り専用の定数や、プライベートメソッド(すべての必要なデータをパラメーターとして受け取る)を作成することは可能です。

以下のコードスニペットは、システム内で有効なものと無効なもの(ステートレスの要件に違反している)の、基本的な例を示しています。

C#

namespace Quantum 
{

  public unsafe class MySystem : SystemMainThread
  {
  // これは有効
    private const int _readOnlyData = 10;
  // これは無効(データがロールバックされないため、ロールバック時にゲームクライアントでドリフト現象を引き起こす)
    private int _mutableData = 10;

    public override void Update(Frame f)
    {
    // 定数を使用した計算は有効
        var temporaryData = _readOnlyData + 5;

    // Frameオブジェクト外に存在する一時データを変更することは無効
        _transientData = 5;
    }
  }
}

SystemsConfig

Quantum 3では、システム構成の処理方法が変更されました。直接コードで構成を埋め込むのではなく、SystemsConfigアセット内で構成をカプセル化されます。

このアセットがRuntimeConfigに渡されると、Quantumは要求されたシステムを自動的にインスタンス化します。

Quantumには、いくつかの組み込みシステム(物理エンジンの更新のエントリーポイント・ナビメッシュ・エンティティプロトタイプのインスタンス化)が含まれていることに注意してください。

決定論性を保証するために、システムが挿入される順序は、すべてのクライアント上でシミュレーターがすべてのコールバックを実行する順序と同じにします。そのため、更新が発生する順序を制御するために、独自システムは望ましい順序で挿入してください。

新しいSystemsConfigの作成

SystemsConfigは通常のQuantumアセットです。つまり、プロジェクトウインドウを右クリックして「Quantum -> SystemsConfig」を選択することで、新規作成が可能です。

アセットにはシステムのリストがシリアライズされています。これは通常のUnityのリストのように操作できます。

Systems Config

システムの有効化と無効化

すべての注入されたシステムはデフォルトで有効ですが、シミュレーション内の任意の場所で関数(Frameオブジェクトから利用可能)を呼び出すことで、実行時にステータスを制御できます。

C#

public override void OnInit(Frame frame)
{
  // MySystemを無効化して、更新(やシグナル)が呼び出されないようにする
  frame.SystemDisable<MySystem>();

  // MySystemを(再)有効化する
  frame.SystemEnable<MySystem>();

  // システムが現在有効化されているかどうかを調べる
  var enabled = frame.SystemIsEnabled<MySystem>();
}

どのシステムも、他のシステムを無効化(有効化)できます。そのため、メインの制御システムを用意し、シンプルなステートマシンを使用して各システムの有効/無効を管理することが一般的なパターンになります(例えば、最初にゲーム内ロビーがあり、ゲームプレイ前のカウントダウンを行い、その後に通常のゲームプレイに入り、最後にスコア表示を行うなど)。

デフォルトでシステムを無効のまま開始するには、以下のプロパティをオーバーライドします。

C#

public override bool StartEnabled => false;

システムグループ

システムはグループ化することが可能で、まとめて有効/無効にすることができます。

SystemConfigを選択し、新しいSystemGroupを追加して、そこに子システムを追加してください。

System Group

注意: Frame.SystemEnable<T>()Frame.SystemDisable<T>()メソッドは型でシステムを識別します。複数のシステムグループが存在する場合には、複数のシステムグループを独立して有効/無効にするための独自実装が必要になります。この場合は、以下のような新しいシステムグループ型を宣言して、SystemConfigアセットで使用することができます。

C#

namespace Quantum
{
  public class MySystemGroup : SystemMainThreadGroup
  {
    public MySystemGroup(string update, params SystemMainThread[] children) : base(update, children)
    {
    }
  }
}

エンティティライフサイクルAPI

このセクションでは、エンティティの作成/構成を直接行うAPIを説明します。データ駆動型アプローチについては、エンティティプロトタイプのチャプターをご覧ください。

新しいエンティティのインスタンスを作成する方法は以下の通りです(メソッドはEntityRefを返します)。

C#

var e = frame.Create();

エンティティは一切の定義済みコンポーネントを持ちません。Transform3DPhysicsCollider3Dをエンティティに追加する方法は次の通りです。

C#

var t = Transform3D.Create();
frame.Set(e, t);

var c =  PhysicsCollider3D.Create(f, Shape3D.CreateSphere(1));
frame.Set(e, c);

以下の2つのメソッドも便利です。

C#

// エンティティ(とそこに追加されているすべてのコンポーネント)を破棄する
frame.Destroy(e);

// EntityRefが有効かどうかをチェックする(他コンポーネント内で参照を格納する際に便利)
if (frame.Exists(e)) {
  // コンポーネントのGet/Setなどを安全に実行できる
}

エンティティが特定のコンポーネント型を持つかどうかを動的にチェックし、フレームから直接コンポーネントデータのポインタを取得することも可能です。

C#

if (frame.Has<Transform3D>(e)) {
    var t = frame.Unsafe.GetPointer<Transform3D>(e);
}

ComponentSetを使用すると、エンティティが複数のコンポーネントを持っているかどうかを一度にチェックできます。

C#

var components = ComponentSet.Create<CharacterController3D, PhysicsBody3D>();
if (frame.Has(e, components)) {
  // 何かする
}

コンポーネントを動的に削除するのも簡単です。

C#

frame.Remove<Transform3D>(e);

EntityRef型

Quantumのロールバックモデルでは、可変サイズのフレームバッファを維持します。言い換えれば、複数のゲームステートデータ(DSLで定義される)のコピーが、異なる場所のメモリブロックに保持されています。そのため、エンティティ・コンポーネント・構造体へのポインタは、単一のFrameオブジェクト(更新など)内でのみ有効です。

EntityRefは、エンティティへの参照を安全に保持(ポインタの一時的な置き換え)するもので、エンティティが存在する限り、フレームを超えて動作します。EntityRefは、内部的に次のデータを含んでいます。

  • エンティティのインデックス:DSLで定義された特定の型の最大数から決められたエンティティのスロットです。
  • エンティティのバージョン番号:エンティティインスタンスが破棄されてスロットが再利用可能になった時に、古いEntityRefを無効にするために使用されます。

フィルター

Quantumはエンティティの型を持ちません。スパースセットECSメモリモデルでは、エンティティはコンポーネントのコレクションのインデックスで、EntityRef型はバージョンなどの追加情報を保持します。これらのコレクションは、動的に割り当てられたスパースセットに保持されます。
したがって、エンティティのコレクションを反復するかわりに、システムで処理するコンポーネントのセットを作成するためにフィルターが使用されます。

C#

public unsafe class MySystem : SystemMainThread
{
    public override void Update(Frame frame)
    {
        var filtered = rame.Filter<Transform3D, PhysicsBody3D>();

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

フィルターの詳細な使用方法については、「コンポーネント」ページをご覧ください。

組み込みアセットとConfigクラス

Quantumにはいくつかの組み込みデータアセットが含まれていて、Frameオブジェクトを通して常にシステムに渡されます。

非常に重要な組み込みアセットオブジェクト(QuantumのアセットDB)は以下の通りです。

  • Map/NavMesh:プレイエリアに関するデータ・静的物理コライダー・ナビゲーションメッシュなどです。カスタムのプレイヤーデータは、データアセットスロットから追加できます(データアセットのチャプターで説明します)。
  • SimulationConfig:物理エンジン・ナビメッシュシステムなど、一般的な構成データです。
  • デフォルトのPhysicsMaterialagent configs(KCC・ナビメッシュなど)

次のスニペットは、Frameオブジェクトから現在のMapNavMeshインスタンスにアクセスする方法を示しています。

C#

// Mapは複数の静的データ(ナビメッシュなど)のコンテナ
Map map = frame.Map;
var navmesh = map.NavMeshes["MyNavmesh"];

アセットデータベース

すべてのQuantumデータアセットは、システム内FrameのデータベースAPIから利用可能です。シミュレーション内のアセットについての詳細はこちらをご覧ください。ビュー(Unityエディター)側でのQuantumアセットの制御についての詳細はこちらを、ビュー特有データのアセット拡張はこちらをご覧ください。

シグナル

前のチャプターで説明したように、シグナルはシステム間通信のためのPublisher/Subscriber形式のAPIを生成するための関数シグネチャです。

次の例は、DSLファイル内で記述されたものです(前のチャプターより)。

C#

signal OnDamage(FP damage, entity_ref entity);

Frameクラス(f変数)に生成されたシグナルは、「配信」システムから呼び出すことでトリガーできます。

C#

// 生成されたシグナルは任意のシステムでトリガーできるため、特定の実装に結合されることはない
f.Signals.OnDamage(10, entity)

「購読」システムは、生成されたISignalOnDamageインターフェースを次のように実装できます。

C#

namespace Quantum
{
  class CallbacksSystem : SystemSignalsOnly, ISignalOnDamage
  {
    public void OnDamage(Frame frame, FP damage, EntityRef entity)
    {
    // 他のシステムがOnDamageシグナルを呼び出すたびに実行される
    }

  }
}

シグナルは、常にFrameオブジェクトを最初のパラメーターに含むことに注意してください。これは通常、ゲームステートに対して有用な操作を行うために必要です。

生成されたシグナルと組み込みシグナル

DSLで直接定義されたシグナル以外にも、Quantumには組み込みのシグナル(例えば、生の物理衝突コールバック)や、エンティティ定義に基づいて生成されたシグナル(エンティティ固有の作成/破棄コールバック)が含まれます。

衝突コールバックのシグナルは、物理エンジンのチャプターで説明していますので、ここでは他の組み込みシグナルを簡単に紹介します。

  • ISignalOnPlayerDataSet:ゲームクライアントが、RuntimePlayerインスタンスをサーバーに送信した時に呼び出されます(そして、データがあるティックに承認/アタッチされます)。
  • ISignalOnAdd<T>/ISignalOnRemove<T>:コンポーネント型Tがエンティティに追加/削除された時に呼び出されます。

イベントのトリガー

シグナルと同様に、イベントをトリガーするエントリーポイントはFrameオブジェクトです。各(具体的な)イベントは、特定の関数(イベントデータをパラメーターに持つ)を生成します。

C#

// DSLによる基本的なイベント定義
event TriggerSound
{
    FPVector2 Position;
    FP Volume;
}

これはシステムから呼び出され、イベントインスタンスをトリガーできます(Unity側の処理はブートストラッププロジェクトのチャプターで説明します)。

C#

// 生成されたイベントは任意のシステムでトリガーできる(FP._0_5は固定小数点数値0.5)
frame.Events.TriggerSound(FPVector2.Zero, FP._0_5);

非常に重要な点は、イベントはゲームプレイ自体の実装に使用してはいけません(Unity側のコールバックは決定論的ではないため)。イベントは、詳細なゲームステートの更新を描画エンジンと通信する一方向のAPIで、ビジュアル・サウンド・UI関連オブジェクトをUnityで更新するためのものです。

その他のFrame API

Frameクラスは、一時的な(必要に応じてロールバックする)データとして扱う必要がある他の決定論的なAPIのエントリーポイントも含んでいます。以下のスニペットは、特に重要なものを示します。

C#

// RNGはポインタ
// Nextは0~1のランダムなFP
// FPやintで指定できる範囲オプションもある
frame.RNG->Next();

// DSLファイルのglobalスコープで定義された任意のプロパティは、Globalポインタから取得できる
var d = frame.Global->DeltaTime;

// インデックスからプレイヤー入力を取得する(iは、DSLで定義された入力構造体のポインタ)
var i = frame.GetPlayerInput(0);

スケジューリングによる最適化

パフォーマンスのボトルネックとして特定されたシステムを最適化するために、シンプルな剰余ベースのエンティティスケジューリングが役立ちます。これを使用すると、ティックごとにエンティティのサブセットのみを反復して更新できます。

C#

public override void Update(Frame frame) {
  foreach (var (entity, c) in frame.GetComponentIterator<Component>()) {
    const int schedulePeriod = 5;
    if (entity.Index % schedulePeriod == frame.Number % schedulePeriod) {
      // ここがエンティティを更新するタイミング
    }
}

schedulePeriod5にすると、エンティティは5ティックごとに更新されます。2にすれば、1ティックおきになります。

この方法により、更新回数を大幅に削減できます。すべてのエンティティを1ティックで更新することを避けるために、entity.Indexを追加して負荷を複数のフレームに分散させます。

このようにエンティティの更新を遅延させるために、コードは以下の要件を満たす必要があります。

  • 遅延更新するコードは、異なるDeltaTimeを処理できるようにする必要があります
  • エンティティの「応答性」の遅延は、視覚的な問題になる可能性があります
  • entity.Indexを使用すると、最新情報が処理されるタイミングがエンティティごとに変わるため、遅延が増加する可能性があります

Quantum ナビゲーションシステム には、この機能が組み込まれています。

Back to top