ファミコンのスクロールテクニックを確認した

前置き(長い)

最近息抜きに Castlevania 3 をやっている. そこで気づいたのは縦スクロールのための空白(空黒?)領域が広いということ.

この領域をなくすか考えてある程度プログラムを作った. この方針でも解決できるとは思うがプログラムが複雑になりすぎた*1こと、不具合の原因調査の過程で nesdev wiki に Split X/Y scroll というテクニックが載っていたので、これのほうが簡単に実装できると判断したので、思いついたアイディアはボツにした.

画面描画中に scroll register を更新する場合、特に Y 座標は CPU address $2006 経由で更新すると 8 pixel 単位の制限がつく. なのでスコアのようなスクロールをしない部分があるゲームで縦スクロールするゲームではスコアは下にあることが大半. スコアが上で縦スクロールするゲームで違和感なく実装できた市販品は悪魔城伝説忍者龍剣伝3の2本だけだと思う. だめな例はデビルマンの洞窟のシーンをみるとわかる. ゼルダの伝説に似たレイアウトは 8 pixel 単位で動いていることが大半.

Split X/Y scroll の自分の解釈

https://www.nesdev.org/wiki/PPU_scrolling
記事のほうが正確なので、自分の解釈はおかしいと思ったら元の記事を確認すること.

レジスタを4度書くだけでそれらの制限から開放される. 各種レジスタ t,v,x,y の動作原理は後述する. 用意することは下記.

  • x,y で 9 bit のスクロール値を作っておく.
    • x は通常の 9 bit の値
    • y は bit 7:0 は 0 から 239 の値を取り 240 から 255 の区間は計算して carray を bit8 に設ける.
  • 下記のプログラムで 4 byte の値を作っておき、スクロールを切り替えるタイミングでレジスタへ書き込む.

自分が作った算出プログラムは下記.

;注意: y bit7:0 の範囲は 0 から 239 (0x00 から 0xef) とする
scroll_bit_concat:
;3 = y8
;2 = x8
	lda	scroll_x_val+1
	lsr	a
	lda	scroll_y_val+1
 rept 3
	rol	a
 endm
	sta	w_2006_0
;7:6 = y7:6
;2:0 = y2:0
	lda	scroll_y_val
	sta	w_2005_1
;2:0 = x2:0
	ldy	scroll_x_val
	sty	w_2005_0
;7:5 = y5:3
;4:0 = x7:3
;y >>= 3
;3.times{
;  y >>= 1
;  x = (x >> 1) | (c << 7)
;}
;	ldy	scroll_x_val
	sty	w_2006_1
;	lda	scroll_y_val
 rept 3
	lsr	a
 endm
 rept 3
	lsr	a
	ror	w_2006_1
 endm
	rts

nametable やパレットはデバッガで出したデータを自分のプログラムに組み込んだ結果は下記のようになる.


サンプルプログラム制作所感

  • スクロールレジスタを書き換える部分はタイミングがかなり重要でできれば HBlank である cycle 256 から 320 の間に4度目のレジスタを書くとちらつかない. mesen ではこれを簡単に確認できるが、それ以前はかなり大変な確認である.
  • タイミング調整のためには IRQ のハンドラ内部では予め計算した結果をレジスタに書き換えるだけにしてループを含む分岐を一切なくす. jsr-rts もできればやらずインライン展開すべきである.
  • 制作したプログラムはスクロールレジスタの更新の後バンクレジスタを書き換える処理をいれたのでちらつきが発生している. ちらつきを避けるには割り込み期間は 2 line の時間を確保しておき、スクロールレジスタを書き換えた後に市販品のような空白領域を設ける.
  • 空白領域でバンクレジスタを書き換えた後、HBlank で空白領域を解除する.

市販品の解析

mesen の event viewer でみた所感.

  • 悪魔城伝説: 先頭の端数分で何も見えないバンクを用意して違和感がない. かなり試行錯誤をしていて無駄な処理が多いように見える.
  • 忍者龍剣伝3: 先頭の端数分で横scrollをすることで空白のnametableを割り当てて端数部を処理している. 処理は簡潔でタイミング調整がきれい.
  • デビルマン: 基本的なプログラムの質が低い. なぜスコア部を下に配置しなかったのか謎.

スクロールレジスタの解説

描画途中に CPU address $2006 を書くときに data で nametable address (例:$20a0)を書くと次の描画で 2 line ずれる. この port を書く用途は CPU address $2007 を介して、CPU から PPU 領域への読み書きに使うのは正しいが、スクロールを更新する場合は name table 参照元として $2xxx を書いてしまいがちで市販品もそれがかなり多いし自分も勘違いした.

CPU address $2006 の1度目の書き込みの data bit 5:4 へ書くと内部で保持されるレジスタは nesdev wiki では t の fine Y scroll と呼ばれるもの. t の fine Y scroll の bit 2 は 0 が代入されて、同 bit1:0 は CPU data bit 5:4 が代入される.

  • CPU address $2007 を介す場合は v の fine Y scroll の bit 1:0 は PPU A13:12 の出力として利用する.
  • PPU が描画として name table を参照する場合は v の fine Y scroll の bit 2:0 は PPU A2:0 の出力として利用し、 PPU A13:12 は 2'b10 が出力される.

この2つは理解しづらく、描画で 2 line ずれる原因となる. CPU address $2006 の1度目の書き込みで fine Y scroll の bit2 は 0 が代入されるのが制限の原因となる. これを回避できるのが Split X/Y scroll の手法である.

*1:8pixelで縦に調整した置いた絵を用意して IRQ を2回かけて空白をなくす