🍧

Photon Fusion 始めました

2022/07/31に公開約12,900字

最近(2022年3月)Unityでのオンラインゲーム開発における定番アセットの一つ「PUN2(Photon Unity Networking 2)」の後継となる「Photon Fusion」がリリースされました。

https://doc.photonengine.com/ja-jp/fusion/current/getting-started/fusion-intro

Fusionの概要については上記の公式ドキュメントを読みましょう。ある程度のオンラインゲーム開発経験があり、一度は通信周りの面倒な壁にぶつかり、いろいろと自前で実装したことがあるような人にとっては、魅力的な機能のサポートが盛りだくさんになっていると思います。

ティックベースシミュレーション!ラグ補償!(すごい)
スナップショット補間!クライアントサイド予測!(すごい)
デルタスナップショット!AOI(関心領域)!(すごい)
200人同時対戦!?その他のいろいろな最先端機能!!(すごい)

しかしその反面、使い方を覚えるまでの学習コストは比較的高めな印象です。様々な機能や動作原理は正しく理解しないと、Fusionのポテンシャルを引き出すどころか致命的なバグを埋め込んでしまうだろうおそれがあり、特に新機能を見ても何がすごいのかまだいまいちピンときていない状態の人やオンラインゲーム開発初心者の人にとっては、慣れるまでに結構苦労しそうな感じがします。これは今後、より整備されるであろう公式ドキュメントに期待することにします。

とはいえ、Fusionの機能をどう利用するか?どんな罠があるか?などは、実際に何か作って試してみるのが早いだろうし、ちょうど(2022年6月)WebGLビルドにも対応したということで、シンプルなオンライン対戦シューティングゲームを作成して、unityroomに公開してみました。

https://unityroom.com/games/struggle-for-power-period

この記事は、PUN2経験者が初めてFusionを使ったオンラインゲームを開発するにあたり、調べたこと・考えたこと・試してみたことなどを、雑多にまとめたものになります。PUN2と比較して記述している部分も多いので、ある程度PUN2の知識があった方が読みやすいと思います。

https://zenn.dev/o8que/books/bdcb9af27bdd7d

開発環境

  • Unity 2022.1.9f1
  • Fusion SDK 1.1.2 Nightly Build 537

Photon Fusionをどこから学ぶか?

まず最初にFusionを始めてみるなら、公式ドキュメントに「Fusion 100」という入門講座があるので、それを一通りやってみるのがオススメです。

https://doc.photonengine.com/ja-jp/fusion/current/fusion-100/fusion-101

なんとなく基礎がつかめた後は、「Manual」から気になる機能を調べたり、「Samples」からサンプルプロジェクトをダウンロードしてコードを読んだりするのが良いでしょう。「Fusion Asteroids」「Fusion Razor Madness」辺りは比較的コンパクトで読みやすい印象でした。

https://doc.photonengine.com/ja-jp/fusion/current/samples/game-samples/fusion-asteroids
https://doc.photonengine.com/ja-jp/fusion/current/samples/game-samples/fusion-razor-madness

ただこの記事を書いている時点で、公式ドキュメントには少し問題があります。Fusionは正式にリリースされてまだ数ヶ月、現在もほぼ毎日のペースでSDKが更新され続けていて、その中にはまれに破壊的な変更も含まれています。おそらくそういった事情もあって、

  • 日本語のページがまだ翻訳されていない
  • 日本語のページの内容が英語のページの内容より古い
  • 英語のページの内容がFusionの最新版の仕様に追従していない

などが散見されます。日本語のページを見て上手くいかなかったら英語のページを見てみたり、何か違うような気がしたらまだ更新されていないのかも?と考えて進める必要があるでしょう。

また、Qiitaにはニム式さんの丁寧な紹介記事が上がっているので、こちらも参考になります。

https://qiita.com/nimushiki/items/d6fe4f0cbe0437677fa8
https://qiita.com/nimushiki/items/c82763817a8c7c4f2278

NetworkRunnerをどうやって生成するか?

ネットワークへの接続処理は、PUN2は静的クラスのPhotonNetworkから行いましたが、Fusionはゲームオブジェクトに追加したNetworkRunnerコンポーネントから行うようになりました。なぜそうなったのか?なにが良いのか?については、ホストマイグレーション(Host Migration)や、マルチピアモード(Multi-Peer Mode)といった機能を調べてみる方が早いでしょう。

https://doc.photonengine.com/ja-jp/fusion/current/manual/host-migration
https://doc.photonengine.com/ja-jp/fusion/current/manual/multipeer

