Unity Technologies によるオンライン対戦FPSゲームの実装にふれてみた

Unity(ゲームエンジン)とGoogle が パートナーシップを結んだぜ、みたいなニュースが記憶に新しいですが、 先月末くらいに、彼らのオンライン対戦FPSのサンプルコードについての詳しい解説動画が上がってました。

ちなみに、このゲームのソースコードは、 アセットも含めて全て Github で公開されています。 Unity-Technologies/FPSSample

動画のなかでも、とくにこれが興味深いです。 Deep dive into networking for Unity's FPS Sample game - Unite LA - YouTube

UDPを使ったオンライン同期のサーバサイドの実装、とくに同期の詳細なメカニズムについて、ここまで噛み砕いた説明を見たことがなかったので、僕にとってはなかなかインパクトが大きかったです。ソースコードも完全公開されてるしなあ。

オンラインゲームのサーバサイドといえば、ノウハウを持っている人間たちが、それぞれのゲームに特化したミドルウェアをかなりの部分まで自前で設計/実装しているイメージがある。(自分のいまの会社もスクラッチで書いてるし)

とくに、インターネット越しに人々がゲームを同時にプレイしようとした際、ゲームの内容によっては、インターネットの遅延はすぐに無視できない大きさになってしまう。TCPが使えれば楽だし、最近はgRPC(http/2) といった便利な高レベルレイヤがあるのだけど、TCPのパケット再送の仕組みは、ゲームによっては同期の要件を満たすことができなかったりする。そのため、オンラインゲームの通信プロトコルというものは、ゲームに特化したものを考える必要に迫られる、と一般的には捉えられている。 (文献によってはこれを「インターネットがバグっている」などと呼称される)

単純なHTTPサーバのように、仕様も実装もオープンになっていて、誰でもありもののHTTP実装が一行も書かずに使えるように整備されている、といった領域とはまた違うのが、ゲーム開発のおもしろいところであり、筋肉が必要なところでもあり、薄暗いところである。即時性の高いゲームのサーバサイド開発というのは、参入障壁は低くはない領域にはなっているとおもう。

しかし、UnityとGoogleが協力することになったことを記念して公開された 特設サイト をみると、どうでしょう。彼らは、オンラインゲームのサーバサイド(特にモバイルらしいです)さえも民主化して、参入障壁を下げるような仕組みづくりを目指しているようだ。

Unity が世にデビューする以前は、ゲームエンジンというものは各社が独自に内製するもの、ゲームの内容ごとに特化した設計/実装をするもの、という空気が業界にはあったらしい。(業界のことはよくしらないが…)
Unityはそこのやりかたを覆した。ゲームエンジンというものを、スクラッチで実装できない下々の者でも扱えるものにすることに成功しちゃったのである。 (もちろん、Unityによって得たものもあれば失なったものもあるし、クローズドな部分も多いけれど、誰もが憎まれ口を叩きながらもUnityを使いUnityに金を落としUnityに毎晩お祈りしている)

そんなUnityが、今度は、Googleと協力して、ゲームのサーバサイドも民衆のものにしてしまうつもりのようだ 。

個人的には、サーバサイドの技術がものすごいスピードでコモディティ化してしまうのを見てきたので、ゲームといえどもどうせいつかはそうなることを避けられないとは思うけど。

Deep dive into networking for Unity's FPS Sample game

さて、 Deep dive into networking for Unity's FPS Sample game には、噂には聞いたことはあったけど、詳細な実装を知らなかったノウハウが色々と説明されていて、とても勉強になった。説明のしかたが視覚的にわかりやすくておもしろい。

こういうまとまった情報がなかなか少ないと思うので、この解説動画と完全に動くソースコードの存在だけでも、オンラインゲームのサーバ開発の民主化にかなり貢献しているのではないかと思う。

以下、この動画を見た内容のメモを残してみようとおもう。 (理解が充分でないところもけっこうあります)

ソースコード

ちなみにソースコードはここ。

Unity-Technologies/FPSSample

このゲームは、サーバもクライアントもどちらもUnityで実装されている。 サーバは、Unityをヘッドレスモードで走らせることで起動することができる。Unityでサーバを走らせるというのは、マジなのか冗談なのか、あくまでUnityのみですべての実装を完結させるためのパフォーマンスなのかは、明言はされていないが、すくなくとも本番で使うにはスケーラビリティが厳しいんじゃないか。

動画の説明に出てくる登場人物たちは、ソースコードでいうとこのあたりになると思う。

  • クライアントサイド
    • ClientGameLoop.Update() クライアントサイドのメインループ
    • ClientGameLoop.ProcessSnapshot(int ticks) サーバから到着したスナップショット(後述) を適用させる
    • ClientGameLoop.PredictionRollback() クライアントのみの推測で進行したゲーム内容のロールバック(後述)
  • サーバサイド
    • ServerGameLoop.Update() サーバサイドのメインループ
    • NetworkServer.commandsIn サーバサイドのコマンドキュー(後述)

みたかんじ、ソースの設計はとても手続き型パラダイムに依っている。
ClientGameLoop,ServerGameLoop と、メインループを司るエントリポイントがあり、各オブジェクトは、Updateも含めてすべて明示的に操作されるようになっている。 ただ、ゲームやネットワークの様々な状態も、かなり大きめのクラスが状態管理していて、どのメソッドによってどの状態が変わるかが制限されていないので、状態遷移は複雑。メソッド呼び出し順番の依存関係も激しそうである。 しかし、順番に頭からお尻まで、流れを追うのはやさしいし、パフォーマンス上の問題も把握しやすいのかもしれない。畑としてはとてもゲーム業界で育ってきたような雰囲気を感じました。

