【Unite Tokyo 2019】「禍つヴァールハイト」最大100人同時プレイ!モバイルオンラインゲームの実装テクニック

2.8K Views

September 30, 19

スライド概要

2019/9/25-6に開催されたUnite Tokyo 2019の講演スライドです。
平井 佑樹(KLab株式会社)
稲田 真吾(KLab株式会社)
西那 康志(KLab株式会社)

こんな人におすすめ
・スマホの常時接続型のゲームを開発に関わるプログラマー
・頻繁に通信のやり取りを行うゲームの開発に関わるプログラマー
・アセットのダウンロード時間を短縮したいと思っているプログラマー

受講者が得られる知見
・常時接続型スマホゲームのクライアント設計の理解
・常時接続を利用したクライアント最適化技術
・通信型のモバイルゲームの開発技法


Unityのイベント資料はこちらから:
https://www.slideshare.net/UnityTechnologiesJapan/clipboards

profile-image

リアルタイム3Dコンテンツを制作・運用するための世界的にリードするプラットフォームである「Unity」の日本国内における販売、サポート、コミュニティ活動、研究開発、教育支援を行っています。ゲーム開発者からアーティスト、建築家、自動車デザイナー、映画製作者など、さまざまなクリエイターがUnityを使い想像力を発揮しています。

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

関連スライド

各ページのテキスト
2.

「禍つヴァールハイト」 最大100人同時プレイ! モバイルオンラインゲームの実装テクニック KLab株式会社 平井 佑樹 稲田 真吾 西那 康志

3.

自己紹介 平井 佑樹 稲田 真吾 西那 康志 クライアントエンジニア エンジニアリングマネージャ クライアントエンジニア バトル/チューニング担当 通信基盤開発/ダウンローダ クライアント基盤/チュー 開発/運用ツール開発 3 ニング/CI担当

4.

禍つヴァールハイトとは — モバイル向けMMORPG — 最大100人同時プレイ可能 — リアルタイムバトル — 通称:まがつ 4

5.

アジェンダ — 通信基盤の紹介 — リアルタイムバトル実装 — アセットロードの負荷軽減 — アセットダウンロードの高速化 — 開発ツールの紹介 5

6.

通信基盤の紹介 ~100⼈人乗っても⼤大丈夫!~ 6

7.

まがつの通信基盤 — CL/SV型 – クライアント: UnityC# – サーバ: Go — 通信ライブラリ fmof を内製 – TCP RPC通信 – MessagePack TCP RPC Client / UnityC# fmof fmof GameServer / Go

8.

なぜ内製? — サーバを Go⾔言語 で開発したかった — チューニングを⾃自分達でできるようにしたかった — 技術的チャレンジ! というか開発当初はサードパーティ通信基盤なくて⾃自作⼀一択という状況だった !8

9.

このセクションで話すこと — RPCクライアントの紹介 — RPCとは? — どう実現しているのか — 受信処理理のチューニング — サーバその他の話はしません !9

10.

RPCとは? Remote Procedure Call - プログラムから別のアドレス空間(通常、共有ネットワーク上の別のコンピュータ 上)にあるサブルーチンや⼿手続きを実⾏行行することを可能にする技術。- wikipedia 今回は [クライアント] C# のメソッド実⾏行行 Go の関数実⾏行行 [サーバ] を実現する技術を指す。 !10

11.

Fmof.RPCClient のお仕事 — ゲームサーバとの通信を担うクラス — ゲーム実装から関数呼び出し → サーバへ送信 — サーバから受信 → ゲーム実装の関数呼び出し — 通信データ圧縮・難読化 ゲーム 実装 Internet (⾃自動⽣生成コード) RPCClient GameServer

12.

送信処理理の例例 ゲーム実装 Rpc.Battle.Attack(enemyId=2, skillId=14) RPCClient 送信Buff Socket

13.

送信処理理の例例 ゲーム実装 Rpc.Battle.Attack(enemyId=2, skillId=14) Serialize RPCClient 送信Buff Func=Attack Enemy=2 Skill=14 Socket

14.

送信処理理の例例 ゲーム実装 Rpc.Battle.Attack(enemyId=2, skillId=14) Serialize RPCClient Write 送信Buff Func=Attack Enemy=2 Skill=14 Socket