特にマルチピアモードは、1アプリケーション内で複数のピアを実行できるらしいので、

  • テスト時に、複数のビルドを実行したり複数のUnityエディタを立ち上げる必要がなくなる
  • あるゲームモードをオンラインで遊びながら、裏で別のゲームモードのマッチングができる?
  • 複数のセッションを画面分割でリアルタイムに観戦するようなアプリケーションが作れる?

など、PUN2では実現できなかった仕組みがいろいろ作れそうで夢が広がっています。

また、NetworkRunnerの挙動を調べてみると、NetworkRunnerコンポーネントが追加されているゲームオブジェクトは、セッションに参加している間は自動でDontDestroyOnLoad()が呼ばれ、シーン遷移で破棄されないようになっています。

これらの点をふまえると、既存のゲームオブジェクト(他のコンポーネントやスクリプトなどが追加されているもの)にNetworkRunnerコンポーネントを追加するより、NetworkRunner用のプレハブを用意して、Instantiate()で複数生成できるようにして使うのが良さそうです。


NetworkRunner用のプレハブ

NetworkRunnerの使用例
[SerializeField]
private NetworkRunner networkRunnerPrefab;

private NetworkRunner runner;

public async UniTask StartGame() {
    runner = Instantiate(networkRunnerPrefab);
    runner.ProvideInput = true;

    var result = await runner.StartGame(new StartGameArgs {
        GameMode = GameMode.AutoHostOrClient,
        SessionName = "TestRoom",
        Scene = SceneManager.GetActiveScene().buildIndex,
        SceneManager = runner.GetComponent<NetworkSceneManagerDefault>()
    });
}

ゲームのフェーズをどうやって管理するか?

作成したオンラインゲームのゲームプレイ中は、以下3つのフェーズに分かれて進行しています。

各フェーズをEnumで定義
public enum PlayPhase
{
    Result, // 結果発表フェーズ
    Ready, // 準備フェーズ
    Action // 戦闘フェーズ
}

現在のフェーズや残り時間などを同期するには、PUN2ではルームのカスタムプロパティを使うのが一般的だったと思いますが、Fusionではネットワークオブジェクト(Network Object)のネットワークプロパティ(Networked Properties)を使うことになるでしょう。

https://doc.photonengine.com/ja-jp/fusion/current/manual/network-object/network-object
https://doc.photonengine.com/ja-jp/fusion/current/manual/network-object/network-behaviour

ネットワークオブジェクトはRunner.Spawn()で生成しても良いですが、あらかじめシーン上に配置したネットワークオブジェクトは、ホストがシーンを読み込んだ際にシーンオブジェクト(Network Scene Object)として自動的に生成されるため、シーンに一つ(または決まった数)しかないネットワークオブジェクトを生成したい時に便利です。



フェーズ管理のネットワークオブジェクトをシーンオブジェクトにする

フェーズ管理のネットワークオブジェクトにスクリプト(PlayPhaseManager)を追加して、そこで「現在のフェーズ」と「残り時間」のネットワークプロパティを定義し、フェーズ遷移の処理を実装しています。

フェーズ管理スクリプトのネットワークプロパティの定義部分
public class PlayPhaseManager : NetworkBehaviour
{
    [Networked]
    public PlayPhase CurrentPhase { get; set; }
    [Networked]
    public TickTimer Timer { get; set; }
}
フェーズ遷移(準備フェーズから戦闘フェーズへの移行判定部分)の例
private void FixedUpdateNetwork() {
    // ホストは(準備フェーズの)残り時間が0になったら、
    // 現在のフェーズを戦闘フェーズに変更して、残り時間を90秒にセットする
    if (Object.HasStateAuthority && Timer.Expired(Runner)) {
        CurrentPhase = PlayPhase.Action;
        Timer = TickTimer.CreateFromSeconds(Runner, 90f);
    }
}

各プレイヤーの情報をどうやって同期させるか?

作成したオンラインゲームで、同期する必要があるプレイヤーの情報は以下の通りです。

  • プレイヤーのアバターの位置と向き
  • プレイヤー名
  • プレイヤーのレベルとステータス
  • 生死フラグとチームフラグ

アバターの位置と向きの同期

PUN2のPhotonTransformViewコンポーネントと同じように、FusionもNetworkTransformコンポーネントをネットワークオブジェクトに追加するだけでTransformの値を同期できます。

https://doc.photonengine.com/ja-jp/fusion/current/manual/prebuilt-components


アバターの位置と向きはNetworkTransformでいい感じに同期してもらう

NetworkTransformは自動でいい感じにTransformの値を補間して同期してくれますが、位置を初期化する時などで補間を無効したい場合は、TeleportToPositionRotation()メソッドが使えます。

アバターを明示的にテレポートさせる
networkTransform.TeleportToPositionRotation(position, rotation);

