第2章
背景にタイルを配置
2.1 GBの画面描画方式
GBの画面を制御するLCDC(LCDコントローラ)は、「タイル」という単位で画面を描画します。
「タイル」は8x8のビットマップで、専用のメモリ領域に定義します。タイルには連番で番号が対応付いており、例えば背景に何かを描画する際は、「N番のタイルを配置する」というようにタイル番号を指定します。
タイルについて
タイルを定義するメモリ空間は、設定により2つあるのですが、本書では0x8000から0x8FFFの領域を使用します*1。
[*1] もう片方は0x8800から0x97FFです。タイルを配置する領域としてどちらを使うかで使い方が変わるのですが、本書では0x8000から0x8FFFを使う場合についてのみ紹介します。
ゲームボーイのタイルの各ピクセルは2ビットで、2 * 8 * 8 = 128ビット(16バイト)で1つのタイルを表します。2ビットで白黒なので、表現できる色は、黒・濃いグレー・薄いグレー・白の4色です。
パレットについて
タイルの各2ビットを黒・濃いグレー・薄いグレー・白のどれと対応付けるかを決めるのが「パレット」という仕組みです。
パレットは8ビットのレジスタで、背景用に1つ(BackGround Palette:BGP)と、本書では扱いませんがオブジェクト用に2つ(Object Palette 0,1:OBP0,OBP1)の計3つあります。
パレットの各ビットの役割は表2.1の通りです。
ビット | 役割 |
---|---|
7-6 | 色番号3の色指定 |
5-4 | 色番号2の色指定 |
3-2 | 色番号1の色指定 |
1-0 | 色番号0の色指定 |
色番号0から3の全4つの色番号に何色を対応付けるかを、各2ビットずつの色指定で指定します。
そして、色指定の2ビットと色との対応が表2.2の通りです。
11 | 黒 |
---|---|
10 | 濃いグレー |
01 | 薄いグレー |
00 | 白 |
そのため、BGPへ0xE4を設定すれば、タイルを背景として描画する際、タイルの各ピクセル(2ビット)を、「11は黒」・「10は濃いグレー」・「01は薄いグレー」・「00は白」と表示できます。
2.2 タイルを作ってみる
では、実際にタイルを定義してみましょう。
と言っても、1タイル16バイトの中に8x8の各ピクセル(2ビット)がどのように並ぶのかというと、それも少し特殊です*2。
[*2] 詳しくは本書末尾の「参考にさせてもらった情報」に記載しているリンク先などを参照してみてください。
16バイトのタイルデータも、自動生成するスクリプトを用意しているので、本書ではそれを使います。
使用するスクリプトは、サンプルのtools
ディレクトリ内にあるtxt22bpp
です。「2bpp」は「2 Bits Per Pixel」の略で、GBのピクセルフォーマットをこのように呼んだりします。
txt22bpp
は、8x8のビットマップをテキストで表現したテキストファイルを与えると、2bpp形式のタイルバイナリデータへ変換します。
例えばリスト2.1のようなファイルを引数に与えることができます。
txt22bpp
は、'#'を色番号3へ、'*'を色番号2へ、'.'を色番号1へ、' 'を色番号0へ変換します02_about_txt22bpp_case
。
[*3] txt22bpp自身もシェルスクリプトです。変換する文字のパターンを変更したい場合は、txt22bpp内のcase文の箇所を書き換えてみてください。
リスト2.1をtile.txt
というファイル名で保存したとすると、以下のようにタイルデータを生成できます。
$ tools/txt22bpp tile.txt tile.2bpp $ ls tile.2bpp tile.2bpp $
生成されたtile.2bpp
が2bpp形式のタイルデータバイナリです。
2.3 タイルをロードしてみる
タイルはメモリ上0x8000以降のタイルデータ領域へロードすることで使えるようになります。この領域は先頭から16バイト毎にタイル番号が割り振られていきます。(0x8000以降の16バイトの領域にロードしたタイルデータはタイル番号0番、0x8010以降の16バイトの領域にロードしたタイルデータはタイル番号1番、という具合です。)
ここでは、0x8000以降の16バイトの領域に先程生成したタイルデータをロードしてタイル番号0番として使えるようにしてみます。
VRAMへアクセスするには
タイルデータ領域や、背景用のタイルマップ領域、オブジェクトを定義する領域などのVRAMの領域は、画面描画を行うLCDCもアクセスします。LCDがアクセスしている間、CPUはVRAMへアクセスしてはいけないことになっています。
そのため、VRAMを読み書きする際は以下のいずれかの対応を行う必要があります。
- LCDを止める
- 止めている間、VRAMへアクセスし放題
- ただし、画面が消える(真っ白になる)
- 止める操作はVブランク期間に行う必要がある
- LCDがアクセスしていない期間にアクセスするようにする
- 次にLCDがアクセスし始めるまではVRAMアクセス可能
- LCDのステータスを監視する、割り込みを使う等でタイミングを計る必要がある
- DMAを使う
- オブジェクトデータの転送にのみ使える
DMAは用途が決まっているのでここでは該当しないとして、「LCDを止める」方法と「LCDがアクセスしていない期間にアクセスするようにする」方法は、ゲーム起動直後に前者の方法でVRAMを初期化し、その後にVRAMへアクセスする必要がある際は後者の方法でアクセスする、と使い分けていることが多いようです。
今の実装は正に起動直後なので、「LCDを止める」方法で実装してみることにします。
LCDを止めるためには
LCDが画面描画を行う中ではいくつかの期間があり、その中でVRAMへアクセスしていない期間はいくつかあるのですが、LCDの停止はその中でも「Vブランク」期間限定でハードウェア的に許されている操作です。
ただ、「LCDがVブランク期間になるのを待つ」処理はgb_wait_for_vblank_to_start
という関数で実装済みです。今回はこのシェル関数を使えば良いので、LCDの振る舞いについて詳しくは説明しません*4。
[*4] ただ、ディスプレイの画面更新処理はこのようなレトロなハードでプログラミングする際の基礎知識だとも思うので、興味があれば「LCD Hブランク Vブランク」などのキーワードで調べてみると面白いです。
LCDCのレジスタについて
そして、Vブランクを待った上で、LCDを止めるにはどうするかというと、LCDCのレジスタを操作します。LCDの有効/無効自体はこのレジスタの最上位ビットなので、このビットだけ操作すればLCDを止めることができます。
ただ、LCDについてはいくつか設定しなければならない部分もあるので、LCDCのレジスタの各ビットについてここで説明します。
LCDCの8ビットレジスタの各ビットをまとめると表2.3の通りです。
ビット | 役割 |
---|---|
7 | LCDの有効(=1)/無効(=0) |
6 | ウィンドウタイルマップアドレス設定 (0=0x9800-9BFF, 1=0x9C00-9FFF) |
5 | ウィンドウの有効(=1)/無効(=0) |
4 | 背景とウィンドウ用のタイルデータ配置アドレス設定 (0=0x8800-97FF, 1=0x8000-8FFF) |
3 | 背景タイルマップアドレス設定 (0=0x9800-9BFF, 1=0x9C00-9FFF) |
2 | オブジェクトサイズ(0=8x8, 1=8x16) |
1 | オブジェクトの有効(=1)/無効(=0) |
0 | 背景の有効(=1)/無効(=0) |
「ウィンドウ」や「オブジェクト」は、本書では扱いませんが、簡単に説明すると、GBの画面は3層のレイヤー構造のようになっていて、一番手前にあるのが「オブジェクト」、次が「ウィンドウ」、最後が「背景」です。そして、それぞれは単なるレイヤーではなく機能が異なります。
「オブジェクト」は「スプライト」などとも呼ばれるもので、8x8(タイル1枚)あるいは8x16(タイル2枚)の画像を座標やその他いくつかの属性情報とともに管理する機能で、オブジェクトに紐付いた座標値のみ更新するだけで、LCDはそのオブジェクトを指定された位置に描画してくれます。
「ウィンドウ」は機能的にはほとんど「背景」と同じで、VRAM上のタイルマップ領域に「この座標にはこのタイル」と指定することで画面上にタイルを描画します。透過色や、ウィンドウ幅・高さの変更などはできないので、有効にすると、画面サイズ分のスクリーンがそのまま1枚、背景を覆い尽くす形で描画されることになります。ただ、ウィンドウの開始座標(左上座標)は変更できるので、画面下部にメッセージウィンドウを描画する、などに使うことができます。
以上を踏まえ、本書ではLCDCには表2.4の設定を行うことにします。
ビット | 設定値 |
---|---|
7 | 有効(=1)/無効(=0)は状況に応じて設定 |
6 | 背景に使わない側を設定(1=0x9C00-9FFF) |
5 | ウィンドウは無効(=0) |
4 | タイルデータ配置アドレスは0x8000-8FFF(=1)にする |
3 | 背景タイルマップアドレスは0x9800-9BFF(=0)にする |
2 | オブジェクトは使わないのでどちらでも良い(=0) |
1 | オブジェクトは無効(=0) |
0 | 背景は有効(=1) |
表2.4を踏まえて、LCDの無効(ビット7を0)も行うと、LCDCのレジスタ設定値は2進数で0b0101 0001で16進数で0x51です。
それでは、この設定をLCDCへ行う処理を追加してみます(リスト2.2)。
処理を追加するにあたり、「32KBに満たない分を0で埋める」の箇所の計算を都度行うのは面倒なので、プログラム本体部分はmain
というシェル関数に記述し、その部分のみmain.o
というファイルへ出力して、そのファイルサイズを使うようにしました。
「LCD設定&LCD止める」処理は2つの命令で行っています。まず1つ目のlr35902_set_reg regA 51
で、レジスタAへLCDCへ設定する値0x51を格納しています。lr35902_set_reg
は「汎用レジスタ」へ値を設定する命令をシェル関数化したものです。GBのCPUであるLR35902の汎用レジスタについて詳しくは後述しますが、ここではAという8ビットのレジスタに0x51という値を格納しています。
そして、lr35902_copy_to_ioport_from_regA $GB_IO_LCDC
でレジスタAの内容をLCDCのレジスタへ設定しています。lr35902_copy_to_ioport_from_regA
は、レジスタAの内容を指定されたIOレジスタへ設定する命令をシェル関数化したものです。
タイルをROMへ追加
VRAMのタイルデータ領域へロードするには、そもそもロード対象であるタイルデータがROM内に存在する必要があります。
タイルデータは2bpp形式で作成しましたので(tile.2bpp
)、それをROMの中に組み込むようにします(リスト2.3)。
タイルデータをロードする際にタイルデータ自身の位置がずれると面倒なので、タイルデータは自由に使えるROM領域の先頭へ配置しました。
それに伴って、実行時のエントリアドレスがタイルサイズ分(16バイト)後ろにずれるので、その調整を行っています。
GBのCPUが持つ汎用レジスタについて
次の項でロード処理を実装するにあたり、ここで、GBのCPU(LR35902)が持つ汎用レジスタについて説明します。
LR35902が持つ汎用レジスタ(一部そうでないものも混ざっていますが)をまとめると表2.5の通りです。
16ビット | 8ビット | 8ビット | 備考 |
---|---|---|---|
AF | A | - | Fはフラグレジスタ |
BC | B | C | |
DE | D | E | |
HL | H | L |
8ビットレジスタがA・B・C・D・E・H・Lの計7個があります。
そして、それぞれは合体させることで16ビットレジスタとして使うことも可能で、AF・BC・DE・HLの4つの16ビットレジスタとして機能します。合体させる際の上位側・下位側も名前の並びの通りです。(例えば、レジスタBCは、レジスタBが上位8ビットでレジスタCが下位8ビット)
ただし、Aと合体させたFは「フラグレジスタ」という演算結果に応じて変化する専用のレジスタなので、AFについては「16ビットの値を格納」などには使えません。
タイルデータをVRAMへロードする
それでは、ROMに配置したタイルデータをVRAMのタイルデータ領域へロードしてみましょう。
実装の方針は以下の通りです。
- レジスタBCへROM上のタイルデータのアドレス(0x0150)を設定
- レジスタHLへVRAM上のタイルデータロード先アドレス(0x8000)を設定
- レジスタCへタイルサイズ(16)を設定
- 以下をレジスタCが0になるまで繰り返す
- レジスタBCが指す先1バイトをレジスタHLの指す先へコピー
- レジスタBCとHLをそれぞれインクリメント
- レジスタCをデクリメント
これを実装するとリスト2.4の通りです。
レジスタに数値を設定する命令もlr35902_set_reg
という名前でシェル関数化しています。lr35902_set_reg regDE $GB_ROM_FREE_BASE
というようにレジスタDEにタイルデータのアドレスを設定していて、HLやCも同様の方法で値を設定しています。
()
でサブシェル化している箇所がループで繰り返し実行される部分です。ここでROMからVRAMへのタイルデータのコピーを1バイトずつ行っています。各行を説明すると、まずlr35902_copy_to_from regA ptrDE
で、レジスタDEが指す先の1バイトをレジスタAへコピーします。lr35902_copy_to_from
は第1引数のレジスタへ第2引数のレジスタをコピーする機械語を生成するシェル関数で、ptrDE
のようにレジスタ指定するとそのレジスタをポインタとして使います*5。次にlr35902_copyinc_to_ptrHL_from_regA
でレジスタHLが指す先へレジスタAをコピーします。なお、このシェル関数ではレジスタHLのインクリメントも同時に行います*6。これで1バイト分のROMからVRAMへのコピーが完了です。lr35902_inc regDE
でレジスタDEをインクリメントし、lr35902_dec regC
でレジスタCをデクリメントします。
[*5] C言語のポインタと同じ意味です。そのレジスタの中身ではなく、そのレジスタに格納されている値をアドレスとして使い、そのアドレスが指す先に作用するようになります。
[*6] レジスタHLに対しては同時にインクリメントも行う命令(ldi)が用意されているため、それに対応するシェル関数を用意しています。
そして、このサブシェル部分は出力をmain.1.o
というファイルへリダイレクトしています。これにより、このサブシェル部分は直ちに標準出力へ出力するのではなく、一旦ファイルへ保存するようにしています。ただこの場合は直ちにcatでその内容を標準出力へ出力しているわけですが、何故このようなことをしているのかというと、この後、この処理をループさせるために相対ジャンプ命令で「何バイト戻れば良いのか」を算出するためです。local sz_1=$(stat -c '%s' main.1.o)
でサブシェル化した部分のバイト数をsz_1
という変数へ格納し、lr35902_rel_jump_with_cond NZ $(two_comp_d $((sz_1+2)))
で相対ジャンプする際の戻るバイト数の計算に使っています。2を足しているのは、相対ジャンプ命令自体のバイト数(2バイト)です。
最後に、この相対ジャンプ命令のシェル関数には_with_cond
が付いています。これは「条件付き相対ジャンプ命令」という「直前の演算結果に応じてジャンプするか否かを変える」命令をシェル関数化したものです。今回の場合、第1引数にNZ
を指定しているので「直前の演算結果がゼロでは無い場合(Not Zero)」にジャンプすることになります。直前の演算はレジスタCのデクリメントなので、レジスタCが0ではない間、サブシェル化した部分の先頭へジャンプすることになります。
パレットを設定する
この節の最後に背景用パレット(BGP)を設定しておきます。
パレットについては先述の通りで、0xE4を設定します。実装はリスト2.5の通りです。
パレット設定はVRAMとは別の単なるレジスタ設定なので、LCD再開後でも構いません。そこで、LCD再開の処理も追加して、その後でBGP設定を行うようにしました。
シェル関数名や変数名からやっていることはわかるかと思いますが、「LCDを再開させる」の箇所では、lr35902_copy_to_regA_from_ioport $GB_IO_LCDC
でLCDCレジスタの内容をレジスタAへロードした後、lr35902_set_bitN_of_reg 7 regA
でビット7のみセット(LCD有効化)し、lr35902_copy_to_ioport_from_regA $GB_IO_LCDC
でLCDCレジスタへ書き戻しています。
「BGP設定」の方は設定方法自体はLCDCのときと同様なので、コードの説明は省略します。
試しに動作確認
この時点で一度、動作確認してみます。
シェルスクリプトをファイルへリダイレクトして、生成したROMファイルをエミュレータで実行してみてください。(前項時点のシェルスクリプトが02_bg/04_set_bgp.sh
に保存されているとします。また、作成したtile.2bpp
がカレントディレクトリに存在するようにしてください。)
$ 02_bg/04_set_bgp.sh >02_bg/04_set_bgp.gb ... $ bgb 02_bg/04_set_bgp.gb ...
実行すると図2.1の画面が表示されます。
Nintendoロゴのみが表示されているデフォルト状態では、タイル番号0のタイルデータは真っ白のタイルだったのですが、今回そこへ自作のタイルのタイルデータを上書きしたため、背景のタイルマップでタイル番号0が指定されていた部分には自作のタイルが表示されるようになりました。
2.4 画面全体を自作タイルで敷き詰めてみる
では、この章の最後に画面全体を自作のタイル(タイル番号0)で敷き詰めるように、背景のタイルマップ領域を初期化してみます。
タイルマップ領域の使い方
LCDCレジスタ設定の際に、背景用のタイルマップ領域は0x9800-9BFF(1024バイト)のアドレスとしました。この領域をどのように使うかというと、画面左上から順にタイル番号(1バイト)を並べるだけです。
GBの画面は、表示領域は160x144ピクセルで、8x8のタイルで換算すると20x18タイルなのですが、実は非表示の領域もあり、スクリーン全体としては256x256ピクセル(32x32タイル)の大きさがあります。次章で紹介しますがLCDには画面スクロール機能があり、その機能のレジスタを設定すると非表示の領域まで画面をスクロールできます。
0x9800-9BFF(1024バイト)のタイルマップ領域は、各1バイトがスクリーン全体32x32タイル(全1024タイル)の各タイルと対応づいていて、この各バイトにタイル番号を設定すると、画面の対応する位置のタイルがそのタイル番号のタイルになる、というわけです。
ここでは、非表示の部分も含めて全てタイル番号0で初期化したいので、0x9800-9BFF(1024バイト)の領域を全て0クリアします。
実装
さっそくですが、実装してみるとリスト2.6の通りです。
追加の行数が少し多めですが、戦略としては「レジスタHLへタイルマップアドレスを設定し、最終アドレス(0x9bff)への設定完了まで、インクリメントしながら繰り返す」というものです。
ここで新たに登場したシェル関数は2つで、まず1つ目はlr35902_clear_reg
です。これは、指定したレジスタをゼロクリアするシェル関数で、Aレジスタ以外を指定した場合、指定されたレジスタへ0を設定する命令を出力します。Aレジスタについてはxorでゼロクリアする命令を出力します*7。
[*7] xor命令の方が0を設定する命令より命令バイト数が少ないためそのようにしています。LR35902が持つxor命令は「レジスタAと何かをxorした結果をレジスタAへ格納する」というものであるためレジスタAに関してのみ、xorでクリアするようにしています。
そして、2つ目はlr35902_compare_regA_and
で、「レジスタAと比較する」命令を出力するシェル関数です。LR35902が持つ比較命令はレジスタAとの比較のみなので、このようなシェル関数名になっています。比較をするとそれに応じてフラグレジスタが変化するため、直後の条件付きジャンプ命令と組み合わせて使用します。
なお、比較が8ビットずつしか行えないため、「レジスタHが最終アドレス上位8ビット(0x9b)と等しく無い間繰り返す(main.2.oのサブシェル部分)」と、それを囲む「レジスタLが最終アドレス下位8ビット(0xff)と等しくない間繰り返す(main.3.oのサブシェル部分)」の2段のループを作り、レジスタHもLも最終アドレスと等しい場合にループを脱出するようにしています。
ここではあくまでも、背景用タイルマップ領域を全て0で初期化できれば、その実装方法は何でも良いです。(おそらくもっと良い実装もあるかも*8)
[*8] ただし、16ビット変数に1024を入れてデクリメントしていく方針の場合、注意が必要です。例えばレジスタBCをカウンタ用として、そこに1024を入れて、デクリメントしながらループを回す実装が考えられますが、デクリメント命令は16ビット演算の際はフラグを設定しないので、相対ジャンプ命令の条件にできません。結局、カウンタ用のレジスタBとCを別々に0と等しいか比較する必要があります。
結果確認
エミュレータで実行すると、今度は図2.2のように画面全体が自作のタイルで初期化されました。