NES 基礎知識 - PPU

今回は PPU について扱います…が、正直難しいのでゲーム内のロジックを調べたいだけなら完全に理解する必要はないと思います(私もきちんと理解してません)。完全に理解したければ NesDevWiki の PPU の項目などを参照。

NTSC を前提とします。PAL などはよくわからないので扱えません。

まず、PPU と CPU (と、APU)は並列に動作しています。3 PPU サイクル = 1 CPU サイクルです。

NES の解像度は 256*240 *1ですが、内部的には 341*262 とみなすことができ、1 PPU サイクルで 1 dot 処理されます。そして、341*262 = 89342 PPU サイクルが 1 フレームとなります(厳密には偶奇フレームで 1 PPU サイクル変動します)。

341 dot の水平線 1 本を「スキャンライン」または単に「ライン」と呼びます。各ラインは以下のように分類されます:

  • line 0-239: visible scanline と呼ばれます。この期間内に 256*240 の描画が行われます。描画と干渉するので、CPU は PPU メモリにアクセスすべきではありません(描画オフでない限り)。
  • line 240: post-render scanline と呼ばれます。この期間は PPU はアイドル状態です。
  • line 241-260: vertical blanking line と呼ばれます(いわゆる VBLANK)。line 241 で VBLANK フラグが立ち、NMI 割り込みが発生します。CPU による PPU メモリへのアクセスはこの期間に行うべきです。サイクル計算すると 341*20 = 6820 PPU サイクルなので、換算すると約 2273 CPU サイクルの猶予があるということになります。
  • line 261: pre-render scanline と呼ばれます。ここで VBLANK フラグが下ろされます。描画は行いませんが、line 0 の描画準備を行うため、CPU は PPU メモリにアクセスすべきではありません(描画オフでない限り)。

各ラインの各ドットで何が起こるのかについては、NesDevWiki の PPU rendering の項目や Ntsc timing.png の図を参照。

エミュレータの場合、フレーム境界は line 240 あたりに設定するのが妥当と思われます(入力処理の関係)。FCEUX もそうなっています。

PPU の描画要素は BG とスプライトに分けられます。BG は 8*8 px のタイルを敷き詰めた画像で、スクロールはできますが、タイル境界という制約があります。スプライトは 8*8 px または 8*16 px の画像で、画面上の好きな位置に表示できますが、最大 64 個しか持てず、さらに横方向の最大表示数は 8 個までという制約があります。大抵のエミュレータでは BG / スプライトの描画を個別にオン/オフできるようになっています。

メモリマップ

PPU アドレス空間は 14bit, 即ち $0000-$3FFF です。レイアウトは以下の通り:

アドレス 内容
$0000-$0FFF ターンテーブル 0
$1000-$1FFF ターンテーブル 1
$2000-$23FF ネームテーブル 0
$2400-$27FF ネームテーブル 1
$2800-$2BFF ネームテーブル 2
$2C00-$2FFF ネームテーブル 3
$3000-$3EFF 通常は $2000-$2EFF ミラー
$3F00-$3F1F パレット
$3F20-$3FFF $3F00-$3F1F ミラー

$0000-$3EFF はカートリッジ側で自由に構成できます。ただし $3000-$3EFF は PPU の描画には使われないため、通常は $2000-$2EFF のミラーとなっています。よって、カートリッジ側では普通 $0000-$2FFF を構成します。

ターンテーブルは BG / スプライトのタイルパターンを格納します。ここは通常 CHR-ROM または CHR-RAM であり、マッパーによってはバンク切り替えで動的に内容が差し替えられます。

ネームテーブルは BG を構成するバッファです。ここは多くのマッパーでは NES の内蔵 VRAM (2KB) を利用するようになっていますが、カートリッジ側で用意した VRAM をマップすることも可能です。

ターンテーブル

ターンテーブルは $0000-$0FFF, $1000-$1FFF の 2 つあります。

各パターンテーブルは 8*8 px のタイルパターンを 256 個格納しています。タイルパターンは 2bpp なので、1 つのタイルパターンは 16 バイトであり、2 つのプレーンから構成されます。1 つ目のプレーンは色の bit0, 2 つ目のプレーンは bit1 を表します。1 つのタイルを例示します:

