ゲーム機のエミュレーションには、さまざまな遅延要因があります。
この記事では、エミュレータ開発者が直接コントロールできる入力遅延の原因を説明し、それを最小化するための私の方法を提案します。
エミュレートされたゲームシステムは、通常、エミュレートされたフレームごとに1回、VBlank割り込みの間にコントローラからの読み込みを行います。
技術的には、ゲームはいつ何度でもコントローラをポーリングすることができますが、99.9%のゲームは上記の戦略に従います。
通常、入力はメモリにマッピングされたI/Oレジスタからポーリングされて得られます。
その後、エミュレータは、エミュレートされたマッピングを物理的なキーボード、マウス、またはゲームパッドに変換し、前記I/Oレジスタの読み取りから現在のボタンの状態を返す必要があります。
ほとんどのエミュレータは、フレームごとに実際のハードウェアデバイスをポーリングするように設計されており、次のようなランループを実行しています。
void Program::run() {
while(stopped() == false) {
hardware.pollInputs(); // 1
emulator.runFrame(); // 2
video.drawFrame(); // 3
}
}
1. hardware.pollInputs()
上記のコードで、hardware.pollInputs()
は、DirectInput、XInput2、SDLなど、利用可能なハードウェア入力APIに問い合わせを行い、その結果をキャッシュして後で使用します。
void Hardware::pollInputs() {
keyStates = directInput.pollKeyboard();
}
2. emulator.runFrame()
次に emulator.runFrame()
では、1フレーム分のビデオデータが揃うまでエミュレータを継続して動作させます。
このときにエミュレーションコアの内部では、ゲームパッドの状態をメモリマップしたI/Oレジスタが読み込まれると、hardware.pollInputs()
によってキャッシュされていた状態が返されます。
uint8_t Emulator::pollGamepad() {
uint8_t data = 0;
data |= program.readInput(GAMEPAD_UP ) << 0;
data |= program.readInput(GAMEPAD_DOWN) << 1;
...
return data;
}
これは、プログラムがエミュレートされた入力を、マッピングされたハードウェアの入力に変換することで行われます。
bool Program::readInput(uint inputID) {
if(inputID == GAMEPAD_UP ) return hardware.keyStates[KEY_UP];
if(inputID == GAMEPAD_DOWN) return hardware.keyStates[KEY_DOWN];
...
}
3. video.drawFrame()
最後に、video.drawFrame()
は、エミュレートされたビデオフレームを受け取り、Direct3D、OpenGL、SDLなどを使って、プログラムのオンスクリーンウィンドウに出力します。
void Video::drawFrame() {
direct3D.draw(program.window, emulator.frame, emulator.width, emulator.height);
}
以上が典型的なエミュレータの処理フローです。
マルチエミュレータのフロントエンドであるRetroArchはこのように書かれています。
この記事では、1フレームあたりのスキャンラインが262本のスーパーファミコンを想定しています。
ここでは、変数V
はエミュレータが現在生成している垂直方向のスキャンラインを意味し、V=0..261
と宣言しましょう。
emulator.runFrame()
がVBlank期間を含む1フレーム全体をエミュレートした場合、hardware.pollInputs()
が呼ばれたときにはV=0になっています。
この状態は、ゲームに読み込まれるV=225
まで続くので、ポーリングされた結果を実際に使用するときには、ほぼ1フレーム分、16msが経過していることになります。
脱線しますが、エミュレータの性能が非常に高く、ビデオに同期している場合は、より早くこの状態に到達し、ホストマシンが独自のVBlank期間に達するまでプログラムをスリープさせることができます。エミュレータをオーディオに同期させたり、単に負荷の高いエミュレータであれば、この利点は否定されます。
ゲームは通常、画面を描画した後、VBlank期間に入ります。ゲームは通常、VBlank中にエミュレートされたコントローラをポーリングします。
ここでもスーパーファミコンを想定して、NTSCモードではV=1..224
、PAL(オーバースキャン)モードではV=1..239
の間で画面をレンダリングしています。
1つの方法は、画面がレンダリングされた直後、入力がポーリングされる前にエミュレータの実行、つまりemulator.runFrame()
を終了させることです。言い換えれば、エミュレートされたVBlank期間の開始時に終了することです。
emulator.runFrame()
がV=225(NTSCの場合。PALの場合はV=240)でリターンした場合、エミュレートされたマシンのVBlankハンドラに戻る直前に、ホストマシンの入力をポーリングします。
RetroArchの入力遅延修正パッチはこのようにして作られており、この潜在的な1フレーム分のラグのペナルティを取り除くことを目的としています。
PALモードとは、SNESに15本のスキャンラインを追加してレンダリングするように指示する設定で、PALのディスプレイであれば容易に確認することができます。しかし、NTSCのゲームでもオーバースキャンを使うものと、PALのゲームでもオーバースキャンを使わないものがあります。
実際、病的ではありますが、VBlank期間中にオーバースキャンの設定を切り替えることも可能です。Titanによるメガドライブのデモシーンソフト『Overdrive 2』は、メガドライブのグラフィックチップを悪用して、オリジナルのハードウェアの能力を超えた追加のスキャンラインを引き出すことができます。
しかし、スーファミの場合でも、オーバースキャンを無効にしてV=225
に達したとしても、それはフレーム全体がレンダリングされたことを保証するものではなく、ゲームがオーバースキャンをオンにして、喜んでスキャンラインを描き始めるかもしれません。これは、V=225
でemulator.runFrame()
を終了しようとする場合には大きな問題となります。
また、ゲームが必ずしも正確にV=225
でコントローラをポーリングするとは限らないことも考慮してください。ゲームによっては、画面が終了する前のV=220
や、VBlankルーチンの終了直後のV=261
で入力をポーリングするものもあります。
病的なゲームでは、利用可能なサイクルがあればいつでも入力をポーリングすることを選ぶかもしれず、結果としてポーリング位置は1フレームごとに変わるかもしれません。
上記の最適化はあまりにも粗いです。もっとうまくできるはずです。
PCエンジンのエミュレータである『Ootake』は、エミュレートされたスキャンラインごとに本物のハードウェア入力をポーリングすることで、この問題を改善しようとしています。
これは確かに有効ですが、エミュレートされたフレームごとに262回のDirectInput
によるポーリングイベントが発生します。60Hzのリフレッシュレートでは、1秒間に15,720回ものDirectInput APIの呼び出しが発生することになります。
どんなに速いUSBデバイスでも1秒間に1,000回しかポーリングしないので、これは非常に無駄なことです。(しかも、OSのデフォルト設定では、USBデバイスのポーリング頻度は通常100回程度です。)
hardware.pollInputs()
の呼び出しには、カーネルモードへのスイッチを必要とするハードウェアAPIへの問い合わせ、キーボード全体の状態の取得、それらの状態のエミュレートされた入力へのマッピングなどの作業が必要です。
これが、エミュレータがこの処理をフレームごとに1回しか行わないようにしている理由です。このオーバーヘッドがなければ、Program::readInput()
がhardware.pollInputs()
自体を呼び出すだけの簡単な解決策になります。
しかし、これには簡単な解決策があります。
今回紹介する解決案を私は、JITポーリング(Just-in-Time Polling)と呼んでいます。
ホストのハードウェア入力が最後にポーリングされた時のタイムスタンプを保持することで、頻繁に呼び出されても実際のポーリングを行わないようにすることができます。
この新しいデザインは次のようなものです。
void Hardware::pollInputs() {
static uint64_t lastPollTime = 0;
uint64_t thisPollTime = getHostMachineCurrentTimestampInMilliseconds();
if(thisPollTime - lastPollTime >= 5) { //latency timeout: 5 milliseconds
keyStates = directInput.pollKeyboard();
lastPollTime = thisPollTime;
}
}
void Program::run() {
while(stopped() == false) {
//we no longer have to call hardware.pollInputs() here
emulator.runFrame();
video.drawFrame();
}
}
bool Program::readInput(uint inputID) {
//we call hardware.pollInputs() here instead
hardware.pollInputs();
if(inputID == GAMEPAD_UP ) return hardware.keyStates[KEY_UP];
if(inputID == GAMEPAD_DOWN) return hardware.keyStates[KEY_DOWN];
...
}
上記のコードでは、emulator.runFrame()
がいつ戻るかを気にする必要はありません。
エミュレートされたシステムが入力をポーリングしようとすると、そのタイミングでホストマシンの入力をポーリングします。
5ミリ秒のタイムアウトがあるので、Emulator::pollGamepad()
がGAMEPAD_DOWN
の状態を取得するためにProgram::readInput()
を2回目に呼び出しても、hardware.pollInputs()
を2回目に呼び出すことはないということです。
ハードウェアは入力が読み込まれる直前にポーリングされ、その後、エミュレーションコアによってすべての入力が1つのクラスタにまとめて読み込まれることがほとんどであるため、hardware.pollInputs()
はフレームごとに1回しか起動されません。
ハードウェアをポーリングした後、エミュレータは次の1フレームになったら実行を再度続けます。
1フレームは16ミリ秒なので、ポーリングのクールダウン期間の5ミリ秒より十分に大きいです。そのため、エミュレートされたゲームがいつ入力をポーリングしようと、ホストハードウェアは直ちにポーリングされます。
また、スキャンラインごとに入力をポーリングし続けるような病的なゲームに遭遇した場合でも、最大入力遅延は5ミリ秒であることを保証しています。
99.9%のケースで、JITポーリングはDirectInputを1秒間に60回しかポーリングしませんが、これはこの記事で最初に取り上げたフレームの最初にポーリングを行う手法と同じ頻度です。
病的なケースでは、5ミリ秒のクールタイムが最大オーバーヘッドの上限となり、最大入力遅延も5ミリ秒、つまり1秒間に200回のDirectInput呼び出しが発生することになります。
これが bsnes と higan の入力ポーリングのエミュレート方法です。
RetroArchの遅延修正パッチは、RetroArchの設計では必要でしたが、bsnesのデザインには必要ありませんでした。さらに言えば、bsnesでRetroArchの遅延修正パッチを使うようにすると、SNESのPALモードをエミュレートして、VBlank期間中に切り替えられるようにすることにも支障をきたしたでしょう。
## クールタイムの変更
JITポーリングの副次的効果として、現在5ミリ秒のポーリングクールタイムをユーザーが設定で変更可能にすることができます。
1ミリ秒という低い値を設定すれば、市場に出回っている1000hzのUSBポーリングゲームパッドやドライバーに追いつくことができます。
また、1フレームのレンダリングにかかる時間よりも高い値を設定すれば、ラグシミュレーターになります。まともな人がなぜこのようなことをしたいのか全くわかりませんが、ユーザーにとって入力遅延は通常、かなり抽象的な概念です。240fps以上の高速カメラや、ビデオをずっと注意深く分析しない限り、直接測定することはできません。
JITポーリングに大きめのクールタイムを設定することで、ユーザーは入力遅延が大きい時の動作をシミュレートすることができます。50msに設定すると、約2フレーム分の入力遅延が発生することになり、2フレーム分の遅延がゲームのプレイ体験や操作感にどのような影響を与えるかを、実際に体験することができます。
ゲームの難易度を大幅に上げるには、入力遅延を増やすのが簡単です。格闘ゲームでは、片方のプレイヤーがもう片方のプレイヤーよりも遅延時間を長くすることで、簡単にハンディキャップを与えることができます。実用的か、役に立つかと言われると疑問ですが...
この手法をRetroArchなどの他のエミュレータに適用すれば、上流のエミュレータにパッチを当てることなく、すべてのエミュレーションコアの遅延を一気に解消できると考えています。
さらに、すでに入力遅延の問題を解決しているエミュレータであっても、入力遅延をさらにわずかに短縮できる可能性があると考えています(例えば、私が考えている、例外としてありうる仮想的なゲームでは、VBlankの開始時ではなく終了時に入力をポーリングすることがあります)。
私はこの技術を発明したと主張しているわけではありません。世の中には何千ものエミュレーターがあります。この記事を書いている時点では、他の場所でこのようなことが行われていることを知りませんし、この技術がもっと普及すれば、エミュレータの助けになると思うと述べているだけです。
また、これが特別に優れたアイデアだと主張しているわけではありません。ただ私にとってはうまくいった、むしろシンプルなアイデアというだけです。
私はこのアイデアの評価を求めていません。パブリックドメインだと思ってください。
そして何よりも、他の人のアプローチを批判しているわけではありません。
私自身のエミュレータも、長年にわたって最初のアプローチを使用していました。
最近、エミュレータ開発のコミュニティでは、入力遅延を軽減することが非常に重要視されており、私はこのことを嬉しく思っています。
RetroArchに実装されているrun-aheadと呼ばれるタイムシフトによるレイテンシー低減の記事など、このトピックについては今後も紹介していきたいと思っています。その時はお楽しみに。
エミュレーション開発は競争ではありませんし、アイデアを出し合って改善していくことは誰にでもメリットがあります。それがこの記事で私がやりたいことです。
お読みいただきありがとうございました。