Dangers on net

なぜUDPをつかうのか?

インターネットのIPパケットは、送信したものが確実にその順番に届くことが保証されていない。 具体的には、送信したつもりのパケットが、相手に届く頃には以下のようになっちゃう問題がある。

  • Duplication (重複)
    • パケットが重複して飛んでくる
    • ルータの設定ミスなどによってありえる(?)
  • Reordering (順番違い)
    • 順番が入れかわる
    • つまり後ろのパケットが前のパケットを置い越して飛んでくる
  • Loss (消失)
    • パケロス。パケットが消失する。
    • 動画のなかでは、パックマンみたいなやつにパケットが食べられてしまっている。

UDPは、これらの問題への対策がされていない。

では何故TCPを使わないのか? - 相手から応答をもらってはじめて送信成功となる仕組みなのでレイテンシが悪い - うまく送れなかった場合にパケットを再送する仕組みなので、再送が発生した場合に、ゲームの流れを一旦止めて待たざるをえない

という問題がある。

UDP Problem detection

UnityのFPSSampleでは、UDPをつかった上で、上記のような問題を検出する実装がはいっている。

  • 送信データにフレーム番号をつける
    • 全てのデータ(パケット)に連番を振っておく → こうすることで、相手方が重複、順番違いの検出ができる
    • サーバからの送信、クライアントからの送信、どちらも同様に番号がついている。
    • 番号=ゲームのフレーム番号。
    • ただフレーム番号をつけるだけではなく、他にも以下のような情報をつける。
      • 最後に受信に成功したフレーム番号
        • サーバの送信データには、最後にクライアントから受信成功した番号がついている
        • クライアントの送信データには、最後にサーバから受信成功した番号がついている
      • AckMask
        • 過去16フレームぶんの、受信に成功したフレーム番号の一覧 → パケット消失の検出ができる
  • ラウンドトリップタイム=RTTの測定
    • 3フレームに一回、クライアントは、「データを送信してからサーバが応答するまで」の時間を計測して記憶しておく
    • この遅延の情報をつかうと、「実際にサーバがいま実行しているのは何フレームめなのか?」といったことを予測することができる

この辺はやはり動画で図解入りの説明をみるとわかりやすいです。

(ちなみに、最近、HTTP/2をさらに最適化するために、L4プロトコルUDPにしちゃおうぜ、HTTP/3だぜ、といったニュースが話題になっていましたが、結局は最適化をしようとするとTCPをやめてUDPの上に実装しなおしということになるんすな)

パケットの消失/順番違いの対応

クライアントからサーバに送信されたデータは、前述のとおりフレーム番号がついている。

サーバのメインループでは、到着したコマンドをすべて処理してしまうのではなく、「いま何フレームめなのか?」を逐一管理しており、適切なフレーム番号のデータのみを処理するようになっている。

  • そのため、もしいま現在のフレーム番号よりも、新しい番号のデータが先に到着してしまった場合、単純に、該当のフレームになるまでそのコマンドは処理を見送られる。
  • ソースをみたかんじだと、遅れてやってきた過去のフレーム番号のデータは到着した時点で処理している気がする。(未確認)
  • ソースをみたかんじだと、パケットの消失が起きた場合は、再送などはとくにせず、なかったものとして扱われる? (未確認)

サーバはゲームのスナップショットをどうやって圧縮するか

この辺もけっこうおもしろかった。

サーバは、「クライアントがどのタイミングまでサーバとの同期に成功したか」を知っている。何故なら、クライアントからのデータには、最後に受信に成功した番号がはいっているからだ。 だから、この「最後に送信に成功してるはずの状態」と、「現在の状態」との差分のみをクライアントに送ってあげれば済むことになる。 このときの、現在の状態との差分をとる基準のスナップショットを「Baseline」と動画/ソース内では呼んでいる。

クライアントサイドのPrediciton

クライアントもサーバも、60FPS(設定値)でゲームループがまわっているが、サーバからクライアントにデータを送信する頻度はもっと少なく、秒間3回(20FPS) になっている。 (60FPSで送信できるほどインターネットが速くないため) そのため、クライアントは、3フレームに一回しか、サーバの状態を同期できない計算になる。 キャラクタの移動とかミサイルの起動とか、なめらかに動いていなければいけないゲームの状態については、サーバからデータを受信できない 2/3のフレームでは、クライアント側で勝手に予測して動かさないといけない。

  • サーバとクライアントで同じソースを完全共有しているので、ミサイルの動きとかをクライアントだけでもエミュレートできる。そしてそのようにする。
  • サーバからのデータが到着したら、クライアントで勝手に推測してすすめていたゲーム状態は一旦ロールバックする実装になっている
  • ラウンドトリップタイム(RTT)から、サーバ側が現在何フレーム目を実行しているかの推測がきえる
    • ※このRTTのくだりの説明がよく理解できなかった。後で追記するかも

当たり判定どうするの?

クライアント側で銃をかまえて敵を撃ったとき、サーバではもう敵は逃げているかもしれない。 この矛盾をどう解決するのか。

ここでも、データにくっついているシーケンス番号が活躍する。 FPSSample では、サーバは、過去16フレームぶんの全員の当たり判定を記録しているので、クライアントが銃を撃った時点のタイミングの当たり判定をひっぱりだしてきて、命中判定をおこなえば良い。 (つまり、撃った側が命中に成功していればかならず当たる仕様ということになりそう)

まとめ

最近のUnityのロードマップはすごい。 全然関係ないけど、Google と協力関係になったってことで、gRPCがUnityにシームレスに統合されたりしないかなあ