Bit Planes            Pixel Pattern
$0xx0=$41  01000001
$0xx1=$C2  11000010
$0xx2=$44  01000100
$0xx3=$48  01001000
$0xx4=$10  00010000
$0xx5=$20  00100000         .1.....3
$0xx6=$40  01000000         11....3.
$0xx7=$80  10000000  =====  .1...3..
                            .1..3...
$0xx8=$01  00000001  =====  ...3.22.
$0xx9=$02  00000010         ..3....2
$0xxA=$04  00000100         .3....2.
$0xxB=$08  00001000         3....222
$0xxC=$16  00010110
$0xxD=$21  00100001
$0xxE=$42  01000010
$0xxF=$87  10000111

※https://wiki.nesdev.com/w/index.php/PPU_pattern_tables より転載

マッパーによってはここに CHR-RAM が割り当てられていたり、バンク切り替え機能があったりします。

BG

BG は 4 つのネームテーブルによって構成されます。各ネームテーブルは 32*30 = 960 個のタイル ID、および 64 バイトの属性テーブルを格納しています。1 タイルは 8*8 px なので、1 つのネームテーブルで 256*240 px を表現できます。

各ネームテーブルと PPU アドレスとの対応関係を図示します(上下/左右はループしています):

     (0,0)     (256,0)     (511,0)
       +-----------+-----------+
       |           |           |
       |           |           |
       |   $2000   |   $2400   |
       |           |           |
       |           |           |
(0,240)+-----------+-----------+(511,240)
       |           |           |
       |           |           |
       |   $2800   |   $2C00   |
       |           |           |
       |           |           |
       +-----------+-----------+
     (0,479)   (256,479)   (511,479)

※https://wiki.nesdev.com/w/index.php/PPU_nametables より転載

ただし、NES の内蔵 VRAM を利用する場合、容量が 2KB しかないため、実際には 2 つのネームテーブルしか使えず、残り 2 つはミラーとなります。多くのマッパーでは以下のいずれかのミラーリングを選択します:

  • Horizontal mirroring - $2000 == $2400, $2800 == $2C00 となる。縦スクロールのゲームは多分これ。
  • Vertical mirroring - $2000 == $2800, $2400 == $2C00 となる。横スクロールのゲームは多分これ。

しかし、このアドレス領域はカートリッジ側で(内蔵 VRAM を利用するかどうかによらず)自由に構成できるので、他の構成もありえます。詳細は NesDevWiki の Mirroring の項目などを参照。

属性テーブルは 2*2 タイル (16*16 px) 単位でパレットを指定するものです。詳細は NesDevWiki の PPU attribute tables の項目を参照。

PPU のスクロール機能により、上に示した 512*480 の画像の中から 256*240 の範囲を BG として表示できます。また、描画中にスクロールオフセットを変更することで部分的なスクロールも可能です(いわゆるラスタスクロール。ただし横ラスタ限定)。

スプライト

スプライトは PPU 内部の OAM (Object Attribute Memory, 256 バイト) で管理されます。OAM はスプライト構造体 64 個の配列です。スプライト構造体は 4 バイトで、内容は以下の通り:

struct Sprite{
    U8 y;
    U8 tile;
    U8 attr;
    U8 x;
};

Sprite.y

座標 y です。ただしスプライトは 1 ライン遅れて表示されるため、実際には +1 された位置に表示されます。よって、y=0 の位置にスプライトを表示することはできず、画面上端からはみ出して表示することもできません。NES の解像度は縦 240 ラインなので、この値を 239 以上にするとスプライトは表示されません(プログラム上では大抵この方法で非表示にしています)。

Sprite.tile

タイル ID 指定です。スプライトサイズによって値の意味が異なります。

スプライトサイズ 8*8 の場合、タイル ID そのものです(パターンテーブルレジスタ $2000 で選択します)。

スプライトサイズ 8*16 の場合、値の意味は以下のようになります(レジスタ $2000 によるパターンテーブル選択は無視されます):

bit 76543210
    TTTTTTTP

P: パターンテーブル選択。0:$0000, 1:$1000
T: スプライト上半分のタイル ID を 2*T とし、下半分を 2*T+1 とする

Sprite.attr

bit 76543210
    VHP...CC

V: 垂直反転
H: 水平反転
P: 優先度 (0:前面, 1:背面)
C: パレット

Sprite.x

座標 x です。画面左端からはみ出して表示することはできません。ただし、レジスタ $2001 で画面左端クリッピングを設定することで擬似的に同じ効果が得られます。