プレイヤー名の同期

プレイヤー名は、アルファベットと数字のみの最大8文字制限にしています。

文字列をネットワークプロパティにする場合は、stringではなくNetworkStringを使う必要があります。NetworkStringの型パラメーターで最大文字数(文字列のサイズ・IFixedStorageインターフェースを実装した型)を指定できるので、ここでは_8に設定しています。

https://doc.photonengine.com/ja-jp/fusion/current/manual/network-collections
プレイヤー名の定義
  [Networked]
- private string NickName { get; set; }
+ private NetworkString<_8> NickName { get; set; }

NetworkStringからstringを取得するにはValueプロパティが便利ですが、このプロパティはアクセスする度にメモリ割り当てが発生します。GCが気になる場合はGet()メソッドで文字列をキャッシュすることで、余計なメモリ割り当てを避けることができます。

プレイヤー名の取得
+ private string nickNameCache;
  public string NickNameValue {
      get {
-        return NickName.Value;
+        NickName.Get(ref nickNameCache);
+        return nickNameCache;
      }
  }

PUN2ではルームに参加する前にプレイヤーのカスタムプロパティを設定しておけば、ルームに参加すると同時にその値を同期することができました。Fusionではクライアントがセッションに参加すると同時に任意の値を同期する方法はなさそうなので、ネットワークオブジェクトが生成されたタイミングでプレイヤー名をRPCでホストに送信し、ホスト側でプレイヤー名を設定しています。(仕組みとしては正しいだろうと思いますが、どうにかならないかな感あります)

https://doc.photonengine.com/ja-jp/fusion/current/manual/rpc
プレイヤー名の設定
public override void Spawned() {
    if (Object.HasInputAuthority) {
        RpcSetNickName("myname");
    }
}

[Rpc(RpcSources.InputAuthority, RpcTargets.StateAuthority)]
private void RpcSetNickName(string nickName) {
    NickName = string.IsNullOrWhiteSpace(nickName) ? "unknown" : nickName;
}

レベルとステータスの同期

プレイヤーは時間経過・攻撃・被弾などでレベルが増減し、レベルに合わせてステータスも増減します。レベルの最大値は99で、ステータスのレベルも合計すると99になります。

レベル 弾数 発射速度 弾速 拡散度 弾サイズ
最大レベル 99 39 15 15 15 15

これらの値をすべて同期するために、int型のネットワークプロパティを6つ(レベルは各ステータスのレベルを合計すれば求められるので、除外するとしたら5つ)定義することになるでしょう。ただ、このケースのようにそれぞれの値が取りうる範囲が非常に小さいなら、ビット演算を使ってint型のデータ1つに詰め込むことで、通信データサイズを削減することもできます。

プレイヤーのステータスの定義
- [Networked] private int Level { get; set; }
- [Networked] private int CountLevel { get; set; }
- [Networked] private int FireRateLevel { get; set; }
- [Networked] private int SpeedLevel { get; set; }
- [Networked] private int SpreadLevel { get; set; }
- [Networked] private int SizeLevel { get; set; }
+ [Networked]
+ private int StatusBytes { get; set; }
プレイヤーのステータスクラスの変換部分
public class PlayerStatus
{
    public int Level { get; private set; }
    public int CountLevel { get; private set; }
    public int FireRateLevel { get; private set; }
    public int SpeedLevel { get; private set; }
    public int SpreadLevel { get; private set; }
    public int SizeLevel { get; private set; }

    // レベルと各ステータスを、int型のデータに変換する
    private int Serialize() {
        return (Level << 22) | (CountLevel << 16) | (FireRateLevel << 12)
            | (SpeedLevel << 8) | (SpreadLevel << 4) | SizeLevel;
    }

    // int型のデータを、レベルと各ステータスに変換する
    public void Deserialize(int statusBytes) {
        Level = (statusBytes >> 22) & 0b_11111_11;
        CountLevel = (statusBytes >> 16) & 0b_11111_1;
        FireRateLevel = (statusBytes >> 12) & 0b_1111;
        SpeedLevel = (statusBytes >> 8) & 0b_1111;
        SpreadLevel = (statusBytes >> 4) & 0b_1111;
        SizeLevel = statusBytes & 0b_1111;
    }
}

https://zenn.dev/o8que/books/bdcb9af27bdd7d/viewer/d0089b

生死フラグとチームフラグの同期

同期する必要があるプレイヤーのフラグは、プレイヤーが現在生存しているかの「生死フラグ」と、どちらのチームに所属しているかの「チームフラグ」の2つです。

真偽値をネットワークプロパティにする場合は、boolではなくNetworkBoolを使うことで、1ビットのデータとして適切にシリアライズされるようです。

