モノ創りで国造りを

ハード/ソフト問わず知見をまとめてます

Verilogでゲームを作る --Designing Video Game Hardware in Verilogの簡易和訳-- part2

Verilogビデオゲーム

Verilogの参考書を探してるとアマゾンで以下の本に出会った。

Designing Video Game Hardware in Verilog

Designing Video Game Hardware in Verilog

プログラミング言語の入門書の多くに、ゲーム作製をテーマにしているものがある。
Verilogでは同様のコンセプトの書籍が見つからなかったので、さっそく購入。
まだ全部読んでいないため、良書かどうかの判断はできないが、
せっかくなので、重要な箇所だけかいつまんで和訳しようと思う。
全部翻訳すると著作権に抵触するらしいので。
なるべく最後まで完結できるように頑張る。

目次はこちら

yuji2yuji.hatenablog.com

12章 動くボール

アニメーション表示の最初の電子ゲームの一つは1958年にBrookhaven National Laboratoryで開発されたTennis of Twoである。
f:id:yuji2yuji:20190819110322p:plain

このアイデアは、いかにして跳ねるボールをオシロスコープ上に表示するかというアナログコンピューターの参考書に例示された回路から生まれた。

後に、Magnavox Odysseyコンソールがタ級をtVスクリーンに映し出した、Pongがアーケードとして誕生した。
f:id:yuji2yuji:20190819110647p:plain
プレーヤーはノブを回転させスクリーン上のパドルを動かし、スクリーン上の動いている四角い玉を跳ね返す。

絶対座標

9章で水平/垂直同期パルスの時間を計るカウンターを使って、同期生成モジュールを作成した。それはまた現在の電子銃の位置を表していた。
動くボールをスクリーンに置くために、これをどう使えばよいだろうか。

抜け目のないデザイナーは、特にソフトウェアのバックグラウンドを持っている場合、「ボールの水平位置と垂直位置を追跡する2つのレジスターを持たせよう。これらを水平および垂直ビーム位置から引き算し、水平/垂直の引き算した結果ががいずれもNピクセル未満だった場合はボールを表示する。連続する各ビデオフレームで、ボールの位置をある方向に数ピクセルだけ動かす。」

1ピクセルのボールを描くところから始めよう。
これはとても簡単、ボールの水平位置と垂直位置をビームポジションと比較して、水平位置と垂直位置の両方が等しいときにボールを描けばよい。

wire ball_hgfx = ball_hpos == hpos; // horizontal
wire ball_vgfx = ball_vpos == vops;// vertial 
wire ball_gfx = ball_hgfx && ball_vgfx; // AND operator

もしball_hgfx信号をCRTに表示すれば、水平ラインが表示される。 ball_vgxf信号は垂直方向のラインを表示する。
両者が交わるところがボールであり、白いドットでであり、ball_gfx信号である。
f:id:yuji2yuji:20190819113053p:plain

より大きなボールを作りたい場合、イコールではなく比較を行う必要がある。
ボール位置をビーム位置から引き、両者の値をボールサイズと比較する。

wire [8:0] ball_hdiff = hpos - ball_hpos;
wire [8:0] ball_vdiff = vpos - ball_vpos;

wire ball_hgfx = ball_hdiff < BALL_SIZE;
wire ball_vgfx = ball_vdiff < BALL_SIZE;
wire ball_gfx = ball_hgfx && ball_vgfx; 

Verilogはデフォルトでunsinegが数字に割り当てられるため、引き算結果が負数の場合はオーバーフローする点に注意して欲しい。
これは良いことで、なぜならオーバーフローした数値は常にBALL_SIZEより大きいからだ。 ただし、ボールのサイズがばかげた大きさでなければ。

ボールをフレームごとに動かしたい。そのためにvsync信号をトリガーとする、これはフレームの終わりに発生する。

always @(posedge vsync or posedge reset)
begin
    if (reset) begin
        ball_hpos <= ball_horiz_initial;
        ball_vpos <= ball_vert_initial;
    end else begin
        ball_hpos <= ball_hpos + ball_horiz_move;
        ball_hpos <= ball_vpos + ball_vert_move;
    end