OAM への書き込みには大抵レジスタ $4014 による DMA 転送が用いられます。

0 番スプライトはタイミング制御に使うことができます(いわゆる「0 爆弾」)。BG 描画中に 0 番スプライトと衝突が起こると内部でフラグが立ち、レジスタ $2002 を通じてこれを検出できます。詳細は NesDevWiki の PPU OAM#Sprite_zero_hits などを参照。

パレット

パレットは PPU 内部の専用 RAM で管理されており、PPU アドレス $3F00-$3F1F にマップされています。レイアウトは以下の通り:

アドレス 内容
$3F00 背景色
$3F01-$3F03 BG パレット 0
$3F04 空き領域
$3F05-$3F07 BG パレット 1
$3F08 空き領域
$3F09-$3F0B BG パレット 2
$3F0C 空き領域
$3F0D-$3F0F BG パレット 3
$3F10 $3F00 ミラー
$3F11-$3F13 スプライトパレット 0
$3F14 $3F04 ミラー
$3F15-$3F17 スプライトパレット 1
$3F18 $3F08 ミラー
$3F19-$3F1B スプライトパレット 2
$3F1C $3F0C ミラー
$3F1D-$3F1F スプライトパレット 3

$3F10 が $3F00 のミラーであることには注意が必要です。配列コピーで PPU $3F00-$3F1F を埋めるようなコードはよくありますが、このとき $3F10 に $3F00 と違う色を書き込むと背景色が変わってしまうことになります。

パレットには NES の色 ID が格納されます。色 ID は 6bit で、明度 2bit / 色相 4bit の形式です:

bit 76543210
    ..VVHHHH

V: 明度
H: 色相

※上位 2bit は書き込んでも無効で、読み取りは常に 0 を返す

具体的な色見本は NesDevWiki の PPU palettes#2C02 などを参照。なお、色 ID 0x0D は TV によっては問題が起こるため、使うべきではないそうです。

I/O レジスタ

$2000 - PPU_CTRL (write)

bit 76543210
    VPHBSINN

V: VBLANK 開始時に NMI 割り込みを発生 (0:off, 1:on)
P: PPU マスター/スレーブ (コードを読む分には気にする必要なし)
H: スプライトサイズ (0:8*8, 1:8*16)
B: BG パターンテーブル (0:$0000, 1:$1000)
S: スプライトパターンテーブル (0:$0000, 1:$1000)
I: PPU アドレスインクリメント (0:+1, 1:+32) - VRAM 上で +1 は横方向、+32 は縦方向
N: ネームテーブル (0:$2000, 1:$2400, 2:$2800, 3:$2C00)

PPU 内部の VBLANK フラグが立っている状態で NMI を許可すると直ちに NMI 割り込みが発生します。これは既に NMI 割り込みが発生していても同様なので注意が必要です。例えば以下のようなシナリオが考えられます:

  • NMI 割り込みが発生
  • NMI ハンドラ先頭で NMI を禁止
  • VBLANK フラグが下ろされる前に NMI ハンドラの処理本体が終了
  • NMI ハンドラから抜ける前に NMI を許可 <- ここで NMI が再生成される

NMI を許可する前に $2002 を読み取って VBLANK フラグを下ろすことでこの問題を回避できます。

ネームテーブル選択はスクロール指定の一部と考えることもできます。

大抵のゲームでは $2000 の値を退避する変数を設けています(NMI 許可状態など、一部のみを変更したいというケースがあるため)。

$2001 - PPU_MASK (write)

bit 76543210
    BGRsbMmG

B: 色強調(青) - 表示のみに影響すると思います。詳しくは知りません^^;
G: 色強調(緑) - 同上
R: 色強調(赤) - 同上
s: スプライト描画 (0:off, 1:on)
b: BG 描画 (0:off, 1:on)
M: 画面左端 8px でスプライトクリッピング (0:有効, 1:無効)
m: 画面左端 8px で BG クリッピング (0:有効, 1:無効)
G: 0:カラー, 1:モノクロ

大抵のゲームでは $2001 の値を退避する変数を設けています(描画オン/オフなど、一部のみを変更したいケースがあるため)。

$2002 - PPU_STATUS (read)

PPU の状態を取得できます。また、このレジスタの読み取りには副作用があります。まず値の意味を示します:

bit 76543210
    VSO.....

