たるいと申します。
前回の記事が思ったより伸びてくれて、ちょっとうれしいです。
ということで調子に乗って、今回はPPU編をはじめていきます。
やっぱり、画面が出ないと楽しくないですよね。
Githubレポジトリはこちらです。実装全体を見たい方はどうぞ。
https://github.com/tarusake/tarunes/tree/hello-ppu
今回のゴール
Verilator のシミュレーションで、最終的に以下のような画面が表示されるところまでを目指します。
以下の画面を出すのに 直接関係しない部分は、容赦なく非実装で進めていきましょう。
ファミコンのPPU
PPUは、ファミコンで画面表示を担当するチップです。
最低限、以下のスペックを押さえておけば大丈夫です。
- 解像度:256×240
- 色数:最大52色(同時発色数は少なめ)
- 動作周波数:CPUの3倍(今回は実装を簡単にするため、CPUと同じ周波数で動かします)
- PPUアドレス空間:14bit(16KB)
シミュレーション時にGUIを出す
PPUに限らずですが、画像を生成する回路を作ったときに「どうやって結果を確認するか」 は、地味に悩ましい問題です。
古典的な方法だと、
- ログを見て心の目で確認する
- PGM形式で画像を吐く
- 期待値ファイルを用意してDiffを取る(だいたい独自フォーマットになりがち)
といった手段が多いかなと思います。
今回は、もう少し直感的でモダンな方法を使いましょう。
Verilator を使っているので、C++でテストベンチを書くことができます。
つまり、C++で使えるGUIライブラリがそのまま使えるということです。
ここではSDL2を使って、シミュレーション結果をそのままウィンドウに表示します。
(画面が出ると単純に楽しいので、この機能はできるだけ早めに実装しておくとモチベーションが上がります)
というわけで、まずは既存のシミュレーションをC++テストベンチに移植するところから始めます。
テストベンチのC++への移植
src フォルダ内に tb_top.cpp を作成します。
Verilator の C++ テストベンチは、だいたい次のような形になるかと思います。
#include "Vtarunes_top.h"
#include "verilated.h"
#include "verilated_vcd_c.h"
int main(int argc, char** argv) {
Verilated::commandArgs(argc, argv);
Verilated::traceEverOn(true);
Vtarunes_top* dut = new Vtarunes_top;
VerilatedVcdC* tfp = new VerilatedVcdC;
dut->trace(tfp, 99);
tfp->open("wave.vcd");
dut->clk = 0;
dut->rst = 0;
const int RESET_CYCLES = 4;
const int SIM_CYCLES = 1000;
for (int cycle = 0; cycle < SIM_CYCLES; cycle++) {
if (cycle == RESET_CYCLES)
dut->rst = 1;
// Low
dut->clk = 0;
dut->eval();
tfp->dump(Verilated::time());
Verilated::timeInc(1);
// High
dut->clk = 1;
dut->eval();
tfp->dump(Verilated::time());
Verilated::timeInc(1);
}
tfp->close();
delete dut;
delete tfp;
return 0;
}
Makefile も書き換えていきましょう。
……とはいえ、このあたりはサクッと生成AIさんに作ってもらえますね。
ポイントは以下の通りです。
- C++テストベンチ(tb_top.cpp)を使用
- --cc:C++向けのモデルを生成
- --exe:自前のC++テストベンチをリンク
- --top-module:Verilogのトップモジュール名を指定(tarunes_top)
- -CFLAGS:C++17を指定(tb_top.cppで必要)
Makefile はこんな感じになります。
VERILATOR := verilator
VERILATOR_FLAGS := -Wall --trace --Wno-fatal -GPROM_PATH=\"helloworld_prg.hex\"
RTL_DIR := target
RTL_SRCS := $(wildcard $(RTL_DIR)/*.sv)
TB_CPP := src/tb_top.cpp
TOP := tarunes_top
all: build
veryl-fmt:
veryl fmt
veryl-build: veryl-fmt
veryl build
build: veryl-build
$(VERILATOR) $(VERILATOR_FLAGS) \
--cc $(RTL_SRCS) \
--top-module $(TOP) \
--exe $(TB_CPP) \
-I$(RTL_DIR) \
-CFLAGS "-std=c++17"
make -C obj_dir -f V$(TOP).mk
run:
./obj_dir/V$(TOP)
clean:
rm -rf obj_dir *.vcd
.PHONY: all build run clean veryl-fmt veryl-build
この後
make
make run
を実行すると、これまでと同様に
シミュレーションが走り、ログ(と波形)が出力されます。
なお、あとから気づいたのですが、make clean のあとにそのまま make を実行すると、RTL_SRCS が空になってしまいエラーになってしまいますね…
その場合は、もう一度 make を実行してください。
- 1回目の make:Veryl から RTL が生成される
- 2回目の make:生成された RTL が RTL_SRCS に正しく認識される
※このあたりは、後ほど修正します。
GUI出力の追加
tb_top.cpp に SDL2 を使ったGUI出力の処理を追加します。
おまじない的なコードが多いので、ここでは「だいたいこんな感じなんだな」くらいで大丈夫です。
この時点では pixel_buffer の中身はすべて 0 なので、表示されるのは 真っ黒な画面になります。
#include "Vtarunes_top.h"
#include "verilated.h"
#include "verilated_vcd_c.h"
#include <SDL2/SDL.h>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
int main(int argc, char** argv) {
Verilated::commandArgs(argc, argv);
Verilated::traceEverOn(true);
Vtarunes_top* dut = new Vtarunes_top;
VerilatedVcdC* tfp = new VerilatedVcdC;
dut->trace(tfp, 99);
tfp->open("wave.vcd");
// SDL2の初期化
if (SDL_Init(SDL_INIT_VIDEO) != 0) {
fprintf(stderr, "SDL_Init Error: %s\n", SDL_GetError());
exit(1);
}
// ウィンドウサイズ: 256x240を2倍スケーリング -> 512x480
const int SCREEN_WIDTH = 256;
const int SCREEN_HEIGHT = 240;
const int SCALE = 2;
const int WINDOW_WIDTH = SCREEN_WIDTH * SCALE;
const int WINDOW_HEIGHT = SCREEN_HEIGHT * SCALE;
SDL_Window* window = SDL_CreateWindow("tarunes",
100,
100,
WINDOW_WIDTH, WINDOW_HEIGHT,
SDL_WINDOW_SHOWN);
if (!window) {
fprintf(stderr, "SDL_CreateWindow Error: %s\n", SDL_GetError());
SDL_Quit();
exit(1);
}
SDL_Renderer* renderer = SDL_CreateRenderer(window, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
if (!renderer) {
fprintf(stderr, "SDL_CreateRenderer Error: %s\n", SDL_GetError());
SDL_DestroyWindow(window);
SDL_Quit();
exit(1);
}
SDL_Texture* texture = SDL_CreateTexture(renderer,
SDL_PIXELFORMAT_ARGB8888,
SDL_TEXTUREACCESS_STREAMING,
SCREEN_WIDTH, SCREEN_HEIGHT);
if (!texture) {
fprintf(stderr, "SDL_CreateTexture Error: %s\n", SDL_GetError());
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
exit(1);
}
// ピクセルバッファ (ARGB8888)
uint32_t pixel_buffer[SCREEN_HEIGHT][SCREEN_WIDTH];
memset(pixel_buffer, 0, sizeof(pixel_buffer));
dut->clk = 0;
dut->rst = 0;
const int RESET_CYCLES = 4;
const int SIM_CYCLES = 1000;
for (int cycle = 0; cycle < SIM_CYCLES; cycle++) {
if (cycle == RESET_CYCLES)
dut->rst = 1;
// Low
dut->clk = 0;
dut->eval();
tfp->dump(Verilated::time());
Verilated::timeInc(1);
// High
dut->clk = 1;
dut->eval();
tfp->dump(Verilated::time());
Verilated::timeInc(1);
// ピクセルバッファをテクスチャにコピーして表示
SDL_UpdateTexture(texture, NULL, pixel_buffer, SCREEN_WIDTH * sizeof(uint32_t));
SDL_RenderClear(renderer);
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);
}
// クリーンアップ
SDL_DestroyTexture(texture);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
tfp->close();
delete dut;
delete tfp;
return 0;
}
Makefile も更新します。
ここでは SDL_CFLAGS と SDL_LDFLAGS を追加してください。
なお、まだ SDL2 をインストールしていない場合は、
あらかじめ以下のコマンドでインストールしておきましょう。
sudo apt install libsdl2-dev
ついでに、make clean のあとに make が失敗する問題を解消するため、
RTL_SRCS を使うのではなく、Verylが生成するリストファイル(tarunes.f) を
参照するように変更します。
これで、make clean 後でもそのまま make が通るようになります。
VERILATOR := verilator
VERILATOR_FLAGS := -Wall --trace --Wno-fatal -GPROM_PATH=\"helloworld_prg.hex\"
RTL_DIR := target
VERYL_PROJ:= tarunes
TB_CPP := src/tb_top.cpp
TOP := $(VERYL_PROJ)_top
SDL_CFLAGS := $(shell sdl2-config --cflags)
SDL_LDFLAGS := $(shell sdl2-config --libs)
all: build
veryl-fmt:
veryl fmt
veryl-build: veryl-fmt
veryl build
build: veryl-build
$(VERILATOR) $(VERILATOR_FLAGS) \
--cc \
-f $(VERYL_PROJ).f \
--top-module $(TOP) \
--exe $(TB_CPP) \
-I$(RTL_DIR) \
-CFLAGS "-std=c++17 $(SDL_CFLAGS)" \
-LDFLAGS "$(SDL_LDFLAGS)"
make -C obj_dir -f V$(TOP).mk
run:
./obj_dir/V$(TOP)
clean:
rm -rf obj_dir target *.vcd
.PHONY: all build run clean veryl-fmt veryl-build
make が通ったら、続けて make run を実行してください。
真っ黒なウィンドウが表示されればOKです。
PPU追加
真っ黒な画面のままだとつまらないので、まずは 何かしら表示できるPPU を作ってみましょう。
ということで PPU.veryl を用意します。
……とはいえ、現時点ではまだ ハリボテPPU です。
ここでは最低限、
- cycle(X座標)
- scanline(Y座標)
- RGB信号出力
だけを実装します。
画面解像度は 256×240 ですが、PPUの内部的な走査範囲は 341×262 なので、
- cycle : 0〜340
- scanline : 0〜261
の値を取るようにしています。
また、NES_PALETTE はハードウェアで固定された値です。
実際にはチップのバージョンや個体差によって微妙に色味が違うらしいのですが、今回はこの値を使います。
とりあえず動いているのが分かるように、横16ピクセルごとに色が変わる表示にしています。
module ppu (
clk : input clock ,
rst : input reset ,
scanline: output logic<9>,
cycle : output logic<9>,
pixel_r : output logic<8>,
pixel_g : output logic<8>,
pixel_b : output logic<8>,
) {
var pixel_index : logic<6> ;
var palette_color: logic<24>;
always_ff {
if_reset {
cycle = 0;
scanline = 0;
} else {
if (cycle == 340) {
cycle = 0;
if (scanline == 261) {
scanline = 0;
} else {
scanline = scanline + 1;
}
} else {
cycle = cycle + 1;
}
}
}
// NES Palette (64 colors) - RGB332 format
const NES_PALETTE: logic<24> [64] = '{
24'h808080, 24'h003DA6, 24'h0012B0, 24'h440096,
24'hA1005E, 24'hC70028, 24'hBA0600, 24'h8C1700,
24'h5C2F00, 24'h104500, 24'h054A00, 24'h00472E,
24'h004166, 24'h000000, 24'h050505, 24'h050505,
24'hC7C7C7, 24'h0077FF, 24'h2155FF, 24'h8237FA,
24'hEB2FB5, 24'hFF2950, 24'hFF2200, 24'hD63200,
24'hC46200, 24'h358000, 24'h058F00, 24'h008A55,
24'h0099CC, 24'h212121, 24'h090909, 24'h090909,
24'hFFFFFF, 24'h0FD7FF, 24'h69A2FF, 24'hD480FF,
24'hFF45F3, 24'hFF618B, 24'hFF8833, 24'hFF9C12,
24'hFABC20, 24'h9FE30E, 24'h2BF035, 24'h0CF0A4,
24'h05FBFF, 24'h5E5E5E, 24'h0D0D0D, 24'h0D0D0D,
24'hFFFFFF, 24'hA6FCFF, 24'hB3ECFF, 24'hDAABEB,
24'hFFA8F9, 24'hFFABB3, 24'hFFD2B0, 24'hFFEFA6,
24'hFFF79C, 24'hD7E895, 24'hA6EDAF, 24'hA2F2DA,
24'h99FFFC, 24'hDDDDDD, 24'h111111, 24'h111111
};
always_comb {
if (cycle <: 256 && scanline <: 240) {
pixel_index = {2'b10, cycle[7:4]};
} else {
pixel_index = 'h0F;
}
// パレットルックアップ
palette_color = NES_PALETTE[pixel_index];
// 8ビットRGBに変換
pixel_r = {palette_color[23:16]};
pixel_g = {palette_color[15:8]};
pixel_b = {palette_color[7:0]};
}
}
それでは、作成したPPUを top.veryl に組み込みましょう。
あわせて、PPUから出力する信号に対応する端子の追加も忘れずに行います。
module top #(
param PROM_PATH: string = "",
) (
clk : input clock ,
rst : input reset ,
scanline: output logic<9>,
cycle : output logic<9>,
pixel_r : output logic<8>,
pixel_g : output logic<8>,
pixel_b : output logic<8>,
) {
...
inst ppu_inst: ppu (
clk : clk ,
rst : rst ,
scanline: scanline,
cycle : cycle ,
pixel_r : pixel_r ,
pixel_g : pixel_g ,
pixel_b : pixel_b ,
);
}
つぎに、scanline、cycle、pixel_r、pixel_g、pixel_b の値を使ってフレームバッファを更新する処理を追加します。
PPUから出力される各ピクセルのRGB値を、対応する座標のフレームバッファに書き込んでいく形です。
そして、最後の行の描画が終わったタイミングで、フレームバッファの内容を SDLのウィンドウに反映させます。
今回は10フレーム分描画したらシミュレーションを終了するようにします。
const int RESET_CYCLES = 4;
const int MAX_FRAMES = 10; // 10フレームで終了
int frame_count = 0;
int prev_scanline = 0;
bool running = true;
// メインループ
while (running && frame_count < MAX_FRAMES) {
// 1サイクルシミュレーション
for (int half = 0; half < 2; half++) {
if (Verilated::time() == RESET_CYCLES) {
dut->rst = 1;
}
dut->clk = half;
dut->eval();
tfp->dump(Verilated::time());
Verilated::timeInc(1);
}
// 有効ピクセル領域かチェック
int scanline = dut->scanline;
int cycle = dut->cycle;
int pixel_r = dut->pixel_r;
int pixel_g = dut->pixel_g;
int pixel_b = dut->pixel_b;
if (cycle < SCREEN_WIDTH && scanline < SCREEN_HEIGHT) {
// 直接RGB値を使用
// ARGB8888形式に変換 (Alpha=0xFF, R, G, B)
pixel_buffer[scanline][cycle] = 0xFF000000 |
((pixel_r & 0xFF) << 16) |
((pixel_g & 0xFF) << 8) |
(pixel_b & 0xFF);
}
// フレームカウント: scanlineが0に戻ったら1フレーム完了
if (prev_scanline == 261 && scanline == 0) {
frame_count++;
printf("Frame %d completed\n", frame_count);
// ピクセルバッファをテクスチャにコピーして表示
SDL_UpdateTexture(texture, NULL, pixel_buffer, SCREEN_WIDTH * sizeof(uint32_t));
SDL_RenderClear(renderer);
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);
}
prev_scanline = scanline;
}
printf("Simulation finished after %d frames\n", frame_count);
make して、make run を実行してください。
今度は カラフルな画面 が表示されるはずです。
これで、PPUから出力された信号をGUI上で確認できるようになりました。
自分で書いた回路の出力がそのまま画面に出てくるの、なんかいいですよね。こういうの。
PPUバスの追加
PPU用のバスを作ります。
PPUのメモリ空間は16KB(14bit幅)で、メモリマップは以下の通りです。
| アドレス | 内容 | 説明 |
|---|---|---|
| 0000-1FFF | キャラクタROM | ROMカートリッジの内容です。 |
| 2000-2FFF | VRAM | ビデオRAMです。4KB分領域がありますが、実装されるVRAMは2KB分だけです。 |
| 3000-3EFF | 未使用 | 未使用 |
| 3F00-3F0F | BGパレットテーブル | BG(背景)の色を選択します。 PPU内に実装しましょう。 |
| 3F10-3F1F | スプライトパレットテーブル | 今回は未使用なのでスルー。 |
| 3F20-3FFF | 未使用 | 未使用 |
要するに、
- 0000–1FFF → キャラクタROM
- 2000–2FFF → VRAM
にアクセスが振り分けられればOKです。
なお、3F00 以降のパレット領域はPPU内部で完結させるため、外部バスには実装不要です。
module bus_ppu (
clk : input clock ,
rst : input reset ,
ppubus : modport bus_if::<8, 14>::slave ,
crombus: modport bus_if::<8, 13>::master,
vrambus: modport bus_if::<8, 11>::master,
) {
var sel_crom: logic;
var sel_vram: logic;
// Addr sel
assign sel_crom = ppubus.addr <: 16'h2000;
assign sel_vram = ppubus.addr >= 16'h2000 && ppubus.addr <: 16'h3F00;
// address assign
assign crombus.addr = if sel_crom ? ppubus.addr : 'x;
assign vrambus.addr = if sel_vram ? ppubus.addr : 'x;
// Write bus assign
assign vrambus.wdata = if sel_vram ? ppubus.wdata : 'x;
assign vrambus.wen = if sel_vram ? ppubus.wen : 'x;
// rom
assign crombus.wdata = 0;
assign crombus.wen = 0;
// Read bus decode
var sel_crom_d: logic;
var sel_vram_d: logic;
always_ff {
if_reset {
sel_crom_d = 0;
sel_vram_d = 0;
} else {
sel_crom_d = sel_crom;
sel_vram_d = sel_vram;
}
}
always_comb {
if (sel_crom_d) {
ppubus.rdata = crombus.rdata;
} else if (sel_vram_d) {
ppubus.rdata = vrambus.rdata;
} else {
ppubus.rdata = 'x;
}
}
}
top.verylにキャラクタROM、VRAM、PPUを追加します。
VRAMは前作ったmemoryと同じでOKです。サイズは2KBなので11bit幅で。
module top #(
param PROM_PATH: string = "",
param CROM_PATH: string = "",
) (
clk : input clock ,
rst : input reset ,
scanline: output logic<9>,
cycle : output logic<9>,
pixel_r : output logic<8>,
pixel_g : output logic<8>,
pixel_b : output logic<8>,
) {
// 追加
inst ppubus : bus_if::<8, 14>;
inst vrambus: bus_if::<8, 11>;
inst crombus: bus_if::<8, 13>;
..
inst ubus_ppu: bus_ppu (
clk : clk ,
rst : rst ,
ppubus : ppubus ,
crombus: crombus,
vrambus: vrambus,
);
inst vram: memory::<8, 11> (
clk : clk ,
rst : rst ,
membus: vrambus,
);
inst crom: memory::<8, 13> #(
PATH: CROM_PATH,
) (
clk : clk ,
rst : rst ,
membus: crombus,
);
}
とりあえずmakeしてちゃんとなっているか確認しておきます。
VRAMへの書き込み
さて、HelloWorld!では、CPUは PPUのレジスタを経由して VRAMにデータを書き込んでいます。
そのため、このあたりの処理も実装していきましょう。
まずは、CPUバス側にPPUレジスタのアドレス範囲を登録します。
PPUのレジスタは、CPUメモリ空間上では
ここでは、そのデコード処理をCPUバスに追加します。
以下は、追加した部分だけを抜き出したコードです
(ついでに、モジュール名を cpu_bus に変更しています)
module bus_cpu (
...
cpu_ppubus: modport bus_if::<8, 3>::master ,
...
) {
var sel_ppu : logic;
...
assign sel_ppu = cpubus.addr >= 16'h2000 && cpubus.addr <= 16'h2007;
...
assign cpu_ppubus.addr = if sel_ppu ? cpubus.addr : 'x;
...
assign cpu_ppubus.wdata = if sel_ppu ? cpubus.wdata : 'x;
assign cpu_ppubus.wen = if sel_ppu ? cpubus.wen : 'x;
...
var sel_ppu_d : logic;
...
always_ff {
if_reset {
...
sel_ppu_d = 0;
...
} else {
...
sel_ppu_d = sel_ppu;
...
}
}
always_comb {
...
} else if (sel_ppu_d) {
cpubus.rdata = cpu_ppubus.rdata;
...
次に、PPUにレジスタ部を追加します。
ここでは CPU側からPPUを操作するためのインタフェースを実装します。
具体的には、
- CPUバス側:cpubus(スレーブ)
- PPU内部バス側:ppubus(マスタ)
をPPUに持たせ、CPUからのレジスタアクセスを受け取れるようにします。
今回、最低限実装するPPUレジスタは以下の2つです。
- $2006 : PPUADDR
- $2007 : PPUDATA
PPUADDR($2006)
CPUから 2回に分けて書き込みを行い、
アクセスしたい PPUメモリ空間のアドレスを指定します。
- 1回目の書き込み:上位8bit
- 2回目の書き込み:下位8bit
というおなじみの仕様です。
PPUDATA($2007)
PPUADDRで指定したアドレスに対して、データの読み書きを行うレジスタです。
今回は 書き込み(Write)のみを実装します。
- PPUDATAに書き込む
- 指定されたPPUメモリマップのアドレスにデータが書き込まれる
- 書き込み後、PPUADDRは自動でインクリメントされる
なお実機では、PPUレジスタ設定によってインクリメント量が +1 / +32 のどちらかになる仕様ですが、今回は該当するPPUレジスタを実装していないので、+1固定 として実装します。
このあたりまで実装できれば、CPUからVRAMやパレットRAMにデータを書き込むための最低限の土台が整います。
module ppu (
clk : input clock ,
rst : input reset ,
cpubus : modport bus_if::<8, 3>::slave ,
ppubus : modport bus_if::<8, 14>::master ,
...
// VRAM Address Latch
var reg_w: logic ;
var reg_v: logic<16>;
var palette_ram: logic<8> [32];
// register bus
always_ff {
if_reset {
ppubus.addr = 0;
ppubus.wen = 0;
ppubus.wdata = 0;
reg_w = 0;
reg_v = 0;
for i: u32 in 0..31 {
palette_ram[i] = 0;
}
} else {
// default
ppubus.wen = 0;
if (cpubus.wen) {
case (cpubus.addr) {
6: {
reg_w = ~reg_w;
if (!reg_w) {
reg_v[15:8] = cpubus.wdata;
} else {
reg_v[7:0] = cpubus.wdata;
}
}
7: {
$display("[PPU WRITE] addr=%h data=%02h", reg_v, cpubus.wdata);
// Palette RAM
if (reg_v[15:8] == 8'h3F) {
palette_ram[reg_v[4:0]] = cpubus.wdata;
} else {
ppubus.wen = 1;
ppubus.addr = reg_v[13:0];
ppubus.wdata = cpubus.wdata;
}
reg_v = reg_v + 1;
}
}
}
}
}
always_ff {
if_reset {
cpubus.rdata = 0;
}
}
...
ここで make して make run を実行すると、
以下のようなログと波形が確認できるはずです。
ログを見ると、
- パレットRAMへの書き込み
- VRAMへのタイルデータの書き込み
が行われていることが分かると思います。
これらのデータが正しく書き込めていれば、文字を表示するための下準備は完了です。
次はいよいよ、
PPUのレンダリング部を実装して、実際に画面に描画していきます。
ログをPPUでgrepしたもの(HELLO,WORLD!は後付け)
[PPU WRITE] addr=3f00 data=0f
[PPU WRITE] addr=3f01 data=00
[PPU WRITE] addr=3f02 data=10
[PPU WRITE] addr=3f03 data=20
[PPU WRITE] addr=3f04 data=0f
[PPU WRITE] addr=3f05 data=06
[PPU WRITE] addr=3f06 data=16
[PPU WRITE] addr=3f07 data=26
[PPU WRITE] addr=3f08 data=0f
[PPU WRITE] addr=3f09 data=08
[PPU WRITE] addr=3f0a data=18
[PPU WRITE] addr=3f0b data=28
[PPU WRITE] addr=3f0c data=0f
[PPU WRITE] addr=3f0d data=0a
[PPU WRITE] addr=3f0e data=1a
[PPU WRITE] addr=3f0f data=2a
[PPU WRITE] addr=21c9 data=48 // H
[PPU WRITE] addr=21ca data=45 // E
[PPU WRITE] addr=21cb data=4c // L
[PPU WRITE] addr=21cc data=4c // L
[PPU WRITE] addr=21cd data=4f // O
[PPU WRITE] addr=21ce data=2c // ,
[PPU WRITE] addr=21cf data=20 //
[PPU WRITE] addr=21d0 data=57 // W
[PPU WRITE] addr=21d1 data=4f // O
[PPU WRITE] addr=21d2 data=52 // R
[PPU WRITE] addr=21d3 data=4c // L
[PPU WRITE] addr=21d4 data=44 // D
[PPU WRITE] addr=21d5 data=21 // !
BGレンダリングの追加
PPUは、8x8ピクセルのタイル(tile) と呼ばれる単位ごとに画面を描画します。
各タイルのパターンデータは、キャラクタROM(CHR ROM) に格納されています。
レンダリングというと大変そうに聞こえますが、処理を順番に分解していけば何とかなります。
※とはいえ、処理内容自体はそれなりにややこしいです。
まず最初に、タイルのX,Y座標から、CHR ROM上のアドレスを計算します。
計算したアドレスと、その次のアドレスからCHR ROMのデータを2バイト分読み込みます。
1ピクセルはCHR ROMデータ2bitで表現されるため、8ピクセル分を描画するには
2バイト(16bit) のデータが必要になります。
実機のPPUでは、タイルのX,Y座標に対応する2bitのattr(属性)データもVRAMから読み出す必要があります。ただし、本実装では簡単のため、このattrデータは常に0固定とします。
これらの処理は外部バス(VRAMやCHR ROM)へのアクセスを伴うため、時間がかかります。
そのため、8ピクセル分をまとめて先に取得し、内部レジスタに保存しておきます。
保存しておいたattrデータと、CHR ROMから読み込んだデータを1クロックごとに1ピクセル分ずつ取り出して描画処理を行います。
まず、attrデータ2bitと、CHR ROMのデータ2bitを組み合わせて4bitのpixel indexを生成します。
この4bitの値をインデックスとして、3F00〜に配置したパレットRAMを参照します。
すると、使用するべきパレット番号(8bit) が得られます。
※ NESのパレットは52色しかないため、実際には下位6bitしか使われません。
得られたパレット番号をインデックスとして、あらかじめ定義しておいた NES_PALETTE を参照すると、RGB値が取得できます。
ここで一点注意があります。
基本的に、上記のタイルフェッチ処理は、現在描画しているタイルではなく、1タイル先のタイルに対して行います。
これを行わないと、表示結果が横方向に1タイルずれてしまいます。
そのため、現在の cycle と scanline から、次に描画するタイルのX,Y座標をあらかじめ計算しておく必要があります。
このあたりをまとめた最小限の実装が以下の通りです。
var visible: logic;
assign visible = cycle <: 256 && scanline <: 240;
// キャラクタROMのアドレス
var chr_addr: logic<14>;
// キャラクタROMのデータ
var plane : logic<8> [2];
var plane_draw: logic<8> [2];
// タイル情報と、次のタイル情報
var tile_x : logic<6>;
var tile_y : logic<6>;
var next_tile_x: logic<6>;
var next_tile_y: logic<6>;
assign tile_x = cycle[8:3];
assign tile_y = scanline[8:3];
assign next_tile_x = if (tile_x == 31) ? 0 : tile_x + 1;
assign next_tile_y = if (tile_x == 31) ? tile_y + 1 : tile_y;
// VRAMとキャラクタROMからデータを取ってくる
always_ff {
if_reset {
chr_addr = 0;
plane[0] = 0;
plane[1] = 0;
plane_draw[0] = 0;
plane_draw[1] = 0;
} else {
if (visible) {
case (cycle[2:0]) {
0: {
let nt_addr : logic<14> = 14'h2000 + next_tile_y * 32 + next_tile_x;
ppubus.addr = nt_addr;
}
// 1: {}
2: {
let tile_id : logic<8> = ppubus.rdata;
ppubus.addr = tile_id * 16 + scanline[2:0];
chr_addr = tile_id * 16 + scanline[2:0];
}
3: {
ppubus.addr = chr_addr + 8;
}
4: {
plane[0] = ppubus.rdata;
}
5: {
plane[1] = ppubus.rdata;
}
// 6: {}
// レジスタに保存
7: {
plane_draw[0] = plane[0];
plane_draw[1] = plane[1];
}
}
}
}
}
var color_2bit: logic<2>;
var fine_x : logic<3>;
// tile内のX座標0 -> {plane[1][7],plane[0][7]};
// tile内のX座標1 -> {plane[1][6],plane[0][6]};
// ...
// 保存していたROMデータをもとに1clkずつ情報をとりだす
assign fine_x = 7 - cycle[2:0];
assign color_2bit = {plane_draw[1][fine_x], plane_draw[0][fine_x]};
...
always_comb {
// pixel_index生成(attr=00固定)
if visiable {
pixel_index = palette_ram[{2'b00, color_2bit}];
} else {
pixel_index = 'h0F;
}
// パレットルックアップ
palette_color = NES_PALETTE[pixel_index];
// 8ビットRGBに変換
pixel_r = {palette_color[23:16]};
pixel_g = {palette_color[15:8]};
pixel_b = {palette_color[7:0]};
}
}
ここで makeして make runをすると、、、HelloWorld!が表示されます!素晴らしい!
ちなみに、attr属性を手で変えてみると色が変わります。面白いですね!
pixel_index = palette_ram[{2'b10, color_2bit}];
FPGAに乗せるために、ちょこっと修正
ここまででBGレンダリング自体は一通り動くようになります。
……と、言いたいところですが、このままではFPGAに実装すると怒られます。
理由は単純で、ppubus.addr を 複数の always_ff から同時にドライブしているためです。
シミュレーションでは問題なく見えても、FPGAではアウトです。
今回の構成では、
- レジスタアクセス部:CPUからのPPUレジスタ書き込み(ライトのみ)
- レンダリング部:BG描画用のVRAM / CHR ROM読み出し(リードのみ)
と役割がはっきり分かれています。
そこで、それぞれの always_ff で 専用のアドレスレジスタにラッチし、ppubus.wen 信号を使って、「今どちらのアドレスを使うか」を MUXで切り替える形にします。
// ppubus.address mux
var ppu_addr_cpu : logic<14>;
var ppu_addr_render: logic<14>;
assign ppubus.addr = if ppubus.wen ? ppu_addr_cpu : ppu_addr_render;
// register bus
always_ff {
if_reset {
ppu_addr_cpu = 0;
...
7: {
$display("[PPU WRITE] addr=%h data=%02h", reg_v, cpubus.wdata);
...
} else {
ppubus.wen = 1;
ppu_addr_cpu = reg_v[13:0];
ppubus.wdata = cpubus.wdata;
}
...
always_ff {
if_reset {
ppu_addr_render = 0;
...
} else {
if (visiable) {
case (cycle[2:0]) {
0: {
let nt_addr : logic<14> = 14'h2000 + next_tile_y * 32 + next_tile_x;
ppu_addr_render = nt_addr;
}
// 1: {}
2: {
let tile_id : logic<8> = ppubus.rdata;
ppu_addr_render = tile_id * 16 + scanline[2:0];
chr_addr = tile_id * 16 + scanline[2:0];
}
3: {
ppu_addr_render = chr_addr + 8;
このように アドレスを一度レジスタに受けてからMUXで切り替えることで、
- 複数ドライバ問題を回避できる
- FPGA実装でも素直に合成できる
構成になります。
※実機PPUとは内部構成は異なりますが、とりあえずはこれで。
まとめ
というわけで、PPUを実装して画面に文字を出すところまでたどり着きました。
- Verilator + SDL2 でGUI表示
- PPUの走査線・サイクル生成
- VRAM / CHR ROM / パレットRAMの実装
- CPUからのPPUレジスタ経由の書き込み
- BGタイルフェッチ&レンダリング
- 「HELLO, WORLD!」の表示
と、ファミコンの「画面が出るまで」に必要な最低限の要素が一通りそろっています。
実機PPUと比べると、
- スクロール未実装
- Attributeは固定
- スプライト未実装
- タイミングもかなり簡略化
と、省略している部分は多いですが、CPUがVRAMに書いたデータが、PPUを通って画面に出る という一番おいしいところは、ちゃんと体験できる構成になっています。
シミュレーション上とはいえ、自分で書いたRTLから直接ピクセルが出てくる瞬間は、やっぱりテンション上がりますね。
おわりに
次回はいよいよ FPGAに載せて実画面に出す予定です。
想定ハードはTang Nano 20Kです。
https://akizukidenshi.com/catalog/g/g130974/
(やっとタイトルに書いてあるFPGAが出てきます)



Comments
Let's comment your feelings that are more than good