この記事は15分程度で読めます。
ゲームプログラミングになじみがない人向けの短めコラムです。
前回記事の続きです。
はじめに
前回の記事では対戦ゲームにおける同着や相打ちの考え方を紹介しました。今回の記事ではもう少し深掘りして、他の実装方法や、より上位概念となるゲームループやプレイヤー情報の検知の考え方について紹介します。
プログラミング言語を知らない人でもある程度読めるように、疑似言語を使って短めのコードで記載するので、なんとなく英語の箇条書きで処理の流れが書かれているんだなと思いながら読んでみて下さい。
ゲームループの深掘り
同着処理の続きを話す前に、前提知識として少しゲームループについて脱線します。
- イベント処理: プレイヤーからの入力やその他のゲームイベントを処理します
-
状態更新: ゲームの状態を変更します
(プレイヤーの移動、ダメージ処理、ゴール判定/死亡判定など) - レンダリング: ゲームの現在の状態を画面に反映します
while (Game.isRun()) {
Game.input()
Game.update()
Game.render()
}
ゲームループは上記の処理をゲームが終わるまで無限にループすることは前回説明しました。
しかし、ゲーム機の性能が高スペックである場合に、低スペックなゲーム機に比べて、上記の処理がより多く動いてしまってはフェアなゲームの作りになりません。そのため一般的には、ゲーム機がどれだけ高スペックであっても、上記の処理が一定回数までしか動かないように制限が設けられています。その制限の1つが FPS (Frame per second) と呼ばれるパラメータです。これは1秒間に画面のレンダリングを行う回数のことです。
ゲーム機において、画面のレンダリングを行う部分を GPU と呼びますが、GPU の性能が高い場合、1回のレンダリングにかかる時間が高速になり、それが人間の眼が認識できる早さ(75分の1秒~240分の1秒程度といわれる)を超えることがあります。そのため、1秒にせいぜい100回程度レンダリングを行っておけば、誰から見ても同程度に滑らかなゲームになります。
逆に GPU の性能が低い場合、1回のレンダリングにかかる時間が長くなり、1秒間に定められた回数のレンダリングを守れなくなることがあります。このときゲームの画面はカクカクと動いているように見えます。対戦ゲームにおいて、相手から見ると高速に動いている弾丸が、自分から見ると低速に動いて見えるとなってしまっては、低スペックなゲーム機の方が回避が有利になってしまいます。
このようなアンフェアな状況が起こらないように、ゲームループにはゲーム機のスペック差が起きにくくなるような工夫がされています。
スリープとスキップ
前回、ゲームループはゲームが続く限り(while is running)3つの処理(input、update、render)を繰り返すと説明しました。
しかし実際には、ゲーム機の性能不足やゲーム機が別の処理に気を取られていることによってアンフェアにならないよう、スリープとスキップによって上記の処理を省略し、バランスを保つようになっています。
スリープ
慣習的に1秒にレンダリングする回数は60回(60FPS)がデフォルトの設定とされていることが多いです。つまり、1回のレンダリングあたりにかかる時間は 60分の1秒 = 16.666... ミリ秒以内である必要があります。このとき、高スペックなゲーム機が1回のレンダリングを 1 ミリ秒で終えてしまった場合どうすればよいでしょう?
答えは簡単で、残りの 15.666... ミリ秒を寝て待てばよいです。これをスリープといいます。どんなに高スペックなゲーム機であったとしても、スリープする時間を調整すれば 60FPS に制限することができるわけですね。
スキップ
逆に低スペックなゲーム機や高スペックでも他の処理に手間を取られて忙しくなっているゲーム機は1回のレンダリングに時間がかかることがあります。もしそれが 16.666... ミリ秒を超えて 20 ミリ秒になってしまった場合どうすればよいでしょうか?
これも答えは簡単で、レンダリングを何回かお休みすればよいです。これをスキップといいます。上記の通りであればレンダリングを1回スキップすると 20 ミリ秒の借金を返済できるので、2回に1回程度レンダリングを行えば、高スペックなゲーム機の人とも対等に対戦ができそうです(*)。
(*) レンダリングではなく input、update に時間がかかっている場合、スキップは推奨されません。なぜなら input、update はゲームの状態を把握する部分なので、ここをスキップしてしまってはプレイヤーが受けたダメージや死亡判定や重力による物体の移動の計算ができなくなってしまいます。そうなると、そもそもゲームでは無くなってしまうため、スキップはレンダリングの部分に限定して行うことが多いです。
また、input、update は ゲーム機の GPU ではなく CPU を使うため、レンダリングのスキップとは別の考え方で余力を作りだす必要があります。
上記を考慮すると、ゲームループは以下のようなコードで表現することができます。
while (Game.isRun()) {
Game.input()
Game.update()
if 前回 render() してからまだ 16.666...ミリ秒経過していない場合
Game.render()
if 次のループまで待機時間がある場合
Game.sleep(待機時間)
}
余力があるときに休み、忙しい時にスキップする、ということですね。
リアルタイム性と遅延
そろそろ脱線は終わりです。同着処理の話の続きをしましょう。
前述の通り、1回のループにかかる時間は 16 ミリ秒程度であり、そのレンダリング1回を1フレームと呼びます(厳密には少し違いますがここではそう呼ぶことにします)。
ゲームにおけるリアルタイム処理というのは、フレーム内にやるべきことが全部間に合っており、実際の時間に追従しながら常に FPS を安定して確保できているときに、リアルタイム処理ができているなどと言うことが多いです。同着や相打ちの判定も、リアルタイムの時間に矛盾せずに行われている必要があります。そのようなゲームはリアルタイム性のあるゲームと呼ばれます。
しかし、その判定結果が判明するのは必ずしも同じフレーム内である必要はありません。例えば3フレーム後に、先ほどの結果は相打ちでしたと分かることが常に保証されていれば、それは結果のアナウンスが常に3フレーム遅延しているだけであり、リアルタイム性が失われているわけではありません。フェアな作りが実現できていることに変わりはないのです。
遅延が発生することが前提となるオンラインゲームなどにおいては、あらかじめこのような作りが考慮されていることがあります。
つまり、1回の状態更新(Game.update)において、過去の情報を閲覧し順位・相打ちを判定できれば、それでもよいということです。
疑似言語で書くと以下のようなコードになります。
function update() {
// pre processing
Game.getRecords() // 前回までのゲームの情報を参照、他のプレイヤーの情報を参照
// player processing
for (player in players) {
player.record() // 今回の自分の情報を記録
player.update() // 過去の情報に戻づいて自分の状態を更新
if player.isDeath()
death(player)
}
// post processing
}
前回のコードとほとんど同じですが、事前処理(pre process)で参照する情報が現在の情報ではなく、過去の情報に変わっています。またその情報は、プレイヤーに関する情報でなくとも構いません。この過去の情報はどこまで遡っても問題ありませんが、遡る時間が長ければ長くなるほどゲーム機のメモリを消費します。
ゲームによっては「ぐわぁ、やられたー・・・ドサッ」のように一定時間内の演出も含めて複数人を相打ちにしたくなることもあります。また、オンラインゲームではそもそも常に最新のフレームの情報が得られるとは限りません。そのような場合には、事前処理の中で、過去のフレームの情報を参照することも検討してください。
まとめ
遅延が前提となるオンラインゲームなどでは、これまでと同じような考え方でフェアなゲームを作ることは難しくなります。フェアなゲームを作る手法は他にも色々あるので興味がある人は調べてみて下さい。特にオンラインの対戦格闘ゲームなどについて勉強すると、過去の情報だけではなく、未来の予測なども行っており、これまで考えたこともないような面白いアイデアに出会うことができます。