15.

受信処理理の例例 ゲーム実装 RPCClient 受信Buff Socket

16.

受信処理理の例例 ゲーム実装 RPCClient Read 受信Buff Func=Attack Player=2 Enemy=2 Skill=14 Socket

17.

受信処理理の例例 ゲーム実装 Rpc.Battle.OnAttack( playerId=2, enemyId=2, skillId=14) Deserialize / Callback RPCClient Read 受信Buff Func=Attack Player=2 Enemy=2 Skill=14 Socket

18.

シリアライザ:MessagePack — MessagePack for C# https://github.com/neuecc/MessagePack-CSharp — Unityに向けてチューニング済 — LZ4圧縮拡張 — MessagePackはGoでも扱いやすい

19.

シリアライズの詳細 Rpc.Battle.Attack(enemyId=2, skillId=14) Serialize RpcPacket 固定⻑⾧長ヘッダ(20Bytes) 引数: Array[2, 14] !19

20.

シリアライズの詳細 Rpc.Battle.Attack(enemyId=2, skillId=14) Serialize RpcPacket 固定⻑⾧長ヘッダ(20Bytes) メッセージ⻑⾧長 関数タイプ 関数ID ユーザID シーケンス番号 引数: Array[2, 14] ここは MessagePack トップレベルは引数の配列列 各種オブジェクトも配列列 圧縮や難読化をかける !20

21.

RPCの実現 — サーバ側も似たように実装 — RpcPacket をクライアント-サーバ間で送り合う — RPC通信を実現 TCP RpcPacket Client / UnityC# GameServer / Go RpcPacket !21

22.

受信処理理のチューニング — オンラインゲームの通信の⼤大半は受信 — 受信処理理は何度も実⾏行行される — GC Alloc 発⽣生を極⼒力力抑えたい

23.

受信処理理 ゲーム実装 RPCClient 受信Buff Socket

24.

受信処理理: データ受信 ゲーム実装 RPCClient Read 受信Buff ヘッダ データ1 データ2 Socket

25.

受信処理理: デシリアライズ ゲーム実装 RPCClient 受信Buff new データ1 ヘッダ データ1 データ2 データ2 Read Socket

26.

受信処理理: ゲーム実装に渡す ゲーム実装 Handler(data1, data2) RPCClient 受信Buff new データ1 ヘッダ データ1 データ2 データ2 Read Socket !26

27.

受信処理理: データの所有権がゲーム実装に渡る ゲーム実装 データ1 データ2 Handler(data1, data2) RPCClient 受信Buff new データ1 ヘッダ データ1 データ2 データ2 Read Socket !27

28.

受信処理理: 新鮮なデータが次々⼊入荷 ゲーム実装 データ1 データ1 データ2 データ1 データ2 データ1 データ2 データ2 Handler(data1, data2) RPCClient 受信Buff new データ1 ヘッダ データ1 データ2 データ2 Read Socket !28

29.

受信処理理: 新鮮なデータが次々⼊入荷 ゲーム実装 データ1 データ1 データ2 データ1 データ2 データ1 データ2 データ2 ここで毎回 new したくない Handler(data1, data2) RPCClient 受信Buff new データ1 ヘッダ データ1 データ2 データ2 Read Socket !29

30.

うまく再利利⽤用したい ゲーム実装 確保済のオブジェクトを再利利⽤用したい データ1 データ2 Handler(???, ???) 引数どうしよう? RPCClient Read 受信Buff ヘッダ データ1 データ2 Socket !30

31.

遅延&上書きデシリアライズ !31

32.

上書きデシリアライズ // bytes を デシリアライズして返却 (毎回 Alloc 発⽣生) var myObj = MessagePackSerializer.Deserialize<MyClass>(bytes); // bytes を myObj に上書きデシリアライズ(これをうまく使いたい) MessagePackSerializer.DeserializeTo<MyClass>(ref myObj, bytes); !32