V: VBLANK フラグ
S: Sprite 0 hit
O: スプライトオーバーフローフラグだがバグがある。気にする必要なし

全てのビットは line 261 dot 1 でクリアされる。

このレジスタを読み取ると直ちに VBLANK フラグが下ろされます。また、$2005 / $2006 の書き込み状態がリセットされます。

このレジスタに関して興味があるのは bit7, bit6 だけなので、読み取る際は BIT 命令を使うと CPU レジスタを破壊せず、and 命令も省略できてスマートです。VBLANK および Sprite 0 hit を待つコード例を示します:

wait_vblank:
        bit $2002
        bpl wait_vblank
; line 261 でクリアされるのを待ってから次の Sprite 0 hit を待つ
wait_sprite0clear:
        bit $2002
        bvs wait_sprite0clear
wait_sprite0hit:
        bit $2002
        bvc wait_sprite0hit

これは読み取り専用レジスタですが、まれに書き込みを行っているゲームがあります(『アルカノイド』など)。おそらく書き込みは何の効果もないと思われます。

$2003 - OAM_ADDR (write)

読み書きする OAM アドレスを指定します。しかし普通は OAM へのデータ転送には DMA を利用するので、このアドレスには単に 0 を書き込み、直後に $4014 へ書き込んで DMA 転送を行うゲームが多いです。

なお、DMA 転送の前にこのアドレスに書き込む値を毎フレーム変えているゲームもあります。これはスプライト優先順位を循環させることで、スプライトが 9 個以上横に並んだとき完全に見えなくなるのを防止する意図と思われますが、NesDevWiki によると 非推奨 のテクニックとのことです。

$2004 - OAM_DATA (read/write)

OAM 読み書き用ですが、普通は DMA でデータ転送するので気にする必要はありません。

$2005 - PPU_SCROLL (write * 2)

BG のスクロールオフセットを指定します。これは 2 度書きレジスタで、1 回目の書き込みで x, 2 回目の書き込みで y を指定します。

書き込み状態(1 回目 / 2 回目)は内部的に $2006 と共有する形で記憶されていますが、$2002 を読み取ることで状態をリセットできます。

大抵のゲームではスクロールオフセット x, y を退避する変数を設けています。これは、$2005 と $2006 は内部的に状態を共有しているため、$2006 へ書き込んだ後は $2005 の再設定が必要になるという理由が大きいと思われます。

$2006 - PPU_ADDR (write * 2)

読み書きする PPU アドレスを指定します。これは 2 度書きレジスタで、1 回目の書き込みで上位バイト、2 回目の書き込みで下位バイトを指定します。

書き込み状態(1 回目 / 2 回目)は内部的に $2005 と共有する形で記憶されていますが、$2002 を読み取ることで状態をリセットできます。

$2005 と $2006 は内部的に状態を共有しているので、$2006 へ書き込んだ後は $2005 の再設定が必要です。

$2007 - PPU_DATA (read/write)

PPU アドレス読み書き用です。$2006 で指定したアドレスに対して読み書きを行った後、内部 PPU アドレスが $2000 に従ってインクリメントされます(+1 または +32)。前述の通り、描画中に読み書きを行うべきではありません。

大抵は書き込み用にのみ使われますが、たまに CHR-ROM 領域にデータを格納しているゲームがあり、その場合は読み取りにも使われます。ただし読み取りにおいては内部バッファが 1 回遅れで更新されるため、最初に 1 回空読みを入れる必要があります(厳密には PPU $0000-$3EFF に対してのみ空読みが必要。詳細は NesDevWiki の解説を参照)。

$4014 - OAM_DMA (write)

OAM への DMA 転送を行います。書き込んだ値が転送元ページとなります。例えば 2 を書き込むと CPU アドレス $0200-$02FF の内容を OAM へ転送します。

DMA 転送は $2003 で指定された OAM アドレスに対して行われるので、事前に $2003 に 0 を書き込んでおくのが普通です。一応コードを示すと:

lda #0
sta $2003
lda #2
sta $4014

DMA 転送には 513 または 514 CPU サイクルかかります(どちらになるかは CPU サイクルの偶奇によるとのことです)。

*1:256*240 全体が映るわけではなく、TV により端が幾分欠ける(いわゆるオーバースキャン領域)。よって、画面端ギリギリには重要な情報を表示しないのが不文律となっている