11.8 ネットワーク品質
11.8.1 レイテンシ
回線速度の問題は、より良いゲーム体験をプレイヤーに提供する上で極めて重要になります。特に、アクション性の高いゲームのであれば数ミリ秒のネットワークの遅延のためにやられてしまうことがあります。このような、通信におけるデータの要求や返答までの遅延時間をレイテンシと呼びます。レイテンシを根本的に解決する方法はなく、通信対戦ゲームを実装するときには必ずレイテンシを意識して作らなければなりません。Xbox 360 では、最大で 200 ミリ秒のレイテンシがあっても動作するように求められます。
テーブルゲームのようなターン制の通信対戦であれば、レイテンシの問題は大きくはありません。一方で、動きの激しい対戦ゲームであれば、事前予測などを行い、通信が一時的に途切れてもゲームが止まったりカクカクした動きにならないように、できる限りの調整を行う必要があります。
レイテンシの影響を少なくするテクニックはゲームの種類などによっても異なりますが、最も正しい選択はより品質の高い回線で通信を行ってもらうことです。Find() メソッドから得られた AvailableNetworkSession オブジェクトは、セッションを開いているホストとのレイテンシや帯域などの測定値を QualityOfService プロパティから提供してくれます。この情報を用いて、通信状態が良好なセッションを優先的に利用するように仕掛けられます。
public QualityOfService QualityOfService { get; }
このプロパティは、ネットワーク接続の品質を表す Microsoft.Xna.Framework.Net.QualityOfService クラスのオブジェクトを返します。
public sealed class QualityOfService
Find() メソッドによって接続可能なセッションが見つかると、内部の処理で自動的にネットワーク品質の検査が行われます。よって、Find() メソッドで結果が得られた時点では QualityOfService オブジェクトに正しい値が設定されていません。品質検査が終了しているかどうかは IsAvailable プロパティで調べられます。
public bool IsAvailable { get; }
このプロパティが true を返せば、品質検査は終了しておりプロパティから品質に関するデータを取得できることを表します。一方、このプロパティが false であれば品質検査が終了しておらず、QualityOfService のプロパティは全て 0 の状態です。
品質検査が終了していれば、QualityOfService オブジェクトのプロパティからレイテンシや帯域の情報を得られます。
ホストに対してデータを送信し、その結果が得られるまでの往復レイテンシのことをラウンドトリップと呼びます。品質測定で送受信されたパケットの平均ラウンドトリップ時間を AverageRoundtripTime プロパティから取得できます。また、最小のラウンドトリップ時間を MinimumRoundtripTime プロパティから取得できます。
public TimeSpan AverageRoundtripTime { get; }
public TimeSpan MinimumRoundtripTime { get; }
AverageRoundtripTime プロパティから得られる平均ラウントトリップ時間はメジアン(中央値)が使われています。品質測定時に使われたパケットのラウントトリップ時間を整列し、その中央の時間が結果として返されます。
ローカルマシンからセッションのホストまでのアップロード帯域幅の予測値は BytesPerSecondUpstream プロパティから、ダウンロード帯域幅の予測値は BytesPerSecondDownstream プロパティから得られます。
public int BytesPerSecondUpstream { get; }
public int BytesPerSecondDownstream { get; }
これらのプロパティが返す整数は「バイト/秒」単位です。ただし、この測定値は大まかな予測にすぎないため、実際の帯域幅が正確に表示されるとは限りません。ゲーム内でこの値を直接表示することは推奨されておらず、アンテナのような直観的なアイコンを使って品質を可視化するべきでしょう。
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.GamerServices; using Microsoft.Xna.Framework.Net; public class Test : Game { public static void Main(string[] args) { using (Game game = new Test()) game.Run(); } private GraphicsDeviceManager graphicsDeviceManager; private AvailableNetworkSessionCollection sessions; private SpriteBatch sprite; private SpriteFont font; private string text; public Test() { graphicsDeviceManager = new GraphicsDeviceManager(this); Components.Add(new GamerServicesComponent(this)); } protected override void LoadContent() { sprite = new SpriteBatch(GraphicsDevice); font = Content.Load<SpriteFont>("Content/TestFont"); base.LoadContent(); } protected override void Update(GameTime gameTime) { GamePadState state = GamePad.GetState(PlayerIndex.One); if (state.Buttons.Back == ButtonState.Pressed) Exit(); SignedInGamer gamer = Gamer.SignedInGamers[PlayerIndex.One]; if (gamer == null) { text = "Please sign in as player one."; if (!Guide.IsVisible) Guide.ShowSignIn(1, true); } else { UpdateMenu(state); } base.Update(gameTime); } private void UpdateMenu(GamePadState state) { text = "X BUTTON: Find a network session\n\n"; if (state.Buttons.X == ButtonState.Pressed) sessions = NetworkSession.Find(NetworkSessionType.SystemLink, 1, null); //検索が行われていない、またはセッションが見つからない if (sessions == null) return; else if (sessions.Count == 0) { text += "Network session not found"; return; } //見つかったセッションの品質情報を表示 foreach (AvailableNetworkSession item in sessions) { QualityOfService quality = item.QualityOfService; text += item.HostGamertag + "\n"; if (quality.IsAvailable) { text += " AverageRoundtripTime=" + quality.AverageRoundtripTime.TotalMilliseconds + "ms\n"; text += " MinimumRoundtripTime=" + quality.MinimumRoundtripTime.TotalMilliseconds + "ms\n"; text += " BytesPerSecondUpstream=" + quality.BytesPerSecondUpstream + "Byte/s\n"; text += " BytesPerSecondDownstream=" + quality.BytesPerSecondDownstream + "Byte/s\n"; } } } protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.White); sprite.Begin(); sprite.DrawString(font, text, Vector2.Zero, Color.Black); sprite.End(); base.Draw(gameTime); } }
コード1の UpdateMenu() メソッドの処理では、Find() メソッドから得られた検索結果から、各セッションの品質情報を取得しています。これらの品質情報は、参考とするための計測値にすぎないので、回線状況によって異なる結果が得られます。よって、同じ環境でも常に同じ値を返すわけではありません。
11.8.2 品質のシミュレーション
ゲームは、常に理想的な品質の回線を使って通信できることを想定するべきではありません。回線速度はゲームの実行環境に依存しますが、Xbox 360 では上り下り共に 8KB/s の帯域で動作することが求められます。システムリンクを使った LAN 内のテスト環境であれば、高速で理想的な帯域幅を得られますが、Xbox LIVE を介した接続では、遠く離れた国のゲーマーがセッションに参加する可能性があることを忘れてはなりません。帯域が限られていることに注意し、不要なデータは送らないように設計しなければなりません。
ゲームは、レイテンシの高い環境でも正常に動作することが求められますが、そのためにはレイテンシの高い環境で正常に動作するかどうかを確認するテストが必要になります。通常、高速なローカルネットワーク内でのテストでは、待ち時間の長いインターネットを介した Xbox LIVE の通信とは環境が大きく異なります。しかし、テストのためにインターネットを介して通信を行う環境を用意するのは簡単ではありません。
そこで、NetworkSession クラスには、レイテンシをシミュレートする SimulatedLatency プロパティが用意されています。
public TimeSpan SimulatedLatency { get; set; }
このプロパティには、送信側のセッションがシミュレートするネットワークの待ち時間を設定します。ここに 200 ミリ秒を表す TimeSpan 構造体の値を設定するれば、パケットの送信に 200 ミリ前後の待ち時間が発生します。ネットワーク対応のゲームを開発する場合は、最大で 200 ミリ秒の待ち時間が発生しても動作することが要求されます。
送信するパケットは SimulatedLatency に設定した待ち時間に固定されるわけではなく、指定されている待ち時間を中心にランダムな値で遅延がシミュレートされます。よって、パケットの順番を保証しない通信オプションを選択している場合、後に送ったパケットが先に到達してしまう現象も確認できます。
信頼性が維持されない設定で送信されたパケットは、途中で失われる可能性があります。このパケットの損失率を SimulatedPacketLoss プロパティで設定して高速な回線でもシミュレートできます。
public float SimulatedPacketLoss { get; set; }
このプロパティには 0.0 ~ 1.0 までの間で、パケットの損失率を設定できます。Xbox 360 ゲームの場合、最大で 10% のパケット損失率で動作することが求められています。よって、このプロパティの値を 0.1 に設定してテストを行い、快適な通信が保たれるかを調べなければなりません。
SimulatedLatency プロパティと SimulatedPacketLoss プロパティの設定は、NetworkSession オブジェクトのローカルな送信者にのみ作用します。よって、セッション参加者全体で共有される値ではありません。他のセッション参加者の送信に影響は与えません。
快適な通信を行うには、ネットワークの品質も重要ですが、同時に送受信するデータの量を少なくすることも重要です。NetworkSession オブジェクトから、データの送受信がどのくらい行われているかという値をパフォーマンスとして得ることができます。データの送信量は BytesPerSecondSent プロパティから、受信量は BytesPerSecondReceived プロパティから得られます。
public int BytesPerSecondSent { get; }
public int BytesPerSecondReceived { get; }
これらのプロパティは、過去 1 秒間の間に送受信されたデータ量をバイト単位で返します。ゲームがネットワークの帯域をどのくらい使用しているのかを確認できます。 開発段階で、これらのプロパティの値を表示して、回線に負荷がかかっていないかどうか監視できます。
using System; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.GamerServices; using Microsoft.Xna.Framework.Net; public class Test : Game { public static void Main(string[] args) { using (Game game = new Test()) game.Run(); } private GraphicsDeviceManager graphicsDeviceManager; private NetworkSession session; private AvailableNetworkSessionCollection sessions; private PacketWriter writer; private PacketReader reader; private SpriteBatch sprite; private SpriteFont font; private string text; public Test() { graphicsDeviceManager = new GraphicsDeviceManager(this); Components.Add(new GamerServicesComponent(this)); writer = new PacketWriter(); reader = new PacketReader(); } protected override void LoadContent() { sprite = new SpriteBatch(GraphicsDevice); font = Content.Load<SpriteFont>("Content/TestFont"); base.LoadContent(); } protected override void Update(GameTime gameTime) { GamePadState state = GamePad.GetState(PlayerIndex.One); if (state.Buttons.Back == ButtonState.Pressed) Exit(); SignedInGamer gamer = Gamer.SignedInGamers[PlayerIndex.One]; if (gamer == null) { text = "Please sign in as player one."; if (!Guide.IsVisible) Guide.ShowSignIn(1, true); } else { if (session != null) UpdateSession(state); else if (sessions != null) UpdateFindSession(state); else UpdateMenu(state); } base.Update(gameTime); } private void UpdateMenu(GamePadState state) { text = "A BUTTON: Create a network session\n"; text += "X BUTTON: Find a network session\n\n"; if (state.Buttons.A == ButtonState.Pressed) session = NetworkSession.Create(NetworkSessionType.SystemLink, 1, 16); else if (state.Buttons.X == ButtonState.Pressed) sessions = NetworkSession.Find(NetworkSessionType.SystemLink, 1, null); } private void UpdateSession(GamePadState state) { text = "B BUTTON: Dispose a network session\n"; text += "Left Stick: Simulation latency\n"; text += "Right Stick: Simulation packet loss\n\n"; if (state.Buttons.B == ButtonState.Pressed || session.SessionState == NetworkSessionState.Ended) { session.Dispose(); session = null; return; } LocalNetworkGamer localGamer = session.LocalGamers[0]; //左スティックによるレイテンシの調整 if (state.ThumbSticks.Left.Y > 0.9 && session.SimulatedLatency.TotalMilliseconds < 500) session.SimulatedLatency += new TimeSpan(0, 0, 0, 0, 1); if (state.ThumbSticks.Left.Y < -0.9 && session.SimulatedLatency.TotalMilliseconds > 0) session.SimulatedLatency -= new TimeSpan(0, 0, 0, 0, 1); //右スティックによるパケット損失の調整 if (state.ThumbSticks.Right.Y > 0.9 && session.SimulatedPacketLoss < 0.9) session.SimulatedPacketLoss += 0.01F; if (state.ThumbSticks.Right.Y < -0.9) { if (session.SimulatedPacketLoss - 0.01F < 0) session.SimulatedPacketLoss = 0; else session.SimulatedPacketLoss -= 0.01F; } //送受信データ量の表示 text += "Sent=" + session.BytesPerSecondSent + "Bytes/sec\n"; text += "Received=" + session.BytesPerSecondReceived + "Bytes/sec\n"; //シミュレーション値の表示 text += "Latency=" + session.SimulatedLatency.TotalMilliseconds + "ms\n"; text += "Packet Loss=" + (int)(session.SimulatedPacketLoss * 100) + "%\n\n"; //各ゲーマーの値を受信 while (localGamer.IsDataAvailable) { NetworkGamer sender; session.LocalGamers[0].ReceiveData(reader, out sender); if (sender != localGamer) sender.Tag = reader.ReadInt32(); } //現在の値に 1 を加算して送信 localGamer.Tag = (localGamer.Tag == null ? 0 : (int)localGamer.Tag + 1); writer.Write((int)localGamer.Tag); localGamer.SendData(writer, SendDataOptions.None); //各ゲーマーの現在の値を表示 foreach (Gamer gamer in session.AllGamers) text += gamer.Gamertag + "=" + (gamer.Tag == null ? 0 : (int)gamer.Tag) + "\n"; session.Update(); } private void UpdateFindSession(GamePadState state) { text = "Network session search results\n"; text += "B BUTTON: Return to menu\n\n"; if (state.Buttons.B == ButtonState.Pressed) sessions = null; else if (sessions.Count == 0) text += "Session not found"; else { session = NetworkSession.Join(sessions[0]); sessions = null; } } protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.White); sprite.Begin(); sprite.DrawString(font, text, Vector2.Zero, Color.Black); sprite.End(); base.Draw(gameTime); } }
コード2は、UpdateSession() メソッド内でレイテンシやパケット損失率を設定し、継続的に値を送受信し続けるプログラムです。セッションに参加している各ゲーマーは整数型の値を持ち、フレーム毎にインクリメントしながら値を送信します。左スティックの上下でレイテンシを変更し、右スティックの上下でパケット損失率を変更できます。
このプログラムでは、SendData() メソッドの通信オプションで None を指定しているため、信頼性も順序も保証されません。送信者は、現在の自分の値をインクリメントしながら整数を送信しているだけなので、受信した整数からパケットの順番が入れ替わっているか、パケットが損失したかを確認できます。レイテンシの値を高く設定すれば受信するパケットの順番がバラバラになり、パケットの損失率を高く設定すれば一部の送信データが受け取れなくなります。