33.
[beta]
遅延デシリアライズ
public struct Serialized<T>
{
// Buff は受信バッファの⼀一部を指す
public ArraySegment<byte> Buff;
// Buff の内容を Deserialize で取得
public T Get()
{
return MessagePackSerializer.Deserialize<T>(Buff);
}
// Buff の内容を DeserializeTo で取得
public void Get(ref T dest)
{
MessagePackSerializer.DeserializeTo<T>(ref dest, Buff);
}
}

!33

34.
[beta]
遅延&上書きデシリアライズ
ゲーム実装

public void Handler(Serialized<MyClass> myData) {
var myObj = myData.Get(); // new してデシリアライズ
myData.Get(ref myObj);

// 上書きデシリアライズ

}

RPCClient

// 受信バッファをSerializedでラップしてゲーム実装に渡す
var myData = new Serialized<MyClass>{Buff = partOfRecvBuff};
Handler(myData);

!34

35.

うまく再利利⽤用したい (再掲) ゲーム実装 確保済のオブジェクトを再利利⽤用したい データ1 データ2 Handler(???, ???) 引数どうしよう? RPCClient Read 受信Buff ヘッダ データ1 データ2 Socket !35

36.

うまく再利利⽤用できた! ゲーム実装 new or 上書きデシリアライズ データ1 データ2 Serialized<T>でデシリアライズを遅延 Handler(serialized_data1, serialized_data2) RPCClient Read 受信Buff ヘッダ データ1 データ2 Socket !36

37.

通信基盤の紹介まとめ まがつの通信基盤 — 通信ライブラリを内製 — CL/SV型でRPCによる通信 受信処理理のチューニング — 遅延&上書きデシリアライズでオブジェクトを再利利⽤用する設計 !37

38.

リアルタイムバトル実装 〜低スペック端末でもリアルタイム同期〜

39.

リアルタイム協力バトル 39

40.

バトルの要素 攻撃判定 40

41.

バトルの要素 攻撃判定 41

42.

バトルの要素 攻撃判定 ダメージ 42

43.

同期ズレ発生 — パーティで同じバトルに参加しているのに画面が異なる — 低スペック端末のバトルのレスポンスが悪い 43

44.

同期ズレ発生 — パーティで同じバトルに参加しているのに画面が異なる — 低スペック端末のバトルのレスポンスが悪い — パーティプレイが成立しない 44

45.

同期ズレ原因 Queue Attack Attack Attack Attack Attack 低スペック Attack サーバ Queue Attack Attack 45 高スペック

46.

同期ズレ原因 Queue Attack Attack Attack Attack Attack 低スペック Attack サーバ Queue Attack Attack 46 高スペック

47.

原因 — 通知処理に1フレーム以上の時間がかかり処理が重なる – エフェクト、UI演出が重い 47

48.

原因 — 通知処理に1フレーム以上の時間がかかり処理が重なる – エフェクト、UI演出が重い — 端末の性能でFPSが異なる – 高スペック端末 – 30~28FPS – 低スペック端末 – 20~10FPS – 当時の開発段階では 48

49.

改善目標 — 演出で処理負荷が増えても画面同期できるようにする — 低スペック端末でも処理負荷を気にせずプレイできる 49

50.

改善目標 — 演出で処理負荷が増えても画面同期できるようにする — 低スペック端末でも処理負荷を気にせずプレイできる — サーバから送られてくる通知を処理する前に確認する — 不要となる通知は削除する 50

51.

今までのフロー Queue Execution Attack Attack Attack Attack Attack 51

52.

今までのフロー Queue Execution Attack Attack Attack Attack 52 Attack

53.

今までのフロー Queue Execution 53 Attack Attack Attack Attack Attack

54.

やりたいこと Queue Execution Attack Attack Attack Attack 54 Attack

55.

やりたいこと 不不要なものは無視 Queue Execution Attack Attack Attack Attack Attack 必要処理理だけ実⾏行行 55

56.

通知の概要 Notice/~ Time (UnixTime) Value (Struct) 56

57.

通知の概要 通知種類類 Notice/~ Time (UnixTime) Value (Struct) 57

58.

通知の概要 通知種類類 Notice/~ Time (UnixTime) Value (Struct) 58 実⾏行行時刻

59.

通知の概要 通知種類類 Notice/~ Time (UnixTime) 実⾏行行時刻 更更新値 Value (Struct) 59

