Photon Fusion 始めました
最近(2022年3月)Unityでのオンラインゲーム開発における定番アセットの一つ「PUN2(Photon Unity Networking 2)」の後継となる「Photon Fusion」がリリースされました。
Fusionの概要については上記の公式ドキュメントを読みましょう。ある程度のオンラインゲーム開発経験があり、一度は通信周りの面倒な壁にぶつかり、いろいろと自前で実装したことがあるような人にとっては、魅力的な機能のサポートが盛りだくさんになっていると思います。
ティックベースシミュレーション!ラグ補償!(すごい)
スナップショット補間!クライアントサイド予測!(すごい)
デルタスナップショット!AOI(関心領域)!(すごい)
200人同時対戦!?その他のいろいろな最先端機能!!(すごい)
しかしその反面、使い方を覚えるまでの学習コストは比較的高めな印象です。様々な機能や動作原理は正しく理解しないと、Fusionのポテンシャルを引き出すどころか致命的なバグを埋め込んでしまうだろうおそれがあり、特に新機能を見ても何がすごいのかまだいまいちピンときていない状態の人やオンラインゲーム開発初心者の人にとっては、慣れるまでに結構苦労しそうな感じがします。これは今後、より整備されるであろう公式ドキュメントに期待することにします。
とはいえ、Fusionの機能をどう利用するか?どんな罠があるか?などは、実際に何か作って試してみるのが早いだろうし、ちょうど(2022年6月)WebGLビルドにも対応したということで、シンプルなオンライン対戦シューティングゲームを作成して、unityroomに公開してみました。
この記事は、PUN2経験者が初めてFusionを使ったオンラインゲームを開発するにあたり、調べたこと・考えたこと・試してみたことなどを、雑多にまとめたものになります。PUN2と比較して記述している部分も多いので、ある程度PUN2の知識があった方が読みやすいと思います。
開発環境
- Unity 2022.1.9f1
- Fusion SDK 1.1.2 Nightly Build 537
Photon Fusionをどこから学ぶか?
まず最初にFusionを始めてみるなら、公式ドキュメントに「Fusion 100」という入門講座があるので、それを一通りやってみるのがオススメです。
なんとなく基礎がつかめた後は、「Manual」から気になる機能を調べたり、「Samples」からサンプルプロジェクトをダウンロードしてコードを読んだりするのが良いでしょう。「Fusion Asteroids」「Fusion Razor Madness」辺りは比較的コンパクトで読みやすい印象でした。
ただこの記事を書いている時点で、公式ドキュメントには少し問題があります。Fusionは正式にリリースされてまだ数ヶ月、現在もほぼ毎日のペースでSDKが更新され続けていて、その中にはまれに破壊的な変更も含まれています。おそらくそういった事情もあって、
- 日本語のページがまだ翻訳されていない
- 日本語のページの内容が英語のページの内容より古い
- 英語のページの内容がFusionの最新版の仕様に追従していない
などが散見されます。日本語のページを見て上手くいかなかったら英語のページを見てみたり、何か違うような気がしたらまだ更新されていないのかも?と考えて進める必要があるでしょう。
また、Qiitaにはニム式さんの丁寧な紹介記事が上がっているので、こちらも参考になります。
NetworkRunnerをどうやって生成するか?
ネットワークへの接続処理は、PUN2は静的クラスのPhotonNetwork
から行いましたが、Fusionはゲームオブジェクトに追加したNetworkRunner
コンポーネントから行うようになりました。なぜそうなったのか?なにが良いのか?については、ホストマイグレーション(Host Migration)や、マルチピアモード(Multi-Peer Mode)といった機能を調べてみる方が早いでしょう。
特にマルチピアモードは、1アプリケーション内で複数のピアを実行できるらしいので、
- テスト時に、複数のビルドを実行したり複数のUnityエディタを立ち上げる必要がなくなる
- あるゲームモードをオンラインで遊びながら、裏で別のゲームモードのマッチングができる?
- 複数のセッションを画面分割でリアルタイムに観戦するようなアプリケーションが作れる?
など、PUN2では実現できなかった仕組みがいろいろ作れそうで夢が広がっています。
また、NetworkRunner
の挙動を調べてみると、NetworkRunner
コンポーネントが追加されているゲームオブジェクトは、セッションに参加している間は自動でDontDestroyOnLoad()
が呼ばれ、シーン遷移で破棄されないようになっています。
これらの点をふまえると、既存のゲームオブジェクト(他のコンポーネントやスクリプトなどが追加されているもの)にNetworkRunner
コンポーネントを追加するより、NetworkRunner
用のプレハブを用意して、Instantiate()
で複数生成できるようにして使うのが良さそうです。
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つのフェーズに分かれて進行しています。
public enum PlayPhase
{
Result, // 結果発表フェーズ
Ready, // 準備フェーズ
Action // 戦闘フェーズ
}
現在のフェーズや残り時間などを同期するには、PUN2ではルームのカスタムプロパティを使うのが一般的だったと思いますが、Fusionではネットワークオブジェクト(Network Object)のネットワークプロパティ(Networked Properties)を使うことになるでしょう。
ネットワークオブジェクトは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
の値を同期できます。
アバターの位置と向きはNetworkTransform
でいい感じに同期してもらう
NetworkTransform
は自動でいい感じにTransform
の値を補間して同期してくれますが、位置を初期化する時などで補間を無効したい場合は、TeleportToPositionRotation()
メソッドが使えます。
networkTransform.TeleportToPositionRotation(position, rotation);
プレイヤー名の同期
プレイヤー名は、アルファベットと数字のみの最大8文字制限にしています。
文字列をネットワークプロパティにする場合は、string
ではなくNetworkString
を使う必要があります。NetworkString
の型パラメーターで最大文字数(文字列のサイズ・IFixedStorage
インターフェースを実装した型)を指定できるので、ここでは_8
に設定しています。
[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でホストに送信し、ホスト側でプレイヤー名を設定しています。(仕組みとしては正しいだろうと思いますが、どうにかならないかな感あります)
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;
}
}
生死フラグとチームフラグの同期
同期する必要があるプレイヤーのフラグは、プレイヤーが現在生存しているかの「生死フラグ」と、どちらのチームに所属しているかの「チームフラグ」の2つです。
真偽値をネットワークプロパティにする場合は、bool
ではなくNetworkBool
を使うことで、1ビットのデータとして適切にシリアライズされるようです。
また、[Networked]
属性の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
以外は不要になりました。
[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」が公開されているようなので、気になる人はチェックしてみると良いでしょう。
追加資料
この記事の内容をベースに、セミナーで発表した時の資料になります。
Discussion
以前教えていただいた通り、この記述を参考にして
「ネットワークオブジェクトはRunner.Spawn()で生成しても良いですが、あらかじめシーン上に配置したネットワークオブジェクトは、ホストがシーンを読み込んだ際にシーンオブジェクト(Network Scene Object)として自動的に生成されるため、シーンに一つ(または決まった数)しかないネットワークオブジェクトを生成したい時に便利です。」
で、やりたいこと(複数プレイヤー間での、ネットワークプロパティとしての同一変数の共有)をしようとしています。
しかし、このページの「PlayPhaseManager」の実装ほぼそのままで
・シーン上にカラのゲームオブジェクト(TestObject)を作成し
・TestObjectに、以下のNetworkBehaviorを記述したスクリプトを追加し
public class Test : NetworkBehaviour
{
[Networked]
NetworkString<_16> NetString { get; set; }
…
・TestObjectにNetwork Objectも追加
したのですが、上記TestスクリプトのFixedUpdateNetwork()(と、Spawned()も…これは必要ありませんが)が実行されている形跡がありません。
なにかまだ、足りない手順があるのでしょうか?
なお、「共有モードのチュートリアル」を参考にして、上記public class Testに
[SerializeField]
private NetworkRunner networkRunnerPrefab;←記事にあるnetworkRunnnerPrefabをアタッチ
private NetworkRunner networkRunner;
も追加してみましたが、やはり結果変わらず(SpawnedもFixedUpdateNetworkも呼ばれない)でした。
シーンオブジェクトを生成するには、
NetworkRunner.StartGame
の引数にScene
を指定する必要があります。この記事の「NetworkRunnerの使用例」のコードも参考してみてください。
ありがとうございます。
ついに、やりたかったこと(テキストの共有)が実現しました。
ほとんどの方には釈迦に説法と思いますが、私と似たようなレベルの人が読んだ時のために、やったことの要点をまとめます。
・StartGameの引数にSceneを追加
var result = await networkRunner.StartGame(new StartGameArgs
{
GameMode = GameMode.Shared,
Scene = SceneManager.GetActiveScene().buildIndex,
SceneManager = networkRunner.GetComponent<NetworkSceneManagerDefault>()
});
(これでFixedUpdateNetworkが実行されるようになる)
・Testクラスに以下の変数とRPCを定義
/個々のプレイヤーが入力した文字を、FixedUpdateNetwork実行まで保管
個々のプレイヤーがそれぞれ保持する/
static String temp:
// 2プレイヤー間の共有文字列
[Networked]
NetworkString<_16> NetString { get; set; }
// StateAuthorityを持たない側のプレイヤー側(起動タイミングが遅かった側と思われる)が、StateAuthority側に、自分のtempの中身を送信する
[Rpc(sources: RpcSources.All, targets: RpcTargets.StateAuthority)]
public void RPC_SetTemp(String inputTemp)
{
temp = inputTemp;
}
・FixedUpdateNetworkで、tempが空文字でない(=NetStringに書き込みたい内容がある)ときは、
StateAuthority有無で処理を分岐
}
}
※厳密には、2プレイヤー同時に文字入力してtempを更新した時の処理とかに、穴がありそうなコードですが。