また、[Networked]属性のOnChangedパラメーターを使って、フラグが変更された際にアバターのビューを更新する(死亡時に爆発エフェクトを再生する等)コールバック処理を行っています。

https://doc.photonengine.com/ja-jp/fusion/current/manual/network-object/network-behaviour#onchanged
生死フラグとチームフラグの定義
  [Networked(OnChanged = nameof(ChangeView))]
- public bool IsAlive { get; set; }
+ public NetworkBool IsAlive { get; set; }
  [Networked(OnChanged = nameof(ChangeView))]
- public bool Team { get; set; }
+ public NetworkBool Team { get; set; }

  public static void ChangeView(Changed<PlayerAvatar> changed) => changed.Behaviour.ChangeView();
  // アバターのビューを更新する
  private void ChangeView() => view.Change(IsAlive, Team);

弾幕をどうやって効率的に同期させるか?

作成したオンラインゲームでは、大量の弾幕が飛び交います。弾はネットワークオブジェクトで生成するのが最も簡単で、弾の数がそれほど多くないなら良い方法の一つになるでしょう。しかし大量に弾を出す場合には、弾の数に比例して通信量が増加する問題が発生してしまいます。

そのため、できるだけ通信量を抑えつつ弾の位置を正確に同期するには、

  • 弾一つ一つをネットワークオブジェクトにはしたくない
  • 弾は(正確に同期するため)ティックベースシミュレーション上で更新したい
  • ティックベースシミュレーションの処理を行うにはネットワークオブジェクトが必要

という要件を満たす必要がありました。いろいろ考えた結果、弾管理オブジェクト(弾の親オブジェクト)をネットワークオブジェクトにして、そのスクリプト(BulletContainer)から弾の更新処理を行うような形になりました。


(弾ではなく)弾管理オブジェクトをネットワークオブジェクトにする

弾管理スクリプトの更新処理部分
public class BulletContainer : SimulationBehaviour
{
    private readonly List<Bullet> activeBullets = new(1024);
    private readonly Stack<Bullet> inactiveBullets = new(1024);

    public override void FixedUpdateNetwork() {
        for (int i = activeBullets.Count - 1; i >= 0; i--) {
            var bullet = activeBullets[i];
            // 弾の消去判定を行う
            if (!bullet.IsAlive) {
                bullet.Deactivate();
                activeBullets.Remove(bullet);
                inactiveBullets.Push(bullet);
            }
        }
    }

    public override void Render() {
        float tick = Runner.Simulation.Tick + Runner.Simulation.StateAlpha;
        // 弾の位置を更新する
        foreach (var bullet in activeBullets) {
            bullet.Render(tick, Runner.DeltaTime);
        }
    }
}

弾幕の発射イベントは、プレイヤーのスクリプトに実装したRPCで同期します。

まず、[Rpc]属性のTickAlignedパラメーターをtrueにして、RPCはティックベースシミュレーションに合わせて実行されるようにします。(デフォルトはtrueなので記述は省略できます)

すると、弾幕の発射に必要な情報(プレイヤーのID・プレイヤーのチームフラグ・アバターの位置と向き・プレイヤーのステータス)はすべてネットワークプロパティから正確な値が取得できるようになるため、RPCのメソッドの引数はオプション引数RpcInfo以外は不要になりました。

https://doc.photonengine.com/ja-JP/fusion/current/manual/rpc#ipwwewbat__88w7e
弾幕を発射するRPCの例
[Rpc(RpcSources.StateAuthority, RpcTargets.All, TickAligned = true)]
private void RpcFireBarrage(RpcInfo info = default) {
    bulletContainer.FireBarrage(
        PlayerId, // プレイヤーID(誰が発射した弾か)
        Team, // チームフラグ(どちらのチームの弾か)
        transform.position, // 発射位置(弾幕がどこから発射されるか)
        transform.eulerAngles.y, // 発射方向(弾幕がどの方向に発射されるか)
        info.Tick, // ティック(弾幕がいつ発射されたか)
        status.Count, // 弾数(発射される弾の数)
        status.Speed, // 弾速(発射される弾の速さ)
        status.Spread, // 拡散度(弾幕の弾同士の間隔)
        status.Size // 弾サイズ(発射される弾の大きさ)
    );
}

弾の同期については、公式ドキュメントの「Samples」に弾の同期周りに特化したサンプルプロジェクト「Fusion Projectiles」が公開されているようなので、気になる人はチェックしてみると良いでしょう。

https://doc.photonengine.com/ja-jp/fusion/current/samples/game-samples/fusion-projectiles

Discussion

ログインするとコメントできます