60.

やりたいこと 不不要なものは無視 Queue Execution Attack Attack Attack Attack Attack 必要処理理だけ実⾏行行 60

61.

やりたいこと 不不要なものは無視 Queue Execution Attack Attack Attack Attack Attack 必要処理理だけ実⾏行行 61

62.

対策 Execution Notice/~ Time (UnixTime) Value (Struct) 62

63.

対策 無視できる? Execution Notice/~ Time (UnixTime) Value (Struct) 63

64.

対策 Execution 過去? Notice/~ Time (UnixTime) Value (Struct) 64

65.

対策 Execution Notice/~ Time (UnixTime) Value (Struct) 更更新する? 65

66.

対策結果 Execution Attack Attack Attack Attack Attack 66

67.

結果 — リリース前の高負荷バトル — iPhone7 — iPhone6s — Pixel2 — Xperia X SOV33 67

68.

リアルタイムバトル実装まとめ — サーバから送られてくる通知は上書き更新で統一 — 重複した通知は最後の通知のみ適用 — 処理負荷が高くても復帰できる仕組み作り 68

69.

アセットロードの負荷軽減 A69

70.

キャラクターのロード A70

71.

課題 — プリロードできない — 操作中のロード負荷 A71

72.

対策1: Priorityによるロード順の制御 — フィールドには最大100人のプレイヤー・NPC・モンスターが存在 — 端末スペックが低いと全てロードし終わるまでに時間かかる — Priorityが高いものからロード – クエストの進行に関わるNPCやモンスターが最優先 – 100人のプレイヤーはカメラから距離が近いプレイヤーを優先 A72

73.

レイヤーによるPriorityの制御 — UIのレイヤーが上に行くほどPriorityが高くなる High A73 Low

74.

割り込みロード キャラクターのロード中の 割り込みロード A74

75.

対策2: 操作中のロード負荷の軽減 操作中は状況によって負荷が変わる ロード処理 1. ファイルロード 2. Instantiate A75

76.

動的に並列ロード数を変更 並列ロード数を4に固定 ↓ FPSに応じて並列ロード数を動的に変更 A76

77.

端末ごとの並列数に差 — iPhone6S 14 並列 — Pixel3a 8 並列 — Xperia Z4 2 並列 [高スペック端末] FPSを維持したままロードが早くなる [低スペック端末] FPSが安定するがロードが遅くなる Priorityの制御でプレイに支障はでない A77

78.

対策3: Instantiateの高速化 — よく使うアセットは事前にロード・Instantiateして 非アクティブにした状態でキャッシュしておく — 2回目以降のInstantiateはClone処理となるため高速化 武器のPrefabのInstantiateの比較 初回のInstantiate Time: 0.52ms 2回目のInstantiate Time: 0.29ms A78

79.

アセットロードの負荷軽減のまとめ — ロード順の制御 — 端末スペック、負荷状況に最適化した並列ロード数 — Instantiateの高速化 A79

80.

アセットダウンロードの⾼高速化 ~ 待ちを減らせば速くなる ~ 80

81.

まがつのアセット事情 アセット総容量量 1.9 GB ファイル数 38987 これをめっちゃ速くダウンロードしたい — 進⾏行行に応じて都度ダウンロード・・ができない! — ゲームの世界に⼊入る前に全アセットを準備しておく必要あり 81

82.

HTTPによるアセットダウンロード — Unity標準API — WWW — UnityWebRequest HTTPで必要なファイルを1つずつダウンロード これが遅い… 82

83.

遅い理理由 GET — 1 TCPコネクション 1 リクエスト — 1 リクエスト毎に往復復待ち — メインスレッド待ち GET →ファイル数に⽐比例例してDL時間増加 83 GET

84.

ファイル数に⽐比例例 合計 50MB のDL時間⽐比較 on モバイル4G回線 ファイル数 50 1024 4096 50秒 100秒 150秒 200秒 84 250秒

85.

ちょっと⾒見見積もってみよう RTT 50ms の回線を仮定 1ファイルあたり1往復復するとして往復復待ち時間は.. — 50ms × 1000 ファイル = 50秒 85

