PCゲームを普通にPC上で実行する場合、ビデオを簡単に垂直同期させたり、オーディオトラックを丸ごとメモリに入れて単独で再生したりすることができます。また、必要に応じて効果音を鳴らすこともできます。その結果、シームレスな体験ができるのです。
しかし、これがエミュレーションとなると、かなり難しくなります。PCゲームとは異なり、オーディオトラック全体を事前に知ることができないため、エミュレーションで生成されたサンプルをストリーミングする必要があるからです。
この記事では、その理由と、いくつかの解決策をご紹介します。
レトロゲーム機のエミュレーションが難しいのは、ゲームの種類の規模があまりにも大きいためです。1台のゲーム機には何千ものタイトルがありますから、少しでもタイミングがずれる、つまりエミュレーションの実行速度が速すぎたり遅すぎたりすれば、ゲームによってはエミュレータがクラッシュしまうでしょう。
一部の例外を除いて、100%の互換性を得るためには、レトロゲームのエミュレータはサイクル精度的に精確でなければなりません。
つまり、ビデオ周波数(1秒間にレンダリングされるフレーム数)とオーディオ周波数(1秒間に再生されるサンプル数)が、(少なくともエミュレータが観測できる範囲で)オリジナルのハードウェアと一致する必要があります。
エミュレータがティアリングやスタッタリングのない滑らかな映像を生成し、音飛びやノイズ音のない滑らかなオーディオを生成するためには、周波数の比率が静的なものであれば完全に一致していなければなりません。残念ながら、実機の場合、周波数の比率は後述のように動的に変化します。
ティアリング: 1枚の画像の中に複数フレームの画像が描画されてしまい、映像が途中で左右にずれたように見える現象のことです。
スタッタリング: 俗に言う「カクツキ」のことです。映像が途中でカクッカクッと一瞬止まったようになる現象です。
しかし、これらの周波数は単純な数字ではありません。よく技術資料の中に、「スーパーファミコンはNTSC方式で映像のリフレッシュレートが60Hz、音声のサンプリングレートが32KHz」などと記載されているものがあります。しかし、それは正確ではありません。
映像や音声の周波数は、その土台となる発振器の周波数に基づいています。発振器には、水晶時計やセラミック発振子などがあります。その違いについては後ほど掘り下げて説明します。
(NTSC版の)SNESのCPU周波数は (315/88)*6000000 Hz
、つまり 約21.477MHz
です。
SNESのAPUの周波数は 32000*768 Hz
。つまり 約24.576MHz
です。
CPUの周波数は、SNESのPPUがNTSCのブラウン管モニターに画像を表示するために必要な、NTSCカラーサブキャリアを基準としています。
スーファミの1スキャンラインは1364クロックサイクルで、1フレームは262スキャンラインです。
したがって、スーファミのビデオリフレッシュレートは次のように導き出されます。(((315/88)*6000000)/1364)/262 Hz
つまり 60.098477561Hz
です。
ここでは簡単にするために、NTSCのカラーサブキャリアシフトのための4Hzの欠落期間に関する1つの注意事項は無視します。
スーファミの1サンプルは768クロックサイクルかかるので 24576000/768 Hz
、つまり32000 Hz
です。
スーファミのリフレッシュレートだけは、それほど単純ではありません。インターレースを有効にすると、奇数フレームに余分なスキャンラインが挿入され、2フィールドに525本のスキャンラインが表示されます。
つまり、ビデオリフレッシュレートは ((((315/88)*6000000)/1364)/525)*2 Hz
、つまり 59.9840042665 Hz
となります。
つまり、2つのリフレッシュレートを気にしなければならないのかと思われるかもしれませんが、残念ながら現実はもっと複雑です。
ゲームがインターレースのオンとオフを同じ時間内に常に切り替えることは、理由がない限り止められません。これにより、有効なビデオ周波数は59.98Hz
から60.09Hz
の範囲で動的に変化しうるということになります。
残念なことに、電気工学の世界では、完璧な回路というものは存在しないのが現実です。すべては不正確さという許容範囲内を持って成り立っています。
スーファミのCPUが使用しているような水晶振動子の場合、わずかな許容範囲があります。そのため、上記のように正確に21.477MHz
になるのではなく、実際の周波数は変動します。
発振器の経年変化や製造工程、さらには現在の温度によっても、発振器の周波数は微妙に変化します。ファミコン本体が動いていても、オシロをつないで出力周波数をモニターすると、時間とともに微妙に変動しているのがみて取れるでしょう。
スーファミのAPUの発振器はセラミック発振子です。これは一般的に安価であると言われていますが、その分、精度は水晶より劣ります。つまり、ばらつきが大きいのです。
実際には、スーファミのAPUの発振器は、32040*768Hz(24.607MHz)
に近いという観測結果があります。
スーファミの周波数が正確ではないということは、PCの周波数も正確ではないということです。
PCのモニターを60Hzに設定し、オーディオ出力を48KHzに設定しても、それは正確には得られません。60.1Hzと48.03KHzの間に近い周波数が得られるかもしれません。
また、仮に測定したとしても、時間の経過とともに変動してしまいます。
システムをエミュレートする場合、一般的には、エミュレートされたシステムを元のハードウェアよりもはるかに速く動かすことができます。なので、どうにかして速度を制限しなければなりません。
1つの方法は、エミュレートされたシステムとホストシステムのビデオリフレッシュレートがほぼ同じであることに頼ることです。
スーファミが60.09Hz、PCモニターが60Hzの場合、モニターのVBlank期間に同期させることで、スクロール部分でもティアリングやスタッタリングが発生せず、常にスムーズな映像を提供することができるのです。
エミュレートされたシステムは、実際には0.15%遅い動作となりますが、このようなわずかな違いが観測されることはほとんどありません。
ビデオサンプルとオーディオサンプルの比率が正確でない場合、次の2つのうちどちらかが起こります。
ビデオの比率が高すぎると、オーディオバッファは空になるまでゆっくりと減っていきます。サウンドカードは、再生するためのオーディオサンプルを必要としますが、何も利用できません。
使用しているオーディオAPIによっては、オーディオバッファがサンプルバッファの先頭にループバックするか(DirectSoundなど)、サウンドドライバがサウンドカードに無音を送信するか(XAudio2など)のどちらかになります。後者の方が望ましいのですが、結果は同じで、オーディオがポップしてひどい音になります。これが起こると、バッファは回復し、音は再びクリアになります。しかし、それは一時的なものです。
スーファミのリフレッシュレートとPCのリフレッシュレートが 60.09Hz と 60.00Hz でずれていると、だいたい10秒に1回、音声が飛ぶことになります。
ビデオの比率が低すぎると、音声バッファが完全に埋まってしまいます。オーディオバッファが大きければ大きいほど、画面に表示されるものとスピーカーから聞こえるものとの間に、より多くの入力ラグが生じることになるからです。そのため、バッファの前の部分を書き換えるか、サンプルを完全に削除するかのどちらかを迫られます。後者の方が若干良いのですが、やはりその都度、大きな音飛びやノイズが発生します。
例えば、スーファミの音声が約32KHzで、PCのサウンドカードのネイティブレートが約48KHzだとします。この音声を単純なリサンプラーに通すと、2つの入力サンプルに対して3つの出力サンプル、つまり3:2の比率で生成することができます。
エミュレーションを実行して、オーディオバッファを満たします。オーディオバッファが一杯になると、エミュレーションを停止し、スペースが空くまで待ってオーディオサンプルを追加します。
この結果、オーディオは完全にクリアになりましたが、今度は逆にビデオに問題が発生します。
オーディオの比率が高すぎると、VBlank期間が発生しますが、表示するフレームがありません。アクティブな表示中にもフレームをレンダリングすると、画面の下にティアリングバーが這うように表示されてしまいます。逆に、そのフレームをスキップすると、スクロールシーンで顕著なスタッタリングが発生します。
オーディオ比率が低すぎると、次のVBlank期間に2つのフレームが用意されることになり、やはり早めに描画するか、フレームを落とすかしなければならず、結果としてティアリングやスタッタリングが発生します。
今までの問題点を見て、両方に同期させようと思うかもしれません。
しかし、そうすると両方の問題を解決するどころか結果的に両方の問題を引き起こすことになります。
ビデオはスタッタリングやティアリングが発生し、オーディオは音飛びやノイズが発生します。これは最悪の事態です。さらにビデオとオーディオのどちらかがエミュレーションをブロックすると、もう片方もストールしてしまいます。
効果的な手法のひとつに、SRC(Static Rate Control)があります。このモードでは、ビデオとオーディオの両方に実際に同期することが可能です。
エミュレートされたシステムとホストシステムのビデオとオーディオの両方の正確な発振器の周波数は変動することがわかっており、理想的なリサンプリングの比率を決定することはできません。しかし、ユーザーにスライダー(微調整用のUI)を提供することで、比率を微調整することができます。
例えば、エミュレータのオーディオリサンプラのスライダーで、オーディオの比率を1.9:3
から2.1:3
まで調整できるようにしたとします。
このスライダーを動かすと、一方では映像の乱れが生じ、他方では音声の乱れが生じることになります。しかし、慎重に中間点を狙うことで、ごくまれにしかスタッターが発生しない状況にすることができます。
練習を重ねた結果、この方法では、10分程度の映像と音声の完全な同期を得られる代わりに、生じたスタッタリングは20msとごくわずかでした。
しかし、これでもまだ完璧ではありませんし、エミュレータを使用しているエンドユーザーに上記のことを説明するのは非常に困難です。
しかし、オーディオのリサンプリング比率をリアルタイムに調整できるとしたらどうでしょうか? そう、それがDRC(Dynamic Rate Control)の要点です。
このモードでは、映像にのみ同期します。DRCの目的は、オーディオバッファの容量を常に半分程度に保つことです。
オーディオAPIを利用して、バッファに残っているサンプル数や、逆にバッファに残っている空き容量を問い合わせることで、いくつかのサンプルが出力されるたびにバッファの状態を確認することができます。
バッファが空になってきたら、オーディオの比率を下げて、より多くのサンプルが生成されるようにすると、バッファが再び満たされてきます。しかし、これではどうしても行き過ぎてしまい、バッファが半分以上になってしまいます。そこで、逆に比率を上げてバッファが減り始めるように補正します。
このようにオーディオリサンプラの比率を常に微調整することで、バッファが満杯にならず、空にならない状態を維持しています。
上記のように、スーファミが60.09Hzで動作するように設定されていて、PCモニターが60Hzで動作している場合、エミュレーションの動作が0.15%遅くなりますが、少なくとも映像と音声は常に完全に同期させることができます。
オーディオのリサンプリング比を調整することで、実際にピッチを変化させます。
そのため、1つのステップでピッチを調整しすぎないようにすることが非常に重要で、そうしないとオーディオは非常に不愉快な音声を出力することになります。
DRCは、理解するのに難しい概念ではありませんが、実装するのは少し難しいので、これからいくつかのコードを使って説明します。
ここではコードを若干省略していますが、完全なソースコードは私のGitHubリポジトリでご覧いただけます。
この記事のコードはパブリックドメインと考えてください。このコードは、浮動小数点の入力サンプルを想定しており、16bitの符号付き出力サンプルを生成しますが、これは私のエミュレータでの設計上の選択です。あなたのニーズに合わせてコードを変更してください。
まず必要なのは、入力と出力の両方の周波数を受け持つリサンプラで、前に話した 2:3のリサンプリング比を実現するためのものです。
オーディオDSPについては後ほど詳しく説明しますが、ここではシンプルな3次関数を使ったリサンプラを紹介します。このコードはどちらかというと標準的な補間ですので、ここではその動作を詳しく説明することはあまり重要ではありません。
auto Cubic::reset(double inputFrequency, double outputFrequency, uint queueSize) -> void {
this->inputFrequency = inputFrequency;
this->outputFrequency = outputFrequency ? outputFrequency : this->inputFrequency;
ratio = inputFrequency / outputFrequency;
fraction = 0.0;
for(auto& sample : history) sample = 0.0;
samples.resize(queueSize ? queueSize : this->outputFrequency * 0.02); // default to 20ms max queue size
}
auto Cubic::setInputFrequency(double inputFrequency) -> void {
this->inputFrequency = inputFrequency;
ratio = inputFrequency / outputFrequency;
}
auto Cubic::pending() const -> bool {
return samples.pending();
}
auto Cubic::read() -> double {
return samples.read();
}
auto Cubic::write(double sample) -> void {
auto& mu = fraction;
auto& s = history;
s[0] = s[1];
s[1] = s[2];
s[2] = s[3];
s[3] = sample;
while(mu <= 1.0) {
double A = s[3] - s[2] - s[0] + s[1];
double B = s[0] - s[1] - A;
double C = s[2] - s[0];
double D = s[1];
samples.write(A * mu * mu * mu + B * mu * mu + C * mu + D);
mu += ratio;
}
mu -= 1.0;
}
ここでは、いくつかのオーディオAPI(waveOut、DirectSound、OSSなど)をサポートしたコードを紹介したいと考えています。
リサンプリング比率を制御する基本的な方法は、ベースクラスに抽象化することができます。まずはそのベースクラスについてみていきましょう。
auto Audio::output(const double samples[]) -> void {
if(!instance->dynamic) return instance->output(samples);
auto maxDelta = 0.005;
double fillLevel = instance->level();
double dynamicFrequency = ((1.0 - maxDelta) + 2.0 * fillLevel * maxDelta) * instance->frequency;
for(auto& resampler : resamplers) {
resampler.setInputFrequency(dynamicFrequency);
resampler.write(*samples++);
}
while(resamplers.first().pending()) {
double samples[instance->channels];
for(uint n : range(instance->channels)) samples[n] = resamplers[n].read();
instance->output(samples);
}
}
ここでは,エミュレーション内で生成されたフレーム(サンプルのペア)ごとに output()
を呼び出しています. maxDelta
はピッチの最大歪みを制御します.
instance->level()
を呼び出すことで、任意のオーディオドライバの現在のバッファの埋まり具合を問い合わせることができます。また、instance->output()
を呼び出すと、実際にサンプルがドライバーに送られ、スピーカーに出力されます。
OSSは、*nixシステム用の伝統的なオーディオインターフェイスです。Linuxではどちらかというと非推奨ですが、エミュレーションレイヤを介して利用できます。また、BSDでもよく動作します。
auto AudioOSS::level() -> double override {
audio_buf_info info;
ioctl(_fd, SNDCTL_DSP_GETOSPACE, &info);
return (double)(_bufferSize - info.bytes) / _bufferSize;
}
auto AudioOSS::output(const double samples[]) -> void override {
for(uint n : range(self.channels)) {
buffer.write(sclamp<16>(samples[n] * 32767.0));
if(buffer.full()) {
write(_fd, buffer.data(), buffer.capacity<uint8_t>());
buffer.flush();
}
}
}
ここでは,SNDCTL_DSP_GETOSPACE
により,バッファのフィルポジションを決定します。
waveOutは、おそらくWindows上で最も古いオーディオAPIですが、プラットフォーム上のどのAPIよりも信頼性の高いバッファポーリングを行うことができます。
auto CALLBACK waveOutCallback(HWAVEOUT handle, UINT message, DWORD_PTR userData, DWORD_PTR, DWORD_PTR) -> void {
auto instance = (AudioWaveOut*)userData;
if(instance->blockQueue > 0) InterlockedDecrement(&instance->blockQueue);
}
auto AudioWaveOut::level() -> double override {
return (double)((blockQueue * frameCount) + frameIndex) / (blockCount * frameCount);
}
auto AudioWaveOut::output(const double samples[]) -> void override {
uint16_t lsample = sclamp<16>(samples[0] * 32767.0); //ensure value is between -32768 and +32767
uint16_t rsample = sclamp<16>(samples[1] * 32767.0);
auto block = (uint32_t*)headers[blockIndex].lpData;
block[frameIndex] = lsample << 0 | rsample << 16;
if(++frameIndex >= frameCount) {
frameIndex = 0;
while(waveOutWrite(handle, &headers[blockIndex], sizeof(WAVEHDR)) == WAVERR_STILLPLAYING);
InterlockedIncrement(&blockQueue);
if(++blockIndex >= blockCount) {
blockIndex = 0;
}
}
}
waveOutのバッファフィルレベルを決定するには、バッファのキューイングシステムを利用します。いくつかの小さなブロックに相当するオーディオをプッシュすると、キューにまだいくつのブロックが残っているかを読み取ることができます。これはマルチスレッドなので、InterlockedIncrement/Decrement
を使用する必要がありますが、難しいことではありません。
個々のサンプルではなくブロック全体をクエリするのはあまり正確ではないので、大きなブロックではなく小さなブロックをたくさん作ることで補っています。
正確な比率は、正直なところ実験によります。私の場合、DRCがうまく機能するには、32個の小さなブロックが十分なキューサイズであると感じています。
実は、さらに優れた技術があります。あるシステムをオリジナルの速度でエミュレートすることができ、レートコントロールを一切必要としない素晴らしいものです。
これはAdaptive Syncと呼ばれるもので、ビデオカードが一定の比率でフレームを出力する代わりに、エミュレータが画面を更新するタイミングをコントロールするものです。
Adaptive Syncモニターを持っている人は少なく、60Hz以上のモニターを持っている人はさらに少なく(75hzのBandai WonderSwanのようなシステムには必要です)、この技術はウィンドウモードではうまく動作しません(動作させることはできますが)。また、非常に低レイテンシのオーディオドライバ(WASAPI、JACKなど)を必要としますが、どこでも動作させることは非常に困難です。
しかし、これには1つの利点があります。DRCは、エミュレートされたシステムとホストシステムのビデオのリフレッシュレートがほぼ同じである場合にのみ機能し、特にウィンドウモードでは、エミュレーターがPCモニターのリフレッシュレートを変更することは現実的ではありません。
しかし、この記事はDRCに関するものです。Adaptive Syncについては、次回以降に詳しくご紹介します。
最後に言いたいのは、エミュレータの開発者は、すべてのベースをカバーするために、DRCとAdaptive Syncの両方を実装するように努力すべきだということです。