NESエミュレータの画面描画
https://github.com/r7kamura/nes8 の画面が一通り動くようになりました。忘れないように、画面描画周りの実装について振り返っておこうと思います。
想定読者
NES エミュレータの画面描画についてはここでは到底書き切れないほどの知識が含まれているので、要点や、あるいは自分が気になったところだけ書くような形になります。NES エミュレータを実装してみることに興味のあるプログラマ向けに書いています。
PPU
NES では、画面に描画すべきデータが PPU (Picture Processing Unit) と呼ばれるハードウェアで計算され、テレビ用の信号として出力されます。 このハードウェアの動きを模倣した実装が https://github.com/r7kamura/nes8/blob/master/src/ts/Ppu.ts です。
CPU と PPU はそれぞれ独立して動作しますが、サイクル数は同期しており、CPU が 1 サイクル分動作するごとに PPU が 3 サイクル分動作するようになっています。そして PPU は約 341 × 262 = 89,342 サイクルで 1 フレーム(1 画面分)を描画します。
横 341、縦 262 の長方形を考え、左上から 1 サイクルごとに 1 マス処理していくようなイメージで考えると都合が良いです。このうち、2 列目から 257 列目まで、1 行目から 239 行目までを処理しているときだけ画面に描画するピクセルを書き込むことで、横 256 px × 縦 240 px の画面を更新することになります。PPU の実装では 341 サイクル区切りで考えると都合が良いので、以降サイクルは 0 から 341 の値を取るということで話を進めていきます (341 を超えたら 0 に戻る)。
各サイクルごとに何をするかが仕様的に決まっている(というか NES のハードウェアがそのように動く)ので、エミュレータ側でもそれに合わせて実装していくのが良いと思われます。画面描画については https://wiki.nesdev.com/w/index.php/PPU_scrolling をよく熟読しながら実装していくのがおすすめです。まず背景描画だけ実装して、それからスプライト描画に着手しましょう。
NMI 割り込み
ピクセルを書き込まないサイクルでは、次の書き込みのためのデータを準備したり、CPU が処理を進められるよう何もせずに待っていたりします。描画中に VRAM やレジスタの値を更新すると描画処理に影響が出てしまうため、描画に影響のある部分を更新するには、PPU が描画処理を行っていないタイミングで処理を進める必要があります。240 行目に PPU 割り込み命令が発行されることになっており、CPU 側ではこの割り込み命令を検知するとプログラム中の特定のアドレスに処理を移することになっているので、これによって上手くやりくりします。
ラスタースクロール
あえて描画中にスクロール量を CPU 側から書き換えることで、波打ったような効果を画面に与えたり、画面上の特定の部分だけスクロールさせたりという手法も用いられます。これはラスタースクロールと呼ばれる手法です。例えば奥行きのあるレースゲームで道路をうねうねと曲げるために、1行描画するたびに横方向へのスクロール量を増減させたり、スーパーマリオブラザーズ™でスコアより下の部分だけスクロールさせたりという感じですね。
レイヤー
NES の画面には、2枚のレイヤーがあります。1つは背景描画用のもので、もう1つはスプライト描画用のものです。スプライトというのは、背景ではないレイヤーに描画する図形のことです。基本的には背景レイヤーより手前にスプライトレイヤーが描画されることになりますが、描画するスプライトごとに背景より前に配置するか後ろに配置するかという情報を持つことができて、後ろには配置される場合は背景が透過色のときだけ描画されます。また、前に配置されるスプライトについても、それが透過色であれば背景が描画されます。
スプライトは 横 8 x 縦 8 ピクセルか、あるいは横 8 x 縦 16 ピクセルになります。これは PPU のレジスタのフラグで管理されます。例えばスーパーマリオブラザーズ™は 8 x 8 のスプライトが使われています。自分の実装もまだ 8 x 16 のスプライトには完璧には対応できていません。
背景描画
NES の背景レイヤーは、8 x 8 px の形状データに着色したタイルを敷き詰めることで描画します。形状データは 1 px あたり 2 bit の情報を持っており、これは 4 色パレットのうちどの色を使うかという情報を示しています。
NES の背景レイヤーでは、横 16 × 縦 16 px の領域ごとに、その領域でどの 4 色パレットを使うかという情報を割り当てられます。4 色パレットは 4 つ設定できるので、これも 1 つの領域ごとに 2 bit の情報を持ちます。
PPU が読み書きする VRAM のうち、画面上のどの部分にどの形状データを割り当てるかという情報が格納される領域をネームテーブル、画面上のどの領域でどの 4 色パレットを利用するかという情報が格納される領域を属性テーブルと呼びます。
ネームテーブル
256 × 240 px の画面に 8 × 8 px の形状データを敷き詰めるため、ネームテーブルでは (256 ÷ 8) × (240 ÷ 8) = 32 × 30 = 960 枚分の指定が必要になります。どの形状データを使うかという情報は、1 枚ごとに 8 bit の情報で表されます。PPU が読み書きする VRAM は 1 アドレスあたり 8 bit の情報を格納できるので、ネームテーブルは 960 個のアドレスでアクセス可能な領域で構成されることになります。
属性テーブル
16 × 16 px ごとに 2 bit でどの 4 色パレットを使うかを表すと書きましたが、実際には 32 × 32 px の 4 ブロック分の領域をまとめて 8 bit ずつ保存されます。下位から 2 bit ずつ、左上、右上、左下、右下というようにまとめられています。
この 32 × 32 px の正方形で 256 × 240 px の画面を埋めたいのですが、実際には 256 × 256 px を 32 × 32 px の正方形 64 枚で埋めるような形になっており、画面下部のはみ出た 256 × 16 px の分に対する 4 色パレットの指定は残念ながら使われず少し無駄になっています。
8 bit 分の情報を 64 個ということで、属性テーブルは 64 個のアドレスでアクセス可能な領域で構成されることになります。ネームテーブルの 960 個と属性テーブルの 64 個を足し合わせると、丁度 1024 個になり、2 の 10 乗でキリが良くなるように設計されています。
形状データ
ネームテーブルからデータを引くことで、どの形状データを使うかという 8 bit の情報が取得できると前述しました。具体的には、先頭から何個目の形状データを使うかで表されます。
形状データは、カートリッジのキャラクター ROM と呼ばれる領域に格納されています。エミュレータでカートリッジのデータを扱う場合、データは .NES 形式(あるいは iNES 形式とも言う)と呼ばれる形式でエンコードされるのが一般的です。.NES 形式のカートリッジデータには、先頭から、メタデータ、プログラム、キャラクターという順でデータが書き込まれています。
背景タイル用の形状データは 8 × 8 px で、1 px あたり 2 bit の情報を持ち、1 枚あたり 128 bit の情報を持ちます。実際には 1 px あたり 1 bit の情報を持つ 8 x 8 px の平面データを 2 枚重ねあわせる形で実現されており、1 枚目を下位 bit、2枚目を上位 bit として扱い、先頭 64 bit が 1 枚目、残り 64 bit が 2 枚目を表すような形になっています。
背景描画おさらい
画面上のある座標のピクセルを描画するためには、まずその座標から背景タイルのインデックスを求め、そのインデックスでネームテーブルに問い合わせます。すると形状データのインデックスが得られるので、キャラクター ROM から形状データを取得します。また別途、現在の座標から属性テーブル用のインデックスを求め、属性テーブルに問い合わせることで、どの 4 色パレットを利用するかという情報を得ます。形状データとこの情報を組み合わることで、どの 4 色パレットのどの色を使うかという、合算して 4 bit の情報が得られるため、このインデックスを元にパレットテーブルに問い合わせることで、6 bit (64 色) の色コードが得られます。エミュレータで動かす上では、この色コードを適当な RGB 形式のカラーコードにマッピングすることで、最終的に画面上のある座標のピクセルに対して適当な色を割り当てられます。
スクロール
ネームテーブルと属性テーブルを足して 1024 個と書きましたが、NES ではスクロールを考慮して、スクロール先の領域用にもう 1 セット、ネームテーブルと属性テーブルの組を用意しておくことができます。ミラーリングされた領域も含めると、全部で 4 組存在します。
例えばスーパーマリオブラザーズ™では、マリオが画面の右端に行くと、ステージ部分が右方向に少しスクロールします。このとき、内部では水平方向へのスクロール量が少し増加されています。背景を描画する際は、画面上の位置とこのスクロール量を考慮して、最終的にどのネームテーブルや属性テーブルにアクセスするかを決定します。
内部的には、15 bit のレジスタや 3 bit のレジスタを利用して、アクセスすべきアドレスが管理されています。厳密には、シフトレジスタやラッチなどが組み合わされて管理されているのですが、ここではもう少し簡易的なモデルで説明しています。
3 bit のレジスタでは、水平方向のスクロール量の内、0 から 7 px までのスクロール量を保持しています。一方 15 bit のレジスタでは、下位から 5 bit で水平方向の 8 px 単位でのスクロール量を、続く 5 bit で垂直方向の 8 px 単位でのスクロール量を、続く 2 bit で 4 つあるネームテーブルと属性テーブルのセットのうちどれを使うかを、続く 3 bit で垂直方向の 0 から 7 px までのスクロール量を保持しています。
画面左上から右下に向かって画面を描画していく際には、これらの値を少しずつ変更していきつつ、これらの値からネームテーブルや属性テーブルにアクセスするためのアドレスを算出し、描画すべきデータを決定していきます。
おわり
駆け足気味な説明になってしまいましたが、一旦以上です。実際のところはこの説明だけでは分からないと思うので、https://github.com/r7kamura/nes8 の PPU の実装と合わせながら読んでみてください。