86.

ちょっと⾒見見積もってみよう RTT 50ms の回線を仮定 1ファイルあたり1往復復するとして往復復待ち時間は.. — 50ms × 1000 ファイル = 50秒 — 50ms × 10000 ファイル = 8.3分 86

87.

ちょっと⾒見見積もってみよう RTT 50ms の回線を仮定 1ファイルあたり1往復復するとして往復復待ち時間は.. — 50ms × 1000 ファイル = 50秒 — 50ms × 10000 ファイル = 8.3分 — 50ms × 38987 ファイル = 32.5分 !? 87

88.

待ちを減らせば 速くなる? 88

89.

速くする⽅方法 — メインスレッドを使わない – コネクション数増やす — まとめてDLして受信後に展開 89

90.

メインスレッド待ちの回避 (採⽤用) — WWW / UnityWebRequest はメインスレッド待ちが必要 — 平均待ち時間は 30fps で 16ms — 16ms × 38987ファイル = 10.4分 — 全然無視できない →メインスレッド⾮非依存のHTTPライブラリを使⽤用 — iOS: NSURLSession — Android: OkHttp 90

91.

コネクション数を増やす (採⽤用) — 並列列数を増やして往復復待ち時間を分散 — 並列列数だけサブスレッドでダウンロード処理理 — コネクションそこまで増やしたくない — スレッド数もそんなに増やせない → 6並列列を採⽤用 91

92.

並列列化の効果 合計 50MB のDL時間⽐比較 on モバイル4G回線 ファイル数 直列列 50 6並列列 1024 4096 50秒 100秒 150秒 →かなり改善 でもまだファイル数に⽐比例例 200秒 92 250秒

93.

まとめてDLして受信後に展開 (採⽤用) GET Range アイデア:まとめてRangeリクエスト 1. アセットを連結したファイルを⽤用意 2. 欲しいファイルをまとめてRangeリクエスト 3. レスポンスを適当に分割保存 GET Range → 往復復待ちが減って⾼高速化! 93

94.

Range Requests (rfc7233) ファイルの⼀一部分を取得するHTTPの機能 リクエスト Range: bytes=xxx,yyy レスポンス 206 Partial Content xxx バイトから yyy バイトの内容が返る ※200とファイル全体を返しても良い事になっている点に注意 94

95.

ファイル数に⽐比例例しなくなる 合計50MBのDL時間⽐比較 on モバイル4G回線 ファイル数 6並列列 50 Rangeリクエスト 1024 4096 10秒 20秒 30秒 40秒 95 50秒

96.

実装 下準備 GET Range 1. アセットをtarで連結して配置 2. tarから情報を抽出して配置 (アセットリスト) – tar名、アセット名、開始位置、サイズ、ハッシュ値 ダウンロード時 GET Range 1. アセットリストをダウンロード 2. 未所持のアセットを列列挙 3. リクエスト範囲を決定 4. レスポンスの分割保存 96

97.

アセット更更新時の対応 — 更更新ファイルだけのtarを配置 — アセットリストを差分更更新 更更新の無いファイル → 古いtar 更更新のあったファイル → 新しいtar いい感じに別Rangeでダウンロードされる パッケージ管理理が簡単! 97

98.

リクエスト範囲の決定⽅方法 満たしたい要件 — 無駄にダウンロードしたくない — 1Rangeをあまり⼤大きくしたくない – 途中から再開を実装したくないので… ⼀一度⼤大きなRangeにまとめた後、以下を満たすまで分割 — 欲しい2つのファイルの間の不不要なバイト数 < 1MB — 1Rangeのサイズ < 6MB 98

99.

結果 — アセット総容量量 1.9 GB ↓ 1分12秒 iPhoneXs Wifi 下り: 491.1Mbps — ファイル数 38987 — 30Mbps 10分 — 1Gbps 1分 環境によってはファイル操作がボトルネックになる 99

100.

待ちを減らせば 速くなる! 100

101.

開発ツールの紹介 A101

102.

通信データを監視したい — 秒間10以上のデータを送受信 — デバッグログでの監視は辛い UnityEditorでリアルタイムに監視できるツール A102

103.

