PC-8001 1ドットスクロールゲーム 解説(その2)
前回 は、PC-8001で1ドットスクロールを実現するためのマップ表示方法を書きました。
その後、VSync待ちの時間を利用して表示処理を高速化する方法を思いついたのでその方法をまとめます。
前回の記事で説明した方式を前提にしているため、マップデータやアトリビュートの基本的な構造については、必要に応じて前回の記事を参照してください。
メインループ(再掲)
以下のような順序で行っています。
- シフト無しマップデータをMapVram1からVramへ転送
- アトリビュート圧縮データをAttrVramに展開
- キャラクタ描画(VramとAttrVramへ書き込み)
- AttrVramからVramのアトリビュート領域を作成
- VSync待ちしてダブルバッファリングの切り替え
- マップ圧縮データを、各行展開して1キャラ分をW_NextMapByteDataに設置
- MapVram1の末尾データとW_NextMapByteDataからMapVram2の末尾データをシフトして生成
- シフトあるマップデータをMapVram2からVramへ転送
- アトリビュート圧縮データをAttrVramに展開
- キャラクタ描画(VramとAttrVramへ書き込み)
- AttrVramからVramのアトリビュート領域を作成
- VSync待ちしてダブルバッファリングの切り替え
- MapVram1の各行の末尾データをW_NextMapByteDataのデータで埋める
改善方針
上の処理の5と12にあるVSync待ちでは、処理がVSyncに入った直後に到達すると、次のVSyncまで待つことになります。最悪の場合、約1/60秒の待ち時間が発生します。
今回、この待ち時間を有効に使えないかと考えました。
表示中のVRAMを書き換えると画面に影響する可能性があります。一方、アトリビュート圧縮データを仮想エリアであるAttrVramへ展開する処理(2と9)は、まだ表示に使っていないバッファを準備する処理です。そのため、この処理ならVSync待ちの間に実行できます。
前回説明したアトリビュート圧縮データとその展開を、VSync待ちの間に先頭行から可能な限り処理します。
変更内容は次の3点です。
- 前回の処理を、全行を繰り返す部分と1行だけ処理する部分に分離する。
- VSync待ち(5と12)で1行処理を呼び出し、次のVSyncが始まるまで可能な限り行を進める。VRAMを切り替えられるようになったら、残りの処理を待たずに切り替える。
- 本来のAttrVram展開処理(2と9)では、VSync待ちで処理できなかった残りの行を続きから処理する。
VSync待ちでどこまで処理したかは、ATR_setAttrDataToAttrMapOne.rowDone に記録します。通常の展開処理は、この値を読み取って続きの行から再開します。
この処理では、AttrVramへの書き込みにSPを使っています。そのため、通常のcall/retは使えません。VSync待ちから呼び出す場合と、通常の展開処理から呼び出す場合のそれぞれで戻り先アドレスを設定し、retの代わりにjpで戻るようにしています。
実装
VSync待ち中の前処理
次のルーチンは、アトリビュートを1行ずつ処理しながらVSyncの状態を監視します。
- すでにVSync中なら、VSyncが終わるまで処理を続ける。
- VSyncが終わったら、次のVSyncが始まるまで処理を続ける。
- VSyncが始まった時点で処理を中断し、VRAMを切り替える。
; ========================
; アトリビュートデータを一部処理する
; VSync待ち中に呼びだしてVsyncが始まるまで処理することで
; 続く setAttrDataToAttrMapの処理を軽くする
; W_AttrRowsDoneで処理済み行数を管理する
8ECD ATR_changeVramAndPrecompute:
; 前処理カウントをリセット
8ECD AF 4 xor a
8ECE 320B8F 13 ld (ATR_setAttrDataToAttrMapOne.rowDone), a
8ED1 DD213E84 14 ld ix, NextMapAttr.workTop
; SP保存、AttrVramへ向ける
8ED5 ED73FE8E 20 ld (.spOne), sp
8ED9 .waitEnd
8ED9 DB40 11 in a, ($40)
8EDB E620 7 and $20
8EDD 280F 12 jr z, .waitStart ; VSync中でないなら次の処理
8EDF 21E88E 10 ld hl, .ret1 ; 戻り先
8EE2 22728F 16 ld (ATR_setAttrDataToAttrMapOne.retAddr), hl
8EE5 .inVSync
; すでにVSync中ならまずVSyncが終わるまで処理を続ける
; call ATR_setAttrDataToAttrMapOne
8EE5 C30A8F 10 jp ATR_setAttrDataToAttrMapOne
8EE8 .ret1
8EE8 DB40 11 in a, ($40)
8EEA E620 7 and $20
8EEC 20F7 12 jr nz, .inVSync
8EEE .waitStart
8EEE 21F78E 10 ld hl, .ret2 ; 戻り先
8EF1 22728F 16 ld (ATR_setAttrDataToAttrMapOne.retAddr), hl
8EF4 .waitVSync
; call ATR_setAttrDataToAttrMapOne
8EF4 C30A8F 10 jp ATR_setAttrDataToAttrMapOne
8EF7 .ret2
8EF7 DB40 11 in a, ($40)
8EF9 E620 7 and $20
8EFB 28F7 12 jr z, .waitVSync
8EFE .spOne equ $ + 1
8EFD 310000 10 ld sp, 0
8F09 C9 10 ret
アトリビュートデータを1行だけ展開する処理
変なところは、SPを使ってデータ書き込みしているのに call/ret を利用しているところです。普通は許されませんが、callで書かれた戻りアドレスはretで戻った後に別のデータで必ず上書きされるので問題が発生しません。
2026/11/15 コード編集: ixを使うのは遅いと分かっていながら面倒で手を付けていなかったのですが、AIにix使わないように高速化してと頼んだらまったくミスなく修正してくれスピードアップが出来ました。以下を修正後のコードに変更しました。
; =================================================
; アトリビュートデータを1行だけ展開する
; SPも利用するので注意 (行ワークの読み書きとバッファ書き込みの両方に使う)
; Input:
; hl = 戻り先アドレス (SPを操作するためcall/retが使えないのでhlで戻す)
; .workPtr = 当該行の NextMapAttr 行ワークアドレス
; =================================================
ATR_setAttrDataToAttrMapOne:
.rowDone equ $ + 1
ld a, 0
cp AttrVram.lines
jr c, .@@F
ld hl, (.retAddr)
jp (hl) ; ret 全行完了済み cy = 0
; ret ; ret 全行完了済み cy = 0
.@@
; a = 何行目か
.workPtr equ $ + 1
ld sp, 0 ; sp = 当該行ワーク (color, length, nextAddrL, nextAddrH)
pop bc ; c = color, b = length
pop hl ; hl = 圧縮データアドレス
ld (.workPtr), sp ; 次行のワーク
inc b
dec b ; aを壊さずに length == 0 判定
jr nz, .decoded
; 前のデータが終わっていたら次のデータを読む
; callの戻りアドレスは当該行の nextAddr に書かれるが直後のpushで上書きする
call decodeAttrData2
push hl ; nextAddr 更新
ld c, e
push bc ; color, length 更新
ld a, (.rowDone) ; decodeAttrData2でaが壊れるので行番号を再取得
.decoded
; c = color, b = length, hl = 圧縮データアドレス, a = 何行目か
; SP = AttrVram row write pointer
; H = (AttrVram.top + AttrVram.lineSize) / 256 + rows_done
; L = (AttrVram.top + AttrVram.lineSize) % 256 = 0
add a, (AttrVram.top + AttrVram.lineSize) / 256
ex de, hl
ld h, a
ld l, (AttrVram.top + AttrVram.lineSize) % 256
ld sp, hl
ex de, hl ; hl = 圧縮データアドレス
; 1行分の書き込み
; spのメモリ領域は後で上書きするのでcallしてもよい
ld e, c ; e = color
ld c, ScrWByte / 2 ; c = 行の残り語数 (2バイト単位)
.nextColor
ld d, e ; d = e = color
srl b ; b = 語数, cy = 奇数フラグ
jr z, .oneLength ; 元の差が差が 0 or 1
ex af, af' ; 奇数フラグ保存
ld a, c
sub b ; a = 残り語数 - 語数
jr nc, .fits
ld b, c ; 行末を超える分は切り捨て
xor a
.fits
ld c, a
; PUSH列の途中に飛び込み b 回だけ push de する
ld a, .pushMax
sub b
ld (.pushOfs), a ; jrの飛び先 = 先頭から (.pushMax - b) バイト後
.pushOfs equ $ + 1
jr .pushTop
.pushTop
REPT .pushMax
push de
ENDM
inc c
dec c
jr z, .lastNoPush ; 行末まで書いた
call decodeAttrData2
ex af, af'
jr nc, .nextColor
.oddColor ; 奇数の1バイト分を処理
push de
dec c
jr z, .lastNoPush
dec b
call z, decodeAttrData2
jr .nextColor
.oneLength
ld d, e
call decodeAttrData2
jr .oddColor
.lastNoPush
; 進捗インクリメント
ld hl, ATR_setAttrDataToAttrMapOne.rowDone
inc (hl)
scf
.retAddr equ $ + 1
jp $0000 ; retの代わり cy = 1
.pushMax equ ScrWByte / 2 ; 1行の最大PUSH回数
続きの行からの処理
=================================================
; アトリビュートデータを展開してアトリビュートバッファに書き込む
; W_AttrRowsDone > 0 の場合はその行から再開する
; ATR_setAttrDataToAttrMapOne を全行完了するまで繰り返すだけ
; =================================================
8F74 ATR_setAttrDataToAttrMap:
8F74 3A0B8F 13 ld a, (ATR_setAttrDataToAttrMapOne.rowDone)
8F77 FE19 7 cp AttrVram.lines
8F79 D0 11 ret nc
; ix = NextMapAttr.workTop + rows_done
8F7A DD213E84 14 ld ix, NextMapAttr.workTop
8F7E 5F 4 ld e, a
8F7F 1600 7 ld d, 0
8F81 DD19 15 add ix, de
8F83 21908F 10 ld hl, .ret ; 戻り先アドレス
8F86 22728F 16 ld (ATR_setAttrDataToAttrMapOne.retAddr), hl
8F89 ED73938F 20 ld (.spOne), sp
8F8D .loop
; call ATR_setAttrDataToAttrMapOne
8F8D C30A8F 10 jp ATR_setAttrDataToAttrMapOne
8F90 .ret
8F90 38FB 12 jr c, .loop
8F93 .spOne equ $ + 1
8F92 310000 10 ld sp, 0
8F95 C9 10 ret
結果
各VSync待ちに何行処理しているかを表示したところ、VSync待ちがどれくらい発生しているか異なるため各回でかなりばらついていますが、最大では25行全部を処理しているようです。
AREA16全体をスクロールする時間を録画した動画で確認したところ、17分30秒程度から16分20秒程度にスピードアップすることが出来ました。機能を増やして処理が増えていることを考えると、(アバウトな計測ですが)10%弱スピードアップしたことになります。
謝辞
アセンブラはAILZ80ASM を利用させていただいています。ありがとうございます。