end

壁での跳ね返り

スクリーンの端っこでボールが跳ね返るようにしたい。
最初にボールが端にヒットしたかをどうかを定義する必要がある。
賞取ると検出したときに、左右の壁ならボールの水平方向の速度を、上下の壁ならボールの垂直方向の速度を反転させる、もし隅っこなら水平/垂直両方の速度を反転させる。

wire ball_horiz_collide = ball_hpos >= 256 - BALL_SIZE;
wire ball_vert_collide = ball_vpos >= 240 - BALL_SIZE;

ボールサイズを考慮して、右辺からボールサイズを引き算した。
ボールが左の壁に当たった場合または上の壁に当たった場合については、 先ほど述べたように負数はオーバーフローして数値が大きくなるため、式には記載しなくてよい。

衝突をトリガーとしたイベントを記載する。

always @(posedge ball_horiz_collide)
    ball_horiz_move <= -ball_horize_move;

always @(posedge ball_vert_collide)
   ball_vert_move <= -ball_vert_move;

posedgeでトリガーするため、イベントは信号がHになったときに一度だけ発生する。信号がフレームごとに1パルスのみ発生する限り、フレームごとに1度だけ速度反転する。

13章 スリッピングカウンター

スリッピングカウンター法

絶対座標のカウンターはソフトウェアでゲームを作成する人になじみ深い方法だ。
しかし最初のハードウェアによるビデオゲームに用いられた方法ではない。
彼らはもう少し賢い方法、スリッピングカウンターと呼ばれる方法を用いた。
歴史的な観点から、このスリッピングカウンターについて少し記載しよう。
(時間がない人は本章は飛ばしても良い)

X/Y座標を表すレジスタの代わりにスリッピングカウンター法は水平と垂直の同期カウンタを並行してカウントする2つのレジスタを保持する。
ボールの絶対位置ではなく、ボール位置とビーム位置の差分を追跡し続けると考えてほしい。

もしボールが定常的であれば、ボールのカウンターが正確にビデオの同期カウンターと同期する、ボールのオフセット位置を除いて。
しかし、もしボールが動いていれば、それらは1フレームに一回だけある決まった量をスリップすることが許される。
スリップの方向と大きさはボールの速さと向きを決める。

例えば、もしボールが右に動いていれば、ボールの水平カウンターは1フレームに数サイクルカウントする 。もし上に動いていれば、ボールの垂直カウンターは数サイクル少なくカウントされる。

なぜこのような方法をとったのだろうか。なぜなら、ハードウェアを少なくできるからだ。
この方法は、4つの9316 4ビットカウンターチップ(水平に2つ、垂直に2つ)、追加のフリップフロップ、および少数のゲートで実装できる。
これは、ビデオ同期ジェネレーターの実装方法に似ている。

もしボールの絶対位置を保持すれば、2つの8btiレジスタと2つの8bitの引き算回路、そして、同期ジェネレーターからレジスターに位置をラッチするためのロジックが必要になる。
* 詳細は省略。

14章 RAM

アーケードゲーム初期のころは、基板上のフリップフロップやカウンターにデータを保持しており、CPUとビデオフレームバッファを除いて、RAMの必要性は少なかった。

ブロック崩してRAMチップが使われていた。ICが一つ$5~$10。
当時のRAMは1bit幅だった。各アドレスが1bitのデータを保持していた。
スペースインベーダーは8つの1bitRAMで構成される。
後期には4bitのRAMが利用可能になり、実装チップ数が減った。

VerilogのRAM

読み書きできる大きなRAMを作りたい。
Verilogは明確なRAMのコンセプトを持たないが、インデックス配列でRAMを模擬できる。
例えば1024byteの容量を持つアドレスバスとwrite-enable信号(we)で読み書きできる"mem"というRAMは以下のように宣言できる。

reg [7:0] mem [1024];
dout <= mem[addr];
if (we)
    mem[addr] <= din;

ビット幅、アドレスをparamで設定することで、可用性の高いRAMモジュールを作成できる。