ツールの構成 — アプリとゲームサーバーが通信したデータを 都度UnityEditorに転送 ゲームサーバー — 通信内容をリアルタイム監視 UnityEditor アプリ ② A103 ①

104.

通信データ監視ツール A104

105.

ツールの基盤部分の説明 UnityEditorと実機アプリをUSBを介してTCP接続し相互通信 TCPクライアント TCPサーバー 高速・安定 A105

106.

USB接続について Androidはadbコマンド、 iOSはiproxyコマンドを使用して ローカルポートをUSBトンネリングして接続 adb -s [Android serial_number] forward tcp:[local port] tcp:[server port] iproxy [local port] [server port] [ios UDID] A106

107.

Unity標準APIでUSB接続 Unityの標準APIでも実機とUnityEditorの通信ができる — UnityEngine.Networking.PlayerConnection.PlayerConnection — UnityEditor.Networking.PlayerConnection.EditorConnection A107

108.

Luaの使用 — アプリにLuaエンジンを組み込み — LuaスクリプトをUnityEditorからアプリに送信&実行 — sluaを利用 – LuaからUnityAPIを簡単に呼び出すようにできるライブラリ – github.com/pangweiwei/slua A108

109.
[beta]
UnityEditorからLuaソースコードの送信処理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

if (GUILayout.Button("非アクティブにする"))
{
// Luaスクリプト
var luaScript = @"
function main()
-- 特定のGameObjectを非アクティブにする
local obj = UnityEngine.GameObject.Find('Player')
obj:SetActive(false)
end";
// UnityEditorから実機にLuaスクリプトを送信
Client.SendLuaScript(
luaScript,
result => {}
);

A109

}

110.
[beta]
UnityEditorからLuaソースコードの送信処理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

if (GUILayout.Button("非アクティブにする"))
{
// Luaスクリプト
var luaScript = @"
function main()
-- 特定のGameObjectを非アクティブにする
local obj = UnityEngine.GameObject.Find('Player')
obj:SetActive(false)
end";
// UnityEditorから実機にLuaスクリプトを送信
Client.SendLuaScript(
luaScript,
result => {}
);

A110

}

111.
[beta]
UnityEditorからLuaソースコードの送信処理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

if (GUILayout.Button("非アクティブにする"))
{
// Luaスクリプト
var luaScript = @"
function main()
-- 特定のGameObjectを非アクティブにする
local obj = UnityEngine.GameObject.Find('Player')
obj:SetActive(false)
end";
// UnityEditorから実機にLuaスクリプトを送信
Client.SendLuaScript(
luaScript,
result => {}
);

A111

}

112.

応用編 — UnityEditorと実機の相互通信 — Luaを使ったアプリの動的更新 A112

113.

Unityのモバイル開発を 快適にするツール A113

114.

実機で使えるHierarchy・Inspector A114

115.

Hierarchy Hierarchy情報送信 A115

116.

Inspector Luaで更新 A116

117.

File Explorer A117

118.

File Explorer A118

119.

File Explorer A119

120.

Inspectorでファイルの詳細情報を表示 A120

121.

アセットバンドル転送ツール A121

122.

アセットの実機確認に時間がかかる — アセットバンドルビルドに20〜30分以上かかる – Jenkinsで全アセットを対象にビルド — サーバーから実機にダウンロード A122

123.

アセットバンドル転送ツール アセットバンドルビルド&ファイル送信が10〜30秒 1. UnityEditorでアセットを右クリックで実行 2. ローカルマシンでアセットバンドルビルド 3. USB経由で実機にファイル送信 4. アプリ側のアセット依存情報等をLuaで更新 A123

124.

開発ツールのまとめ — UnityEditorと実機の相互通信 — Luaを使ったアプリの動的更新 この2つがあれば、よりモバイル開発を快適にする事ができる A124

125.

全体のまとめ — 通信基盤の紹介 — リアルタイムバトル実装 — アセットロードの負荷軽減 — アセットダウンロードの高速化 — 開発ツールの紹介 125

126.

禍つヴァールハイト App Store, Google Playで「まがつ」で検索 ご興味があるかたは是非プレイしてみて下さい。 126

127.

ご清聴ありがとうございました 127