module RAM_sync(clk, addr, din, dout, we);
    
    parameter A = 10; //address bits
    parameter D = 8; //data bits

    input clk;
    input [A-1:0] addr;
    input [D-1:0] din;
    output [D-1:0] dout;
    input we;
    
    reg [D01:0] mem [0:(1<<A)-1];

    always @(posedge clk) begin
        if(we)
            mem[addr] <= din;
        dout <= mem[addr];
    end
endmodule

多くのFPGAはこの手のモジュールを自動で名部のblockRAMに変換し、リソースを開放する。

Tri-stateバスと入出力ポート

RAMをコモンバスに接続したいかもしれない。そのバスを入力と出力両方に使用したい。そのような場合にTri-stateロジックを使用できる。

0と1に加えて、ハイインピーダンスのZが使用できる。
上記のRAMモジュールでは、書き込みしていない場合に、シグナルパスをオープンにしたい。
入出力ポートの宣言とデータの読み出しは以下のように書ける。

inout [D-1:0] data;
assign data = we ? {D{1'bz}} : mem[addr];

書き込み中だとweがHなので、dataは入インピーダンス状態。
読それ以外ではdata = mem[addr]となる。
*このDはparameterで設定された8を意味する。

tri-stateバスへの書き込み

一方バスへの書き込みは以下となる。

always @(posedge clk)begin
    if(we)
        mem[addr] <= data;
end

15章 タイルグラフィック

RAMとCPUの価格が下がり続けると、ビデオゲームはCRT端末と同じラインに沿って設計され、均一サイズのセルの行と列が表示されるようになった。
このスキームは現在、タイルグラフィックスとして一般に知られている。

タイルグラフィックシステムは、特定のセルの行と列に表示する文字を指定するRAMの値を検索する。
次に、各文字のビッチマップを含むROM内のタイルデータを検索する。

11章で作成したROMを使って、単純なRAMベースのタイルグラフィック生成器を作成する。

wire [9:0] ram_addr;
wire [7:0] ram_read;
reg [7:0] ram_write;
reg ram_wrireenable = 0;

RAM_sync ram(
    .clk(clk),
    .dout(ram_read),
    .din(ram_write),
    .addr(ram_addr),
    .we(ram_writeenable)
);

次にXとYの位置をデコードし行と列のデータに変換する。

wire [4:0] row = vpos[7:3];
wire [4:0] col = hpos[7:3];
wire [2:0] rom_yofs = vpos[2:0];
wire [4:0] rom_bits;

10ビットアドレスを得るために、rowとcolを連結する。
またdigitデータをramと紐づける。

assign ram_addr = {row, col};
wire [3:0] digit = ram_read[3:0];

ROMと結び付ける。

wire [2:0] xofs = hpos[2:0];
wire digit_gfx = rom_bits[~xofs];// inverts xofs.

digits10_case numbers(
    .digit(digit),
    .yofs(rom_yofs),
    .bits(rom_bits)
);

xofsを反転させる理由は、ROMに保存されているbitデータと画面に表示させるbitの方向が左右逆なため。

アニメーション

上に書いたのは変化のない表示には適している。しかし、アニメーションも動作させたい。そのために、RAMに書き込む必要がある。
我々の単純なアニメーションは、すべてのメモリーセルをフレームごとに1ずつ増やしていく。
各行の最後のスキャンラインの間で、そして各文字の最後の2クロックの間だけ書き込みを行う。
セルは8x8で数字は5x5なので、書き込みが読み出しと衝突しないだけの十分なブランクがある。

always @(posedge clk)begin
    case (hpos[2:0])
        // on 7th pixel of cell
        6:begin
            //increment RAM cell
            ram_write <= (ram_read + 1);
            //only enable write on last scanline of cell
            ram_writeenable <= (vpos[2:0] == 7);
        end
        7:begin
            //disable write
            ram_writeenable <= 0;
        end
    endcase
end

これはとても単純なアニメーションで、updateブロックはRAMのアドレスに触れていない。

16章 スイッチとパドル

ほぼ回路の説明なので省略。
スイッチとパドルの回路の模式図は以下。
f:id:yuji2yuji:20190820082317p:plain
f:id:yuji2yuji:20190820082407p:plain